ruby_native 0.8.2 → 0.9.1

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: 8f882623a2d70e309c284e7a67a9dd4119579024f5c8da2afbb8cefbf5185127
4
+ data.tar.gz: e05abee9a75d6f830fa0e575517746bfa0e4bc728aeff5d2da75f893de09289e
5
5
  SHA512:
6
- metadata.gz: 1953585bc5fc67ad9d2d86de072497695239ec5873d58a7cb8dfa8191b1abfd94173ace3036a7b6442ef04b213f28be7f76ca1ebc6a2c7adf5edd8587cb406e7
7
- data.tar.gz: d7cd25a3cc33a9ce35a815f2b9d5a627b8e479d2ebaa6d25ab9ea23b5ebeb759b4e679e412ca347721c9c86a9c78a25848c065fe38a2619f171c509bec83466b
6
+ metadata.gz: a598a22166379ba712b9b0fb9417434cb94786937085055363bac1eb676c06a684addaecd4537e5b45098a9ed0ca810d0569d4c1af12c5adbe170cad1d0de6b2
7
+ data.tar.gz: 3fd6fe2454af67d3ea515862dc3161b2a2695dbf1534774baa3afa03e72e8ae564cb147a24d6ff7ad53feebaf1d41520025bdd849a2bb5dc139e583874d2ae0e
data/README.md CHANGED
@@ -79,6 +79,26 @@ The companion app persists the scanned URL across launches. Long-press the app i
79
79
  - `GET /native/config` - returns the YAML config as JSON
80
80
  - `POST /native/push/devices` - registers a push notification token (requires `current_user` from host app)
81
81
 
