ruby_native 0.2.9 → 0.3.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/lib/generators/ruby_native/install_generator.rb +9 -0
- data/lib/ruby_native/cli/credentials.rb +28 -0
- data/lib/ruby_native/cli/login.rb +69 -0
- data/lib/ruby_native/cli/preview.rb +1 -1
- data/lib/ruby_native/cli/screenshots.rb +591 -0
- data/lib/ruby_native/cli.rb +14 -1
- data/lib/ruby_native/version.rb +1 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d439edf69ea1ee01ec61d732c6d60590e2cfe8ad82af5fbb3c914d08b3bb678e
|
|
4
|
+
data.tar.gz: 7c8a4001bddbefb569eda81a455e14643aa08e76a3ec1d15edcbfa9acdb5b1e7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5668a8b5c5e9e1e3a10effd882711867ba004f7015a1dcca39a3a41efdbf306acb09809486f058d6f2fcc775df6c40b5a91c5bf2e0cff45a4c79ac8a65795892
|
|
7
|
+
data.tar.gz: 03b6f326068805f701d208c065f493c2d48a556e7e5e14adc6520fd98f3e2084254b139a7074e58e89344e7bb01b236c635ad02ef9d60240354ccd9838941c1f
|
|
@@ -20,6 +20,15 @@ module RubyNative
|
|
|
20
20
|
say " Added .trycloudflare.com to allowed hosts in development.rb", :green
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
+
def add_gitignore
|
|
24
|
+
gitignore = File.join(destination_root, ".gitignore")
|
|
25
|
+
return unless File.exist?(gitignore)
|
|
26
|
+
return if File.read(gitignore).include?(".ruby_native")
|
|
27
|
+
|
|
28
|
+
append_to_file ".gitignore", "\n# Ruby Native (Playwright, screenshots, session data)\n.ruby_native/\n"
|
|
29
|
+
say " Added .ruby_native/ to .gitignore", :green
|
|
30
|
+
end
|
|
31
|
+
|
|
23
32
|
def copy_claude_instructions
|
|
24
33
|
return unless File.directory?(File.join(destination_root, ".claude"))
|
|
25
34
|
copy_file "CLAUDE.md", ".claude/ruby_native.md"
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "fileutils"
|
|
3
|
+
|
|
4
|
+
module RubyNative
|
|
5
|
+
class CLI
|
|
6
|
+
class Credentials
|
|
7
|
+
PATH = File.join(Dir.home, ".ruby_native", "credentials")
|
|
8
|
+
|
|
9
|
+
def self.token
|
|
10
|
+
return unless File.exist?(PATH)
|
|
11
|
+
JSON.parse(File.read(PATH))["token"]
|
|
12
|
+
rescue JSON::ParserError
|
|
13
|
+
nil
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.save(token)
|
|
17
|
+
dir = File.dirname(PATH)
|
|
18
|
+
FileUtils.mkdir_p(dir)
|
|
19
|
+
File.write(PATH, JSON.generate(token: token))
|
|
20
|
+
File.chmod(0600, PATH)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.clear
|
|
24
|
+
File.delete(PATH) if File.exist?(PATH)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
require "securerandom"
|
|
2
|
+
require "net/http"
|
|
3
|
+
require "uri"
|
|
4
|
+
require "json"
|
|
5
|
+
require "ruby_native/cli/credentials"
|
|
6
|
+
|
|
7
|
+
module RubyNative
|
|
8
|
+
class CLI
|
|
9
|
+
class Login
|
|
10
|
+
HOST = ENV.fetch("RUBY_NATIVE_HOST", "https://rubynative.com")
|
|
11
|
+
|
|
12
|
+
def initialize(argv = [])
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def run
|
|
16
|
+
code = SecureRandom.hex(20)
|
|
17
|
+
url = "#{HOST}/cli/session/new?code=#{code}"
|
|
18
|
+
|
|
19
|
+
puts "Opening browser to authorize..."
|
|
20
|
+
open_browser(url)
|
|
21
|
+
puts "Waiting for authorization..."
|
|
22
|
+
|
|
23
|
+
token = poll_for_token(code)
|
|
24
|
+
|
|
25
|
+
if token
|
|
26
|
+
Credentials.save(token)
|
|
27
|
+
puts "Logged in to Ruby Native."
|
|
28
|
+
else
|
|
29
|
+
puts "Authorization timed out. Please try again."
|
|
30
|
+
exit 1
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def open_browser(url)
|
|
37
|
+
case RUBY_PLATFORM
|
|
38
|
+
when /darwin/
|
|
39
|
+
system("open", url)
|
|
40
|
+
when /linux/
|
|
41
|
+
system("xdg-open", url)
|
|
42
|
+
when /mingw|mswin/
|
|
43
|
+
system("start", url)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def poll_for_token(code)
|
|
48
|
+
uri = URI("#{HOST}/cli/session/poll?code=#{code}")
|
|
49
|
+
attempts = 0
|
|
50
|
+
max_attempts = 60
|
|
51
|
+
|
|
52
|
+
loop do
|
|
53
|
+
attempts += 1
|
|
54
|
+
return nil if attempts > max_attempts
|
|
55
|
+
|
|
56
|
+
sleep 2
|
|
57
|
+
|
|
58
|
+
response = Net::HTTP.get_response(uri)
|
|
59
|
+
if response.is_a?(Net::HTTPSuccess)
|
|
60
|
+
data = JSON.parse(response.body)
|
|
61
|
+
return data["token"]
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
rescue Interrupt
|
|
65
|
+
nil
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -109,7 +109,7 @@ module RubyNative
|
|
|
109
109
|
puts ""
|
|
110
110
|
puts url
|
|
111
111
|
puts ""
|
|
112
|
-
puts "Scan
|
|
112
|
+
puts "Scan the QR code or paste the URL into the Ruby Native Preview app."
|
|
113
113
|
puts "Keep this running and your Rails server on port #{@port} in another terminal."
|
|
114
114
|
puts "Press Ctrl+C to stop."
|
|
115
115
|
end
|
|
@@ -0,0 +1,591 @@
|
|
|
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
|
+
status_bar_color: resolve_color(appearance[:status_bar_color])
|
|
415
|
+
}.compact
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
def resolve_color(value)
|
|
419
|
+
case value
|
|
420
|
+
when Hash then value[:light] || value["light"]
|
|
421
|
+
when String then value
|
|
422
|
+
end
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
def api_upload(endpoint, file_path, screenshot_path:, position:, total:, compositor_config: nil)
|
|
426
|
+
uri = URI("#{HOST}#{endpoint}")
|
|
427
|
+
boundary = "RubyNative#{SecureRandom.hex(16)}"
|
|
428
|
+
|
|
429
|
+
body = build_multipart_body(boundary, file_path, screenshot_path, position, total, compositor_config)
|
|
430
|
+
|
|
431
|
+
req = Net::HTTP::Post.new(uri)
|
|
432
|
+
req["Authorization"] = "Token #{Credentials.token}"
|
|
433
|
+
req["Content-Type"] = "multipart/form-data; boundary=#{boundary}"
|
|
434
|
+
req.body = body
|
|
435
|
+
|
|
436
|
+
response = make_request(uri, req)
|
|
437
|
+
raise TokenExpiredError if response.is_a?(Net::HTTPUnauthorized)
|
|
438
|
+
|
|
439
|
+
if response.is_a?(Net::HTTPNotFound)
|
|
440
|
+
puts "App not found. Clearing app_id from config."
|
|
441
|
+
clear_app_id_from_config
|
|
442
|
+
return
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
response
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def build_multipart_body(boundary, file_path, path, position, total, compositor_config)
|
|
449
|
+
parts = []
|
|
450
|
+
|
|
451
|
+
parts << "--#{boundary}\r\n"
|
|
452
|
+
parts << "Content-Disposition: form-data; name=\"path\"\r\n\r\n"
|
|
453
|
+
parts << "#{path}\r\n"
|
|
454
|
+
|
|
455
|
+
parts << "--#{boundary}\r\n"
|
|
456
|
+
parts << "Content-Disposition: form-data; name=\"position\"\r\n\r\n"
|
|
457
|
+
parts << "#{position}\r\n"
|
|
458
|
+
|
|
459
|
+
parts << "--#{boundary}\r\n"
|
|
460
|
+
parts << "Content-Disposition: form-data; name=\"total\"\r\n\r\n"
|
|
461
|
+
parts << "#{total}\r\n"
|
|
462
|
+
|
|
463
|
+
if compositor_config
|
|
464
|
+
parts << "--#{boundary}\r\n"
|
|
465
|
+
parts << "Content-Disposition: form-data; name=\"compositor_config\"\r\n\r\n"
|
|
466
|
+
parts << "#{JSON.generate(compositor_config)}\r\n"
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
parts << "--#{boundary}\r\n"
|
|
470
|
+
parts << "Content-Disposition: form-data; name=\"file\"; filename=\"#{File.basename(file_path)}\"\r\n"
|
|
471
|
+
parts << "Content-Type: image/png\r\n\r\n"
|
|
472
|
+
parts << File.binread(file_path)
|
|
473
|
+
parts << "\r\n"
|
|
474
|
+
|
|
475
|
+
parts << "--#{boundary}--\r\n"
|
|
476
|
+
parts.join
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
def make_request(uri, req)
|
|
480
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
481
|
+
http.use_ssl = uri.scheme == "https"
|
|
482
|
+
http.open_timeout = 10
|
|
483
|
+
http.read_timeout = 30
|
|
484
|
+
http.request(req)
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
def clear_app_id_from_config
|
|
488
|
+
raw = File.read(CONFIG_PATH)
|
|
489
|
+
raw = raw.gsub(/^ app_id: .+\n/, "")
|
|
490
|
+
File.write(CONFIG_PATH, raw)
|
|
491
|
+
|
|
492
|
+
require "yaml"
|
|
493
|
+
@config = YAML.load_file(CONFIG_PATH, symbolize_names: true) || {}
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
# --- Playwright ---
|
|
497
|
+
|
|
498
|
+
def add_to_gitignore
|
|
499
|
+
gitignore = ".gitignore"
|
|
500
|
+
return unless File.exist?(gitignore)
|
|
501
|
+
return if File.read(gitignore).include?(".ruby_native")
|
|
502
|
+
|
|
503
|
+
File.open(gitignore, "a") { |f| f.puts "\n# Ruby Native\n.ruby_native/" }
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
def storage_path
|
|
507
|
+
File.join(STORAGE_DIR, STORAGE_FILE)
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
def run_playwright(script)
|
|
511
|
+
FileUtils.mkdir_p(STORAGE_DIR)
|
|
512
|
+
script_path = File.join(STORAGE_DIR, "capture.js")
|
|
513
|
+
File.write(script_path, script)
|
|
514
|
+
|
|
515
|
+
node_path = File.expand_path(File.join(STORAGE_DIR, "node_modules"))
|
|
516
|
+
env = {"NODE_PATH" => node_path}
|
|
517
|
+
|
|
518
|
+
stdout, stderr, status = Open3.capture3(env, "node", script_path)
|
|
519
|
+
puts stdout unless stdout.empty?
|
|
520
|
+
|
|
521
|
+
unless status.success?
|
|
522
|
+
puts stderr unless stderr.empty?
|
|
523
|
+
end
|
|
524
|
+
ensure
|
|
525
|
+
File.delete(script_path) if script_path && File.exist?(script_path)
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
def login_script
|
|
529
|
+
<<~JS
|
|
530
|
+
const { webkit } = require('playwright');
|
|
531
|
+
|
|
532
|
+
(async () => {
|
|
533
|
+
const browser = await webkit.launch({ headless: false });
|
|
534
|
+
const context = await browser.newContext();
|
|
535
|
+
const page = await context.newPage();
|
|
536
|
+
|
|
537
|
+
await page.goto('#{base_url}/');
|
|
538
|
+
console.log('Sign in to your app, then close the browser window.');
|
|
539
|
+
|
|
540
|
+
await page.waitForEvent('close', { timeout: 0 }).catch(() => {});
|
|
541
|
+
await context.storageState({ path: '#{storage_path}' });
|
|
542
|
+
await browser.close();
|
|
543
|
+
})();
|
|
544
|
+
JS
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
def capture_script(paths)
|
|
548
|
+
storage_opt = if File.exist?(storage_path)
|
|
549
|
+
"storageState: '#{storage_path}',"
|
|
550
|
+
else
|
|
551
|
+
""
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
screenshots_js = paths.map.with_index { |path, i|
|
|
555
|
+
safe_name = path.gsub(/[^a-z0-9]/i, "_").gsub(/^_+|_+$/, "")
|
|
556
|
+
safe_name = "root" if safe_name.empty?
|
|
557
|
+
output_path = File.join(@output, "#{"%02d" % (i + 1)}_#{safe_name}.png")
|
|
558
|
+
|
|
559
|
+
<<~CAPTURE
|
|
560
|
+
const response_#{i} = await page.goto('#{base_url}#{path}', { waitUntil: 'networkidle' });
|
|
561
|
+
if (response_#{i} && response_#{i}.status() >= 400) {
|
|
562
|
+
console.log(' #{path} -> ERROR ' + response_#{i}.status() + ' (skipped)');
|
|
563
|
+
} else {
|
|
564
|
+
await page.screenshot({ path: '#{output_path}' });
|
|
565
|
+
console.log(' #{path} -> #{output_path}');
|
|
566
|
+
}
|
|
567
|
+
CAPTURE
|
|
568
|
+
}.join("\n")
|
|
569
|
+
|
|
570
|
+
<<~JS
|
|
571
|
+
const { webkit } = require('playwright');
|
|
572
|
+
|
|
573
|
+
(async () => {
|
|
574
|
+
const browser = await webkit.launch();
|
|
575
|
+
const context = await browser.newContext({
|
|
576
|
+
#{storage_opt}
|
|
577
|
+
viewport: { width: #{WIDTH_PT}, height: #{HEIGHT_PT} },
|
|
578
|
+
deviceScaleFactor: #{SCALE},
|
|
579
|
+
userAgent: 'Ruby Native iOS/1.0 Screenshot',
|
|
580
|
+
});
|
|
581
|
+
const page = await context.newPage();
|
|
582
|
+
|
|
583
|
+
#{screenshots_js}
|
|
584
|
+
|
|
585
|
+
await browser.close();
|
|
586
|
+
})();
|
|
587
|
+
JS
|
|
588
|
+
end
|
|
589
|
+
end
|
|
590
|
+
end
|
|
591
|
+
end
|
data/lib/ruby_native/cli.rb
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
require "ruby_native/cli/credentials"
|
|
2
|
+
require "ruby_native/cli/login"
|
|
1
3
|
require "ruby_native/cli/preview"
|
|
4
|
+
require "ruby_native/cli/screenshots"
|
|
2
5
|
|
|
3
6
|
module RubyNative
|
|
4
7
|
class CLI
|
|
@@ -7,11 +10,21 @@ module RubyNative
|
|
|
7
10
|
case command
|
|
8
11
|
when "preview"
|
|
9
12
|
RubyNative::CLI::Preview.new(argv).run
|
|
13
|
+
when "screenshots"
|
|
14
|
+
RubyNative::CLI::Screenshots.new(argv).run
|
|
15
|
+
when "login"
|
|
16
|
+
RubyNative::CLI::Login.new(argv).run
|
|
17
|
+
when "logout"
|
|
18
|
+
RubyNative::CLI::Credentials.clear
|
|
19
|
+
puts "Logged out of Ruby Native."
|
|
10
20
|
else
|
|
11
21
|
puts "Usage: ruby_native <command>"
|
|
12
22
|
puts ""
|
|
13
23
|
puts "Commands:"
|
|
14
|
-
puts "
|
|
24
|
+
puts " login Authenticate with Ruby Native"
|
|
25
|
+
puts " logout Remove stored credentials"
|
|
26
|
+
puts " preview Start a tunnel and display a QR code"
|
|
27
|
+
puts " screenshots Capture web screenshots for App Store images"
|
|
15
28
|
end
|
|
16
29
|
end
|
|
17
30
|
end
|
data/lib/ruby_native/version.rb
CHANGED
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.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Joe Masilotti
|
|
@@ -73,7 +73,10 @@ files:
|
|
|
73
73
|
- lib/generators/ruby_native/templates/ruby_native.yml
|
|
74
74
|
- lib/ruby_native.rb
|
|
75
75
|
- lib/ruby_native/cli.rb
|
|
76
|
+
- lib/ruby_native/cli/credentials.rb
|
|
77
|
+
- lib/ruby_native/cli/login.rb
|
|
76
78
|
- lib/ruby_native/cli/preview.rb
|
|
79
|
+
- lib/ruby_native/cli/screenshots.rb
|
|
77
80
|
- lib/ruby_native/engine.rb
|
|
78
81
|
- lib/ruby_native/helper.rb
|
|
79
82
|
- lib/ruby_native/inertia_support.rb
|