rustwright-core 0.1.1

Rust CDP core for a Python Playwright-compatible automation API
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
# frozen_string_literal: true

require 'json'
require 'monitor'
require 'rbconfig'

module Rustwright
  VERSION = '0.1.0'
  UNSET = Object.new.freeze

  class Error < StandardError; end
  class ClosedError < Error; end

  module Normalize
    module_function

    LAUNCH_KEYS = {
      'headless' => 'headless',
      'executablePath' => 'executable_path',
      'executable_path' => 'executable_path',
      'channel' => 'channel',
      'args' => 'args',
      'ignoreAllDefaultArgs' => 'ignore_all_default_args',
      'ignore_all_default_args' => 'ignore_all_default_args',
      'ignoreDefaultArgs' => 'ignore_default_args',
      'ignore_default_args' => 'ignore_default_args',
      'timeout' => 'timeout',
      'userDataDir' => 'user_data_dir',
      'user_data_dir' => 'user_data_dir',
      'env' => 'env',
      'chromiumSandbox' => 'chromium_sandbox',
      'chromium_sandbox' => 'chromium_sandbox',
      'proxy' => 'proxy'
    }.freeze

    SCREENSHOT_KEYS = {
      'path' => 'path',
      'fullPage' => 'fullPage',
      'full_page' => 'fullPage',
      'clip' => 'clip',
      'timeout' => 'timeout',
      'type' => 'type',
      'quality' => 'quality',
      'omitBackground' => 'omitBackground',
      'omit_background' => 'omitBackground'
    }.freeze

    def launch(options)
      normalize_known_hash(options || {}, LAUNCH_KEYS, 'launch options')
    end

    def screenshot(options)
      return nil if options.nil? || options.empty?

      normalize_known_hash(options, SCREENSHOT_KEYS, 'screenshot options')
    end

    def option_hash(options, keywords, allowed, context)
      base = options.nil? ? {} : options
      raise ArgumentError, "#{context} must be a Hash" unless base.is_a?(Hash)

      merged = base.merge(keywords)
      normalize_known_hash(merged, allowed, context)
    end

    def normalize_known_hash(value, mapping, context)
      raise ArgumentError, "#{context} must be a Hash" unless value.is_a?(Hash)

      value.each_with_object({}) do |(key, item), normalized|
        source_key = key.to_s
        target_key = mapping[source_key]
        raise ArgumentError, "unknown #{context} key #{key.inspect}" unless target_key
        raise ArgumentError, "duplicate #{context} key #{target_key.inspect}" if normalized.key?(target_key)

        normalized[target_key] = json_value(item)
      end
    end

    def json_value(value)
      case value
      when Hash
        value.each_with_object({}) { |(key, item), result| result[key.to_s] = json_value(item) }
      when Array
        value.map { |item| json_value(item) }
      when Symbol
        value.to_s
      else
        value
      end
    end
  end
end

require_relative 'rustwright/native'
require_relative 'rustwright/wire'