82
+ ## Push notifications
83
+
84
+ Delivery uses the companion `action_push_native` gem. Ruby Native owns the registration handshake (the `native_push_tag` helper prompts for permission, `/native/push/devices` stores the token) and the tap-handling conventions on the native side.
85
+
86
+ When sending a push, two destination keys are supported via `with_data`:
87
+
88
+ - `path` — internal route appended to your base URL and loaded in the in-app WebView (e.g. `/sources/42`).
89
+ - `url` — full external URL opened in `SFSafariViewController`, leaving the WebView in place behind it (e.g. `https://dashboard.stripe.com/payments/pi_abc`).
90
+
91
+ If both are present, `url` wins. `http` and `https` URLs open in `SFSafariViewController`; other valid schemes (`mailto:`, `tel:`, `maps:`, third-party app schemes, etc.) hand off to the appropriate app via `UIApplication.open`. Malformed `url` strings are dropped (the tap does not fall back to `path`).
92
+
93
+ ```ruby
94
+ ApplicationPushNotification
95
+ .with_data(path: source_path(source), url: notification.external_url)
96
+ .new(title: "New payment", body: "$49.99 from joe@example.com")
97
+ .deliver_later_to(user.push_devices)
98
+ ```
99
+
100
+ For model/migration setup, see [action_push_native](https://github.com/basecamp/action_push_native).
101
+
82
102
  ## Normal and Advanced Modes
83
103
 
84
104
  Normal Mode works with any frontend framework and requires no JavaScript. You get tabs, form page marking, push notifications, and history management.
@@ -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(SignInHelper.new(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
 
@@ -129,6 +129,26 @@ The gem auto-mounts at `/native`. No route configuration needed.
129
129
  - `GET /native/config` returns the YAML config as JSON
130
130
  - `POST /native/push/devices` registers a push notification device token
131
131
 
132
+ ## Push notifications
133
+
134
+ Delivery uses the companion `action_push_native` gem. Ruby Native owns the registration (the `native_push_tag` helper prompts for permission, `/native/push/devices` stores the token) and defines the tap conventions on the native side.
135
+
136
+ Two destination keys are supported via `with_data`:
137
+
138
+ - `path` — internal route appended to your base URL, loaded in the WebView.
139
+ - `url` — full external URL, opened in `SFSafariViewController`.
140
+
141
+ If both are present, `url` wins. `http` and `https` open in `SFSafariViewController`; other valid schemes (`mailto:`, `tel:`, `maps:`, third-party app schemes) open via `UIApplication.open`. Malformed `url` does not fall back to `path`.
142
+
143
+ ```ruby
144
+ ApplicationPushNotification
145
+ .with_data(path: source_path(source), url: notification.external_url)
146
+ .new(title: "New payment", body: "$49.99 from joe@example.com")
147
+ .deliver_later_to(user.push_devices)
148
+ ```
149
+
150
+ For model/migration setup, see the [action_push_native](https://github.com/basecamp/action_push_native) README.
151
+
132
152
  ## CLI
133
153
 
134
154
  ### Deploy from CI
@@ -154,7 +174,6 @@ Set `RUBY_NATIVE_TOKEN` as an environment variable for CI (no interactive login
154
174
  bundle exec ruby_native login # authenticate (opens browser)
155
175
  bundle exec ruby_native deploy # trigger a build
156
176
  bundle exec ruby_native preview # start a tunnel and display a QR code
157
- bundle exec ruby_native screenshots # capture App Store screenshots
158
177
  bundle exec ruby_native logout # remove stored credentials
159
178
  ```
160
179
 
@@ -16,6 +16,7 @@ module RubyNative
16
16
 
17
17
  def initialize(argv)
18
18
  @if_needed = argv.include?("--if-needed")
19
+ @platform = parse_platform(argv)
19
20
  end
20
21
 
21
22
  def run
@@ -105,7 +106,7 @@ module RubyNative
105
106
  req = Net::HTTP::Post.new(uri)
106
107
  req["Authorization"] = "Token #{Credentials.token}"
107
108
  req["Content-Type"] = "application/json"
108
- req.body = JSON.generate(gem_version: RubyNative::VERSION)
109
+ req.body = JSON.generate(build_payload)
109
110
 
110
111
  response = make_request(uri, req)
111
112
 
@@ -174,7 +175,7 @@ module RubyNative
174
175
  puts " Version: v#{data["version"]} (#{data["number"]})"
175
176
  puts " Ruby Native: #{data["native_version"]}" if data["native_version"]
176
177
  puts ""
177
- puts "Your build is being submitted to TestFlight."
178
+ puts success_destination_message(data)
178
179
  break
179
180
  when "failure", "failed", "cancelled"
180
181
  puts ""
@@ -208,15 +209,59 @@ module RubyNative
208
209
  end
209
210
 
210
211
  def print_status(status)
211
- labels = {
212
- "queued" => "Queued",
213
- "building" => "Building",
214
- "processing" => "Submitting to App Store Connect"
215
- }
216
- label = labels[status]
212
+ label = status_labels[status]
217
213
  puts " #{label}..." if label
218
214
  end
219
215
 
216
+ def status_labels
217
+ if android?
218
+ {
219
+ "queued" => "Queued",
220
+ "building" => "Building Android AAB",
221
+ "processing" => "Uploading to Play Internal Testing"
222
+ }
223
+ else
224
+ {
225
+ "queued" => "Queued",
226
+ "building" => "Building",
227
+ "processing" => "Submitting to App Store Connect"
228
+ }
229
+ end
230
+ end
231
+
232
+ def success_destination_message(data)
233
+ platform = data["platform"] || requested_platform
234
+ case platform
235
+ when "android"
236
+ "Your build is being uploaded to Play Internal Testing."
237
+ else
238
+ "Your build is being submitted to TestFlight."
239
+ end
240
+ end
241
+
242
+ def parse_platform(argv)
243
+ return "android" if argv.include?("--android")
244
+
245
+ flag = argv.find { |a| a.start_with?("--platform=") }
246
+ return flag.split("=", 2).last if flag
247
+
248
+ "ios"
249
+ end
250
+
251
+ def requested_platform
252
+ @platform
253
+ end
254
+
255
+ def android?
256
+ @platform == "android"
257
+ end
258
+
259
+ def build_payload
260
+ payload = { gem_version: RubyNative::VERSION }
261
+ payload[:platform] = @platform unless @platform == "ios"
262
+ payload
263
+ end
264
+
220
265
  # --- App linking ---
221
266
 
222
267
  def link_app
@@ -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,22 +12,24 @@ 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 ""
26
28
  puts "Commands:"
27
- puts " deploy Trigger an iOS build"
29
+ puts " deploy Trigger an iOS build (use --android for Android)"
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)
@@ -40,8 +53,10 @@ module RubyNative
40
53
  tag.div(data: { native_navbar: title.to_s }, hidden: true) { builder.to_html }
41
54
  end
42
55
 
43
- def native_fab_tag(icon:, href: nil, click: nil)
44
- data = { native_fab: true, native_icon: icon }
56
+ def native_fab_tag(icon: nil, icons: nil, href: nil, click: nil)
57
+ resolved = RubyNative::Helper.resolve_icon(icon: icon, icons: icons, platform: try(:native_platform))
58
+ raise ArgumentError, "native_fab_tag requires an icon" if resolved.nil?
59
+ data = { native_fab: true, native_icon: resolved }
45
60
  data[:native_href] = href if href
46
61
  data[:native_click] = click if click
47
62
  tag.div(data: data, hidden: true)
@@ -58,16 +73,31 @@ module RubyNative
58
73
  data
59
74
  end
60
75
 
76
+ # Picks the right icon name for the current native platform. Accepts the
77
+ # single `icon:` form (applied to every platform) and/or the `icons:` hash
78
+ # form (`{ ios: "...", android: "..." }`). When both are given, a matching
79
+ # `icons[platform]` wins; otherwise falls back to `icon`. Returns nil when
80
+ # nothing resolves.
81
+ def self.resolve_icon(icon: nil, icons: nil, platform: nil)
82
+ if icons.is_a?(Hash) && platform
83
+ key = platform.to_sym
84
+ per_platform = icons[key] || icons[key.to_s]
85
+ return per_platform if per_platform
86
+ end
87
+ icon
88
+ end
89
+
61
90
  class NavbarBuilder
62
91
  def initialize(context)
63
92
  @context = context
64
93
  @items = []
65
94
  end
66
95
 
67
- def button(title = nil, icon: nil, href: nil, click: nil, position: :trailing, selected: false, &block)
96
+ def button(title = nil, icon: nil, icons: nil, href: nil, click: nil, position: :trailing, selected: false, &block)
97
+ resolved = RubyNative::Helper.resolve_icon(icon: icon, icons: icons, platform: @context.try(:native_platform))
68
98
  data = { native_button: "" }
69
99
  data[:native_title] = title if title
70
- data[:native_icon] = icon if icon
100
+ data[:native_icon] = resolved if resolved
71
101
  data[:native_href] = href if href
72
102
  data[:native_click] = click if click
73
103
  data[:native_position] = position.to_s
@@ -101,11 +131,12 @@ module RubyNative
101
131
  @items = []
102
132
  end
103
133
 
104
- def item(title, href: nil, click: nil, icon: nil, selected: false)
134
+ def item(title, href: nil, click: nil, icon: nil, icons: nil, selected: false)
135
+ resolved = RubyNative::Helper.resolve_icon(icon: icon, icons: icons, platform: @context.try(:native_platform))
105
136
  data = { native_menu_item: "", native_title: title }
106
137
  data[:native_href] = href if href
107
138
  data[:native_click] = click if click
108
- data[:native_icon] = icon if icon
139
+ data[:native_icon] = resolved if resolved
109
140
  data[:native_selected] = "" if selected
110
141
  @items << @context.tag.div(data: data)
111
142
  end
@@ -3,7 +3,7 @@ module RubyNative
3
3
  extend ActiveSupport::Concern
4
4
 
5
5
  included do
6
- helper_method :native_app?, :native_version if respond_to?(:helper_method)
6
+ helper_method :native_app?, :native_version, :native_platform if respond_to?(:helper_method)
7
7
  end
8
8
 
9
9
  def native_app?
@@ -14,5 +14,14 @@ module RubyNative
14
14
  match = request.user_agent.to_s.match(/RubyNative\/([\d.]+)/)
15
15
  NativeVersion.new(match ? match[1] : "0")
16
16
  end
17
+
18
+ # Returns "ios" or "android" for native requests, nil for web browsers.
19
+ # Used by view helpers to pick the right icon from `icons: { ios:, android: }`.
20
+ def native_platform
21
+ ua = request.user_agent.to_s
22
+ return "ios" if ua.include?("Ruby Native iOS")
23
+ return "android" if ua.include?("Ruby Native Android")
24
+ nil
25
+ end
17
26
  end
18
27
  end
@@ -0,0 +1,26 @@
1
+ module RubyNative
2
+ module Screenshots
3
+ # Yielded to the customer's `screenshot_sign_in` lambda. Exposes the
4
+ # request, session, and cookies as public accessors so the lambda never
5
+ # touches `ActionController` internals directly. `cookies` in particular
6
+ # is private on `ActionController::Base` in Rails 8.1+, so customers
7
+ # would otherwise have to fall back to `controller.send(:cookies)`.
8
+ class SignInHelper
9
+ def initialize(controller)
10
+ @controller = controller
11
+ end
12
+
13
+ def cookies
14
+ @controller.send(:cookies)
15
+ end
16
+
17
+ def request
18
+ @controller.request
19
+ end
20
+
21
+ def session
22
+ @controller.session
23
+ end
24
+ end
25
+ end
26
+ end
@@ -1,3 +1,3 @@
1
1
  module RubyNative
2
- VERSION = "0.8.2"
2
+ VERSION = "0.9.1"
3
3
  end
data/lib/ruby_native.rb CHANGED
@@ -10,12 +10,21 @@ require "ruby_native/iap/verifiable"
10
10
  require "ruby_native/iap/decodable"
11
11
  require "ruby_native/iap/normalizable"
12
12
  require "ruby_native/iap/apple_webhook_processor"
13
+ require "ruby_native/screenshots/sign_in_helper"
13
14
  require "ruby_native/engine"
14
15
 
15
16
  module RubyNative
16
17
  mattr_accessor :config
17
18
  mattr_accessor :subscription_callbacks, default: []
18
19
 
20
+ # Screenshot configuration. Set via `RubyNative.configure` in an initializer.
21
+ mattr_accessor :screenshot_key
22
+ mattr_accessor :screenshot_sign_in
23
+
24
+ def self.configure
25
+ yield self
26
+ end
27
+
19
28
  def self.on_subscription_change(&block)
20
29
  subscription_callbacks << block
21
30
  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.1
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
@@ -101,6 +101,7 @@ files:
101
101
  - lib/ruby_native/native_detection.rb
102
102
  - lib/ruby_native/native_version.rb
103
103
  - lib/ruby_native/oauth_middleware.rb
104
+ - lib/ruby_native/screenshots/sign_in_helper.rb
104
105
  - lib/ruby_native/tunnel_cookie_middleware.rb
105
106
  - lib/ruby_native/version.rb
106
107
  homepage: https://github.com/ruby-native/gem
@@ -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