ruby_native 0.8.2 → 0.9.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 48ed2ba9df701df93f8e2f323e2de1a7fd3eecb5bb110be14ca31f2e411e9c36
4
- data.tar.gz: 79506084ad07267d3536c30ff281a65bd7c0c20a8851a20556b5a5ba49d165aa
3
+ metadata.gz: e1dba22f3ed73cde73344241945e6862510cb3d0f42560f77225b5f9ad9e78d4
4
+ data.tar.gz: 7cf291e917138449546715e585cc3926842b94dcafcf7c85d3ea439f7b17edd6
5
5
  SHA512:
6
- metadata.gz: 1953585bc5fc67ad9d2d86de072497695239ec5873d58a7cb8dfa8191b1abfd94173ace3036a7b6442ef04b213f28be7f76ca1ebc6a2c7adf5edd8587cb406e7
7
- data.tar.gz: d7cd25a3cc33a9ce35a815f2b9d5a627b8e479d2ebaa6d25ab9ea23b5ebeb759b4e679e412ca347721c9c86a9c78a25848c065fe38a2619f171c509bec83466b
6
+ metadata.gz: 0daccf8ec40c9f0c4514b618fff3bcbaffcda78e8777a6ddc2b5b5aca0bbe7844f3a7ab5e1a5c83c3875c4889b6531cf65bd09b949353636f66d0c3f3d62a8fb
7
+ data.tar.gz: e8a3d231802d4f4324a664f1e6bcaaba5a69ca9a95ad03d531d24185e1b816df739c8baa363587b300a95a5377c8513aea8a888e56a498aa2af45dec92bcbf8e
@@ -0,0 +1,62 @@
1
+ module RubyNative
2
+ module Screenshots
3
+ # Validates the per-app screenshot key, signs in the configured screenshot
4
+ # user, and sets a session-scoped cookie that the host app can use to
5
+ # render deterministically (freeze timestamps, hide notifications, etc.).
6
+ #
7
+ # The key is accepted via the `X-RubyNative-Screenshot-Key` header or the
8
+ # `?ruby_native_screenshot_key=` URL parameter. WKWebView drops custom
9
+ # headers across redirect chains in some iOS versions, so the URL-param
10
+ # fallback is the primary path used by Ruby Native's screenshot pipeline.
11
+ class SessionsController < ::ActionController::Base
12
+ def show
13
+ # Defense in depth: prevent the URL (which carries the key as a query
14
+ # param on the way in) from leaking via the Referer header to anything
15
+ # the redirect target loads.
16
+ response.headers["Referrer-Policy"] = "no-referrer"
17
+
18
+ unless RubyNative.screenshot_key.present? && RubyNative.screenshot_sign_in.present?
19
+ Rails.logger.info { "[RubyNative] /native/screenshots/session called but screenshot config is not set" }
20
+ head :not_found
21
+ return
22
+ end
23
+
24
+ unless valid_key?
25
+ Rails.logger.info { "[RubyNative] /native/screenshots/session rejected: invalid key" }
26
+ head :unauthorized
27
+ return
28
+ end
29
+
30
+ RubyNative.screenshot_sign_in.call(self)
31
+
32
+ cookies[:_ruby_native_screenshot_session] = {
33
+ value: "1",
34
+ httponly: true,
35
+ secure: request.ssl?,
36
+ same_site: :lax
37
+ }
38
+
39
+ redirect_to safe_return_to, allow_other_host: false
40
+ end
41
+
42
+ private
43
+
44
+ def valid_key?
45
+ provided = request.headers["X-RubyNative-Screenshot-Key"].presence || params[:ruby_native_screenshot_key].presence
46
+ return false unless provided
47
+ ActiveSupport::SecurityUtils.secure_compare(provided.to_s, RubyNative.screenshot_key.to_s)
48
+ end
49
+
50
+ def safe_return_to
51
+ target = params[:return_to].to_s
52
+ return "/" if target.empty?
53
+ # Reject anything that isn't a single-leading-slash same-host path.
54
+ # `//evil.com` and `/\evil.com` would be treated as protocol-relative
55
+ # or backslash-confusing inputs by some browsers.
56
+ return "/" if target.start_with?("//", "/\\")
57
+ return "/" unless target.start_with?("/")
58
+ target
59
+ end
60
+ end
61
+ end
62
+ end
data/config/routes.rb CHANGED
@@ -15,4 +15,7 @@ RubyNative::Engine.routes.draw do
15
15
  post "completions/:uuid", to: "completions#create", as: :completion