module Rustwright
  class << self
    def default_library_path
      extension = RbConfig::CONFIG['host_os'].match?(/darwin/) ? 'dylib' : 'so'
      File.join('target', 'release', "librustwright_capi.#{extension}")
    end

    def chromium(library_path: nil)
      path = library_path || ENV['RUSTWRIGHT_CAPI_LIB'] || default_library_path
      Chromium.new(native_for(path))
    end

    def inline_html_url(html)
      raise ArgumentError, 'html must be a String' unless html.is_a?(String)

      encoded = html.encode(Encoding::UTF_8).bytes.map do |byte|
        if (byte >= 65 && byte <= 90) || (byte >= 97 && byte <= 122) ||
           (byte >= 48 && byte <= 57) || [45, 46, 95, 126].include?(byte)
          byte.chr
        else
          format('%%%02X', byte)
        end
      end.join
      "data:text/html;charset=utf-8,#{encoded}"
    end

    private

    def native_for(path)
      expanded = File.expand_path(path)
      native_mutex.synchronize do
        native_instances[expanded] ||= Native.new(expanded)
      end
    end

    def native_instances
      @native_instances ||= {}
    end

    def native_mutex
      @native_mutex ||= Mutex.new
    end
  end

  class Chromium
    def initialize(native)
      @native = native
    end

    def executable_path
      out = @native.pointer_slot
      status = @native.call(:rw_chromium_executable_path, out)
      @native.check_status!(status, 'rw_chromium_executable_path')
      @native.copy_owned_string(@native.pointer_address(out), nullable: true)
    end

    def launch(options = nil, **keywords)
      base = options || {}
      raise ArgumentError, 'launch options must be a Hash' unless base.is_a?(Hash)

      merged = base.merge(keywords)
      normalized = Normalize.launch(merged)
      out = @native.pointer_slot
      status = @native.call(:rw_chromium_launch, JSON.generate(normalized), out)
      @native.check_status!(status, 'rw_chromium_launch')
      handle = @native.pointer_address(out)
      raise Error, 'rw_chromium_launch returned an unexpected NULL browser' if handle.zero?

      Browser.new(@native, handle)
    end
  end

  class Browser
    def initialize(native, handle)
      @native = native
      @handle = handle
      @monitor = Monitor.new
      @pages = []
    end

    def new_page
      synchronize do
        ensure_open!
        out = @native.pointer_slot
        status = @native.call(:rw_browser_new_page, @handle, out)
        @native.check_status!(status, 'rw_browser_new_page')
        page_handle = @native.pointer_address(out)
        raise Error, 'rw_browser_new_page returned an unexpected NULL page' if page_handle.zero?

        Page.new(@native, self, page_handle).tap { |page| @pages << page }
      end
    end

    def ws_endpoint
      synchronize do
        ensure_open!
        pointer = @native.call(:rw_browser_ws_endpoint, @handle)
        @native.raise_null_error!('rw_browser_ws_endpoint') if @native.null?(pointer)
        @native.copy_owned_string(pointer)
      end
    end

    def close
      synchronize do
        return nil if @handle.nil?

        errors = []
        @pages.dup.each do |page|
          begin
            page.close
          rescue StandardError => e
            errors << e
          end
        end

        handle = @handle
        begin
          status = @native.call(:rw_browser_close, handle)
          @native.check_status!(status, 'rw_browser_close')
        rescue StandardError => e
          errors << e
        ensure
          @native.call(:rw_browser_free, handle)
          @handle = nil
          @pages.clear
        end

        raise errors.first unless errors.empty?

        nil
      end
    end

    def closed?
      synchronize { @handle.nil? }
    end

    # Internal synchronization shared by every page belonging to this browser.
    def synchronize(&block)
      @monitor.synchronize(&block)
    end

    def remove_page(page)
      synchronize { @pages.delete(page) }
    end

    private

    def ensure_open!
      raise ClosedError, 'browser is closed' if @handle.nil?
    end
  end

  class Page
    SIMPLE_TIMEOUT_KEYS = { 'timeout' => 'timeout' }.freeze
    GOTO_KEYS = {
      'waitUntil' => 'wait_until',
      'wait_until' => 'wait_until',
      'timeout' => 'timeout',
      'referer' => 'referer'
    }.freeze
    CLOSE_KEYS = {
      'timeout' => 'timeout',
      'runBeforeUnload' => 'run_before_unload',
      'run_before_unload' => 'run_before_unload'
    }.freeze

    def initialize(native, browser, handle)
      @native = native
      @browser = browser
      @handle = handle
      @monitor = Monitor.new
    end

    def target_id
      native_call do
        pointer = @native.call(:rw_page_target_id, @handle)
        @native.raise_null_error!('rw_page_target_id') if @native.null?(pointer)
        @native.copy_owned_string(pointer)
      end
    end

    def goto(url, options = nil, **keywords)
      opts = Normalize.option_hash(options, keywords, GOTO_KEYS, 'goto options')
      url = input_string(url, 'url')
      wait_until = nullable_input_string(opts['wait_until'], 'wait_until')
      referer = nullable_input_string(opts['referer'], 'referer')

      native_call do
        out = @native.pointer_slot
        status = @native.call(
          :rw_page_goto,
          @handle,
          url,
          wait_until || 0,
          timeout(opts['timeout']),
          referer || 0,
          out
        )
        @native.check_status!(status, 'rw_page_goto')
        json = @native.copy_owned_string(@native.pointer_address(out), nullable: true)
        json.nil? ? nil : JSON.parse(json)
      end
    end

    def click(selector, options = nil, **keywords)
      opts = Normalize.option_hash(options, keywords, SIMPLE_TIMEOUT_KEYS, 'click options')
      selector = input_string(selector, 'selector')
      native_call do
        status = @native.call(:rw_page_click, @handle, selector, timeout(opts['timeout']))
        @native.check_status!(status, 'rw_page_click')
        nil
      end
    end

    def fill(selector, value, options = nil, **keywords)
      opts = Normalize.option_hash(options, keywords, SIMPLE_TIMEOUT_KEYS, 'fill options')
      selector = input_string(selector, 'selector')
      value = input_string(value, 'value')
      native_call do
        status = @native.call(:rw_page_fill, @handle, selector, value, timeout(opts['timeout']))
        @native.check_status!(status, 'rw_page_fill')
        nil
      end
    end

    def title(options = nil, **keywords)
      opts = Normalize.option_hash(options, keywords, SIMPLE_TIMEOUT_KEYS, 'title options')
      native_call do
        out = @native.pointer_slot
        status = @native.call(:rw_page_title, @handle, timeout(opts['timeout']), out)
        @native.check_status!(status, 'rw_page_title')
        @native.copy_owned_string(@native.pointer_address(out))
      end
    end

    def text_content(selector, options = nil, **keywords)
      opts = Normalize.option_hash(options, keywords, SIMPLE_TIMEOUT_KEYS, 'text_content options')
      selector = input_string(selector, 'selector')
      native_call do
        out = @native.pointer_slot
        status = @native.call(:rw_page_text_content, @handle, selector, timeout(opts['timeout']), out)
        @native.check_status!(status, 'rw_page_text_content')
        @native.copy_owned_string(@native.pointer_address(out), nullable: true)
      end
    end

    def evaluate(expression, argument = UNSET, options = nil, **keywords)
      opts = Normalize.option_hash(options, keywords, SIMPLE_TIMEOUT_KEYS, 'evaluate options')
      expression = input_string(expression, 'expression')
      argument_json = argument.equal?(UNSET) ? nil : JSON.generate(argument)
      native_call do
        out = @native.pointer_slot
        status = @native.call(
          :rw_page_evaluate,
          @handle,
          expression,
          argument_json || 0,
          timeout(opts['timeout']),
          out
        )
        @native.check_status!(status, 'rw_page_evaluate')
        json = @native.copy_owned_string(@native.pointer_address(out))
        Wire.decode(JSON.parse(json))
      end
    end

    def screenshot(options = nil, **keywords)
      base = options || {}
      raise ArgumentError, 'screenshot options must be a Hash' unless base.is_a?(Hash)

      merged = base.merge(keywords)
      normalized = Normalize.screenshot(merged)
      options_json = normalized.nil? ? nil : JSON.generate(normalized)
      native_call do
        out_buffer = @native.pointer_slot
        out_length = @native.size_slot
        status = @native.call(
          :rw_page_screenshot,
          @handle,
          options_json || 0,
          out_buffer,
          out_length
        )
        @native.check_status!(status, 'rw_page_screenshot')
        @native.copy_owned_bytes(
          @native.pointer_address(out_buffer),
          @native.size_value(out_length)
        )
      end
    end

    def close(options = nil, **keywords)
      opts = Normalize.option_hash(options, keywords, CLOSE_KEYS, 'close options')
      @browser.synchronize do
        @monitor.synchronize do
          return nil if @handle.nil?

          handle = @handle
          begin
            status = @native.call(
              :rw_page_close,
              handle,
              timeout(opts['timeout']),
              opts['run_before_unload'] ? 1 : 0
            )
            @native.check_status!(status, 'rw_page_close')
          ensure
            @native.call(:rw_page_free, handle)
            @handle = nil
            @browser.remove_page(self)
          end
        end
      end
      nil
    end

    def closed?
      @browser.synchronize { @monitor.synchronize { @handle.nil? } }
    end

    private

    def native_call
      @browser.synchronize do
        @monitor.synchronize do
          raise ClosedError, 'page is closed' if @handle.nil?
          raise ClosedError, 'browser is closed' if @browser.closed?

          yield
        end
      end
    end

    def timeout(value)
      return Float::NAN if value.nil?

      Float(value)
    rescue ArgumentError, TypeError
      raise ArgumentError, "timeout must be numeric, got #{value.inspect}"
    end

    def input_string(value, name)
      raise ArgumentError, "#{name} must be a String" unless value.is_a?(String)

      value.encode(Encoding::UTF_8)
    end

    def nullable_input_string(value, name)
      value.nil? ? nil : input_string(value, name)
    end
  end
end