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 +4 -4
- data/README.md +20 -0
- data/app/controllers/ruby_native/screenshots/sessions_controller.rb +62 -0
- data/config/routes.rb +3 -0
- data/lib/generators/ruby_native/install_generator.rb +1 -1
- data/lib/generators/ruby_native/templates/CLAUDE.md +20 -1
- data/lib/ruby_native/cli/deploy.rb +53 -8
- data/lib/ruby_native/cli.rb +6 -5
- data/lib/ruby_native/engine.rb +1 -1
- data/lib/ruby_native/helper.rb +37 -6
- data/lib/ruby_native/native_detection.rb +10 -1
- data/lib/ruby_native/screenshots/sign_in_helper.rb +26 -0
- data/lib/ruby_native/version.rb +1 -1
- data/lib/ruby_native.rb +9 -0
- metadata +3 -2
- data/lib/ruby_native/cli/screenshots.rb +0 -590
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8f882623a2d70e309c284e7a67a9dd4119579024f5c8da2afbb8cefbf5185127
|
|
4
|
+
data.tar.gz: e05abee9a75d6f830fa0e575517746bfa0e4bc728aeff5d2da75f893de09289e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
@@ -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
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
data/lib/ruby_native/cli.rb
CHANGED
|
@@ -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
|
data/lib/ruby_native/engine.rb
CHANGED
data/lib/ruby_native/helper.rb
CHANGED
|
@@ -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
|
|
44
|
-
|
|
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] =
|
|
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] =
|
|
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
|
data/lib/ruby_native/version.rb
CHANGED
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.
|
|
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
|