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 +4 -4
- 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 +0 -1
- data/lib/ruby_native/cli.rb +5 -4
- data/lib/ruby_native/engine.rb +1 -1
- data/lib/ruby_native/helper.rb +13 -0
- data/lib/ruby_native/version.rb +1 -1
- data/lib/ruby_native.rb +8 -0
- metadata +2 -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: e1dba22f3ed73cde73344241945e6862510cb3d0f42560f77225b5f9ad9e78d4
|
|
4
|
+
data.tar.gz: 7cf291e917138449546715e585cc3926842b94dcafcf7c85d3ea439f7b17edd6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
@@ -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
|
|
|
@@ -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
|
|
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,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
|
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)
|
data/lib/ruby_native/version.rb
CHANGED
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.
|
|
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
|