16
16
  resource :restore, only: :create
17
17
  end
18
+ namespace :screenshots do
19
+ resource :session, only: :show, controller: "sessions"
20
+ end
18
21
  end
@@ -25,7 +25,7 @@ module RubyNative
25
25
  return unless File.exist?(gitignore)
26
26
  return if File.read(gitignore).include?(".ruby_native")
27
27
 
28
- append_to_file ".gitignore", "\n# Ruby Native (Playwright, screenshots, session data)\n.ruby_native/\n"
28
+ append_to_file ".gitignore", "\n# Ruby Native\n.ruby_native/\n"
29
29
  say " Added .ruby_native/ to .gitignore", :green
30
30
  end
31
31
 
@@ -154,7 +154,6 @@ Set `RUBY_NATIVE_TOKEN` as an environment variable for CI (no interactive login
154
154
  bundle exec ruby_native login # authenticate (opens browser)
155
155
  bundle exec ruby_native deploy # trigger a build
156
156
  bundle exec ruby_native preview # start a tunnel and display a QR code
157
- bundle exec ruby_native screenshots # capture App Store screenshots
158
157
  bundle exec ruby_native logout # remove stored credentials
159
158
  ```
160
159
 
@@ -2,7 +2,6 @@ require "ruby_native/cli/credentials"
2
2
  require "ruby_native/cli/deploy"
3
3
  require "ruby_native/cli/login"
4
4
  require "ruby_native/cli/preview"
5
- require "ruby_native/cli/screenshots"
6
5
 
7
6
  module RubyNative
8
7
  class CLI
@@ -13,13 +12,16 @@ module RubyNative
13
12
  RubyNative::CLI::Deploy.new(argv).run
14
13
  when "preview"
15
14
  RubyNative::CLI::Preview.new(argv).run
16
- when "screenshots"
17
- RubyNative::CLI::Screenshots.new(argv).run
18
15
  when "login"
19
16
  RubyNative::CLI::Login.new(argv).run
20
17
  when "logout"
21
18
  RubyNative::CLI::Credentials.clear
22
19
  puts "Logged out of Ruby Native."
20
+ when "screenshots"
21
+ warn "ruby_native screenshots was removed in 0.9.0."
22
+ warn "Screenshots are now captured by rubynative.com against your deployed site."
23
+ warn "See https://rubynative.com/docs/ship/screenshots for the new flow."
24
+ exit 1
23
25
  else
24
26
  puts "Usage: ruby_native <command>"
25
27
  puts ""
@@ -28,7 +30,6 @@ module RubyNative
28
30
  puts " login Authenticate with Ruby Native"
29
31
  puts " logout Remove stored credentials"
30
32
  puts " preview Start a tunnel and display a QR code"
31
- puts " screenshots Capture web screenshots for App Store images"
32
33
  end
33
34
  end
34
35
  end
@@ -9,7 +9,7 @@ module RubyNative
9
9
  end
10
10
 
11
11
  initializer "ruby_native.filter_params" do |app|
12
- app.config.filter_parameters += [:signedPayload]
12
+ app.config.filter_parameters += [:signedPayload, :ruby_native_screenshot_key]
13
13
  end
14
14
 
15
15
  initializer "ruby_native.helpers" do
@@ -1,5 +1,18 @@
1
1
  module RubyNative
2
2
  module Helper
3
+ # True when the current request is part of a Ruby Native screenshot run.
4
+ # Use this to render deterministically: freeze relative timestamps, hide
5
+ # push banners, suppress ads, disable A/B variants, skip notifications.
6
+ #
7
+ # <% if ruby_native_screenshot_session? %>
8
+ # Stamped 2 days ago
9
+ # <% else %>
10
+ # <%= time_ago_in_words(stamp.created_at) %>
11
+ # <% end %>
12
+ def ruby_native_screenshot_session?
13
+ cookies[:_ruby_native_screenshot_session] == "1"
14
+ end
15
+
3
16
  def native_tabs_tag(enabled: true)
4
17
  return "".html_safe unless enabled
5
18
  tag.div(data: { native_tabs: true }, hidden: true)
@@ -1,3 +1,3 @@
1
1
  module RubyNative
2
- VERSION = "0.8.2"
2
+ VERSION = "0.9.0"
3
3
  end
data/lib/ruby_native.rb CHANGED
@@ -16,6 +16,14 @@ module RubyNative
16
16
  mattr_accessor :config
17
17
  mattr_accessor :subscription_callbacks, default: []
18
18
 
19
+ # Screenshot configuration. Set via `RubyNative.configure` in an initializer.
20
+ mattr_accessor :screenshot_key
21
+ mattr_accessor :screenshot_sign_in
22
+
23
+ def self.configure
24
+ yield self
25
+ end
26
+
19
27
  def self.on_subscription_change(&block)
20
28
  subscription_callbacks << block
21
29
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_native
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.2
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Joe Masilotti
@@ -70,6 +70,7 @@ files:
70
70
  - app/controllers/ruby_native/iap/purchases_controller.rb
71
71
  - app/controllers/ruby_native/iap/restores_controller.rb
72
72
  - app/controllers/ruby_native/push/devices_controller.rb
73
+ - app/controllers/ruby_native/screenshots/sessions_controller.rb
73
74
  - app/controllers/ruby_native/webhooks/apple_controller.rb
74
75
  - app/javascript/ruby_native/back.js
75
76
  - app/models/ruby_native/iap/purchase_intent.rb
@@ -89,7 +90,6 @@ files:
89
90
  - lib/ruby_native/cli/deploy.rb
90
91
  - lib/ruby_native/cli/login.rb
91
92
  - lib/ruby_native/cli/preview.rb
92
- - lib/ruby_native/cli/screenshots.rb
93
93
  - lib/ruby_native/engine.rb
94
94
  - lib/ruby_native/helper.rb
95
95
  - lib/ruby_native/iap/apple_webhook_processor.rb
@@ -1,590 +0,0 @@
1
- require "json"
2
- require "open3"
3
- require "fileutils"
4
- require "tempfile"
5
- require "net/http"
6
- require "uri"
7
- require "ruby_native/cli/credentials"
8
-
9
- module RubyNative
10
- class CLI
11
- class Screenshots
12
- STORAGE_DIR = ".ruby_native"
13
- STORAGE_FILE = "screenshots_storage.json"
14
- OUTPUT_DIR = ".ruby_native/screenshots"
15
- CONFIG_PATH = "config/ruby_native.yml"
16
- SCALE = 3
17
- WIDTH_PX = 1320
18
- HEIGHT_PX = 2868
19
- WIDTH_PT = WIDTH_PX / SCALE # 440
20
- HEIGHT_PT = HEIGHT_PX / SCALE # 956
21
-
22
- HOST = ENV.fetch("RUBY_NATIVE_HOST", "https://rubynative.com")
23
-
24
- def initialize(argv)
25
- @url = parse_option(argv, "--url", nil)
26
- @port = parse_option(argv, "--port", nil)
27
- @output = parse_option(argv, "--output", OUTPUT_DIR)
28
- @login = argv.delete("--login")
29
-
30
- if @url
31
- unless @url.match?(%r{\Ahttps?://})
32
- host = @url.split(":", 2).first
33
- scheme = (host == "localhost" || host.match?(/\A\d+\.\d+\.\d+\.\d+\z/)) ? "http" : "https"
34
- @url = "#{scheme}://#{@url}"
35
- end
36
- @url = @url.chomp("/")
37
- @port = URI(@url).port if @port.nil?
38
- else
39
- @port = (@port || 3000).to_i
40
- end
41
- end
42
-
43
- def run
44
- load_config!
45
- check_node!
46
- check_playwright!
47
-
48
- if @login
49
- run_login
50
- else
51
- run_setup unless screenshots_configured?
52
- check_server!
53
- capture_screenshots
54
- upload_screenshots if credentials_available?
55
- end
56
- end
57
-
58
- private
59
-
60
- def parse_option(argv, flag, default)
61
- index = argv.index(flag)
62
- if index
63
- argv.delete_at(index)
64
- argv.delete_at(index) || default
65
- else
66
- default
67
- end
68
- end
69
-
70
- # --- Config ---
71
-
72
- def load_config!
73
- unless File.exist?(CONFIG_PATH)
74
- puts "config/ruby_native.yml not found. Run `rails generate ruby_native:install` first."
75
- exit 1
76
- end
77
-
78
- require "yaml"
79
- @config = YAML.load_file(CONFIG_PATH, symbolize_names: true) || {}
80
- end
81
-
82
- def screenshots_configured?
83
- paths = @config.dig(:screenshots, :paths)
84
- paths.is_a?(Array) && !paths.empty?
85
- end
86
-
87
- def screenshot_paths
88
- @config.dig(:screenshots, :paths) || []
89
- end
90
-
91
- def tab_paths
92
- tabs = @config[:tabs] || []
93
- tabs.map { |tab| tab[:path] }.compact
94
- end
95
-
96
- # --- First-run setup ---
97
-
98
- def run_setup
99
- puts "Let's set up screenshots! (one-time)"
100
- puts ""
101
-
102
- paths = prompt_for_paths
103
- write_paths_to_config(paths)
104
- prompt_for_login
105
-
106
- puts ""
107
- end
108
-
109
- def prompt_for_paths
110
- tabs = tab_paths
111
-
112
- if tabs.any?
113
- puts "Which paths do you want to capture?"
114
- puts "Your tabs: #{tabs.join(", ")}"
115
- puts "Enter paths (comma-separated) or press Enter to use tab paths:"
116
- else
117
- puts "Which paths do you want to capture?"
118
- puts "Enter paths (comma-separated):"
119
- end
120
-
121
- print "> "
122
- input = $stdin.gets&.strip || ""
123
-
124
- if input.empty?
125
- if tabs.any?
126
- tabs
127
- else
128
- puts "No paths entered and no tabs configured."
129
- exit 1
130
- end
131
- else
132
- input.split(",").map(&:strip).reject(&:empty?).map { |p| p.start_with?("/") ? p : "/#{p}" }
133
- end
134
- end
135
-
136
- def write_paths_to_config(paths)
137
- raw = File.read(CONFIG_PATH)
138
-
139
- yaml_paths = paths.map { |p| " - #{p}" }.join("\n")
140
- screenshot_block = "\nscreenshots:\n paths:\n#{yaml_paths}\n"
141
-
142
- File.write(CONFIG_PATH, raw.rstrip + "\n" + screenshot_block)
143
-
144
- # Reload config
145
- require "yaml"
146
- @config = YAML.load_file(CONFIG_PATH, symbolize_names: true) || {}
147
-
148
- puts ""
149
- puts "Added to #{CONFIG_PATH}:"
150
- puts ""
151
- puts " screenshots:"
152
- puts " paths:"
153
- paths.each { |p| puts " - #{p}" }
154
- end
155
-
156
- def prompt_for_login
157
- puts ""
158
- puts "Does your app require sign-in? (y/n)"
159
- print "> "
160
- input = $stdin.gets&.strip&.downcase || ""
161
-
162
- if input == "y" || input == "yes"
163
- puts ""
164
- run_login
165
- end
166
- end
167
-
168
- # --- Checks ---
169
-
170
- def check_node!
171
- _, _, status = Open3.capture3("node", "--version")
172
- unless status.success?
173
- puts "Node.js is required for screenshots. Install it from https://nodejs.org"
174
- exit 1
175
- end
176
- end
177
-
178
- def check_server!
179
- uri = URI("#{base_url}/")
180
- http = Net::HTTP.new(uri.host, uri.port)
181
- http.use_ssl = uri.scheme == "https"
182
- http.open_timeout = 5
183
- http.request(Net::HTTP::Head.new(uri))
184
- rescue Errno::ECONNREFUSED, Errno::ECONNRESET, SocketError, Net::OpenTimeout
185
- if @url
186
- puts "Could not reach #{@url}."
187
- else
188
- puts "No server running on port #{@port}."
189
- puts ""
190
- puts "Start your Rails server first:"
191
- puts " bin/rails server#{" -p #{@port}" if @port != 3000}"
192
- end
193
- puts ""
194
- puts "Then run this command again."
195
- exit 1
196
- end
197
-
198
- def check_playwright!
199
- FileUtils.mkdir_p(STORAGE_DIR)
200
- add_to_gitignore
201
-
202
- playwright_dir = File.join(STORAGE_DIR, "node_modules", "playwright")
203
- unless File.exist?(playwright_dir)
204
- puts "Playwright is required for screenshots."
205
- puts "Install it now? (y/n)"
206
- print "> "
207
- input = $stdin.gets&.strip&.downcase || ""
208
- exit 0 unless input == "y" || input == "yes"
209
-
210
- puts ""
211
- puts "Installing Playwright..."
212
- system("npm", "install", "--prefix", STORAGE_DIR, "playwright", out: File::NULL, err: File::NULL)
213
-
214
- puts "Installing WebKit browser..."
215
- system("npx", "--prefix", STORAGE_DIR, "playwright", "install", "webkit", out: File::NULL)
216
-
217
- unless File.exist?(playwright_dir)
218
- puts ""
219
- puts "Failed to install Playwright. Install it manually:"
220
- puts " npm install playwright"
221
- puts " npx playwright install webkit"
222
- exit 1
223
- end
224
-
225
- puts ""
226
- end
227
- end
228
-
229
- def base_url
230
- @url || "http://localhost:#{@port}"
231
- end
232
-
233
- # --- Login ---
234
-
235
- def run_login
236
- puts "Opening browser to #{base_url}..."
237
- puts "Sign in to your app, then close the browser window."
238
- puts ""
239
-
240
- script = login_script
241
- run_playwright(script)
242
-
243
- if File.exist?(storage_path)
244
- puts ""
245
- puts "Session saved."
246
- else
247
- puts ""
248
- puts "No session saved. Try again with `ruby_native screenshots --login`."
249
- exit 1
250
- end
251
- end
252
-
253
- # --- Capture ---
254
-
255
- def capture_screenshots
256
- paths = screenshot_paths
257
-
258
- if paths.empty?
259
- puts "No screenshot paths configured in #{CONFIG_PATH}."
260
- puts "Run `ruby_native screenshots` to set them up, or add manually:"
261
- puts ""
262
- puts " screenshots:"
263
- puts " paths:"
264
- puts " - /inbox"
265
- puts " - /profile"
266
- exit 1
267
- end
268
-
269
- FileUtils.mkdir_p(@output)
270
-
271
- puts "Capturing #{paths.length} screenshot#{"s" if paths.length > 1} at #{WIDTH_PX}x#{HEIGHT_PX} (#{WIDTH_PT}x#{HEIGHT_PT}pt @#{SCALE}x)..."
272
- puts ""
273
-
274
- script = capture_script(paths)
275
- run_playwright(script)
276
-
277
- puts ""
278
- puts "Screenshots saved to #{@output}/."
279
- end
280
-
281
- # --- Upload ---
282
-
283
- def credentials_available?
284
- if Credentials.token
285
- true
286
- else
287
- puts ""
288
- puts "Run `ruby_native login` to upload screenshots to Ruby Native."
289
- false
290
- end
291
- end
292
-
293
- def upload_screenshots
294
- begin
295
- app_id = @config.dig(:ruby_native, :app_id)
296
- app_id = link_app unless app_id
297
- return unless app_id
298
-
299
- files = Dir.glob(File.join(@output, "*.png")).sort
300
- if files.empty?
301
- puts "No screenshots to upload."
302
- return
303
- end
304
-
305
- puts ""
306
- puts "Uploading #{files.length} screenshot#{"s" if files.length > 1}..."
307
-
308
- api_delete("/api/v1/apps/#{app_id}/web_screenshots")
309
-
310
- files.each_with_index do |file, index|
311
- path = screenshot_paths[index] || File.basename(file, ".png")
312
- config = compositor_config_for(path)
313
- api_upload("/api/v1/apps/#{app_id}/web_screenshots", file, screenshot_path: path, position: index, total: files.length, compositor_config: config)
314
- puts " #{File.basename(file)}"
315
- end
316
-
317
- puts ""
318
- puts "Uploaded to Ruby Native."
319
- rescue TokenExpiredError
320
- puts "Token expired. Run `ruby_native login` again."
321
- rescue => e
322
- puts "Upload failed: #{e.message}"
323
- puts "Screenshots are saved locally in #{@output}/."
324
- end
325
- end
326
-
327
- def link_app
328
- apps = fetch_apps
329
- return unless apps
330
-
331
- if apps.empty?
332
- puts "No apps found on your account."
333
- return
334
- end
335
-
336
- app = if apps.length == 1
337
- puts "Using app: #{apps[0]["name"]}"
338
- apps[0]
339
- else
340
- puts "Which app?"
341
- apps.each_with_index do |a, i|
342
- puts " #{i + 1}. #{a["name"]}"
343
- end
344
- print "> "
345
- choice = ($stdin.gets&.strip || "").to_i
346
- unless choice.between?(1, apps.length)
347
- puts "Invalid choice."
348
- return
349
- end
350
- apps[choice - 1]
351
- end
352
-
353
- app_id = app["public_id"]
354
- write_app_id_to_config(app_id)
355
- app_id
356
- end
357
-
358
- def write_app_id_to_config(app_id)
359
- raw = File.read(CONFIG_PATH)
360
-
361
- if raw.match?(/^ruby_native:/)
362
- # Append under existing ruby_native key
363
- raw = raw.gsub(/^(ruby_native:\s*\n)/, "\\1 app_id: #{app_id}\n")
364
- else
365
- raw = raw.rstrip + "\n\nruby_native:\n app_id: #{app_id}\n"
366
- end
367
-
368
- File.write(CONFIG_PATH, raw)
369
-
370
- require "yaml"
371
- @config = YAML.load_file(CONFIG_PATH, symbolize_names: true) || {}
372
- end
373
-
374
- def fetch_apps
375
- uri = URI("#{HOST}/api/v1/apps")
376
- req = Net::HTTP::Get.new(uri)
377
- req["Authorization"] = "Token #{Credentials.token}"
378
-
379
- response = make_request(uri, req)
380
-
381
- case response
382
- when Net::HTTPUnauthorized
383
- raise TokenExpiredError
384
- when Net::HTTPSuccess
385
- JSON.parse(response.body)
386
- else
387
- puts "Failed to fetch apps: #{response.code}"
388
- nil
389
- end
390
- end
391
-
392
- TokenExpiredError = Class.new(StandardError)
393
-
394
- def api_delete(path)
395
- uri = URI("#{HOST}#{path}")
396
- req = Net::HTTP::Delete.new(uri)
397
- req["Authorization"] = "Token #{Credentials.token}"
398
-
399
- response = make_request(uri, req)
400
- raise TokenExpiredError if response.is_a?(Net::HTTPUnauthorized)
401
- response
402
- end
403
-
404
- def compositor_config_for(path)
405
- tabs = (@config[:tabs] || []).map { |tab|
406
- {title: tab[:title], icon: tab[:icon], selected: tab[:path] == path}
407
- }
408
-
409
- appearance = @config[:appearance] || {}
410
- {
411
- tabs: tabs,
412
- tint_color: resolve_color(appearance[:tint_color]),
413
- background_color: resolve_color(appearance[:background_color])
414
- }.compact
415
- end
416
-
417
- def resolve_color(value)
418
- case value
419
- when Hash then value[:light] || value["light"]
420
- when String then value
421
- end
422
- end
423
-
424
- def api_upload(endpoint, file_path, screenshot_path:, position:, total:, compositor_config: nil)
425
- uri = URI("#{HOST}#{endpoint}")
426
- boundary = "RubyNative#{SecureRandom.hex(16)}"
427
-
428
- body = build_multipart_body(boundary, file_path, screenshot_path, position, total, compositor_config)
429
-
430
- req = Net::HTTP::Post.new(uri)
431
- req["Authorization"] = "Token #{Credentials.token}"
432
- req["Content-Type"] = "multipart/form-data; boundary=#{boundary}"
433
- req.body = body
434
-
435
- response = make_request(uri, req)
436
- raise TokenExpiredError if response.is_a?(Net::HTTPUnauthorized)
437
-
438
- if response.is_a?(Net::HTTPNotFound)
439
- puts "App not found. Clearing app_id from config."
440
- clear_app_id_from_config
441
- return
442
- end
443
-
444
- response
445
- end
446
-
447
- def build_multipart_body(boundary, file_path, path, position, total, compositor_config)
448
- parts = []
449
-
450
- parts << "--#{boundary}\r\n"
451
- parts << "Content-Disposition: form-data; name=\"path\"\r\n\r\n"
452
- parts << "#{path}\r\n"
453
-
454
- parts << "--#{boundary}\r\n"
455
- parts << "Content-Disposition: form-data; name=\"position\"\r\n\r\n"
456
- parts << "#{position}\r\n"
457
-
458
- parts << "--#{boundary}\r\n"
459
- parts << "Content-Disposition: form-data; name=\"total\"\r\n\r\n"
460
- parts << "#{total}\r\n"
461
-
462
- if compositor_config
463
- parts << "--#{boundary}\r\n"
464
- parts << "Content-Disposition: form-data; name=\"compositor_config\"\r\n\r\n"
465
- parts << "#{JSON.generate(compositor_config)}\r\n"
466
- end
467
-
468
- parts << "--#{boundary}\r\n"
469
- parts << "Content-Disposition: form-data; name=\"file\"; filename=\"#{File.basename(file_path)}\"\r\n"
470
- parts << "Content-Type: image/png\r\n\r\n"
471
- parts << File.binread(file_path)
472
- parts << "\r\n"
473
-
474
- parts << "--#{boundary}--\r\n"
475
- parts.join
476
- end
477
-
478
- def make_request(uri, req)
479
- http = Net::HTTP.new(uri.host, uri.port)
480
- http.use_ssl = uri.scheme == "https"
481
- http.open_timeout = 10
482
- http.read_timeout = 30
483
- http.request(req)
484
- end
485
-
486
- def clear_app_id_from_config
487
- raw = File.read(CONFIG_PATH)
488
- raw = raw.gsub(/^ app_id: .+\n/, "")
489
- File.write(CONFIG_PATH, raw)
490
-
491
- require "yaml"
492
- @config = YAML.load_file(CONFIG_PATH, symbolize_names: true) || {}
493
- end
494
-
495
- # --- Playwright ---
496
-
497
- def add_to_gitignore
498
- gitignore = ".gitignore"
499
- return unless File.exist?(gitignore)
500
- return if File.read(gitignore).include?(".ruby_native")
501
-
502
- File.open(gitignore, "a") { |f| f.puts "\n# Ruby Native\n.ruby_native/" }
503
- end
504
-
505
- def storage_path
506
- File.join(STORAGE_DIR, STORAGE_FILE)
507
- end
508
-
509
- def run_playwright(script)
510
- FileUtils.mkdir_p(STORAGE_DIR)
511
- script_path = File.join(STORAGE_DIR, "capture.js")
512
- File.write(script_path, script)
513
-
514
- node_path = File.expand_path(File.join(STORAGE_DIR, "node_modules"))
515
- env = {"NODE_PATH" => node_path}
516
-
517
- stdout, stderr, status = Open3.capture3(env, "node", script_path)
518
- puts stdout unless stdout.empty?
519
-
520
- unless status.success?
521
- puts stderr unless stderr.empty?
522
- end
523
- ensure
524
- File.delete(script_path) if script_path && File.exist?(script_path)
525
- end
526
-
527
- def login_script
528
- <<~JS
529
- const { webkit } = require('playwright');
530
-
531
- (async () => {
532
- const browser = await webkit.launch({ headless: false });
533
- const context = await browser.newContext();
534
- const page = await context.newPage();
535
-
536
- await page.goto('#{base_url}/');
537
- console.log('Sign in to your app, then close the browser window.');
538
-
539
- await page.waitForEvent('close', { timeout: 0 }).catch(() => {});
540
- await context.storageState({ path: '#{storage_path}' });
541
- await browser.close();
542
- })();
543
- JS
544
- end
545
-
546
- def capture_script(paths)
547
- storage_opt = if File.exist?(storage_path)
548
- "storageState: '#{storage_path}',"
549
- else
550
- ""
551
- end
552
-
553
- screenshots_js = paths.map.with_index { |path, i|
554
- safe_name = path.gsub(/[^a-z0-9]/i, "_").gsub(/^_+|_+$/, "")
555
- safe_name = "root" if safe_name.empty?
556
- output_path = File.join(@output, "#{"%02d" % (i + 1)}_#{safe_name}.png")
557
-
558
- <<~CAPTURE
559
- const response_#{i} = await page.goto('#{base_url}#{path}', { waitUntil: 'networkidle' });
560
- if (response_#{i} && response_#{i}.status() >= 400) {
561
- console.log(' #{path} -> ERROR ' + response_#{i}.status() + ' (skipped)');
562
- } else {
563
- await page.screenshot({ path: '#{output_path}' });
564
- console.log(' #{path} -> #{output_path}');
565
- }
566
- CAPTURE
567
- }.join("\n")
568
-
569
- <<~JS
570
- const { webkit } = require('playwright');
571
-
572
- (async () => {
573
- const browser = await webkit.launch();
574
- const context = await browser.newContext({
575
- #{storage_opt}
576
- viewport: { width: #{WIDTH_PT}, height: #{HEIGHT_PT} },
577
- deviceScaleFactor: #{SCALE},
578
- userAgent: 'Ruby Native iOS/1.0 Screenshot',
579
- });
580
- const page = await context.newPage();
581
-
582
- #{screenshots_js}
583
-
584
- await browser.close();
585
- })();
586
- JS
587
- end
588
- end
589
- end
590
- end