ruflet 0.0.18 → 0.0.20
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 +54 -1
- data/lib/ruflet/cli/build_command.rb +697 -118
- data/lib/ruflet/cli/extra_command.rb +11 -8
- data/lib/ruflet/cli/flutter_sdk.rb +42 -7
- data/lib/ruflet/cli/new_command.rb +112 -36
- data/lib/ruflet/cli/run_command.rb +326 -70
- data/lib/ruflet/cli/templates.rb +8 -5
- data/lib/ruflet/cli.rb +3 -3
- data/lib/ruflet/hot_reload/harness.rb +32 -0
- data/lib/ruflet/hot_reload.rb +226 -0
- data/lib/ruflet/version.rb +1 -1
- metadata +3 -1
|
@@ -16,12 +16,16 @@ require "time"
|
|
|
16
16
|
module Ruflet
|
|
17
17
|
module CLI
|
|
18
18
|
module RunCommand
|
|
19
|
+
CLIENT_CHANNEL_MANIFEST = "ruflet_client-manifest.json"
|
|
20
|
+
DEFAULT_CLIENT_UPDATE_INTERVAL = 6 * 60 * 60
|
|
21
|
+
|
|
19
22
|
def command_run(args)
|
|
20
|
-
options = { target: "mobile", requested_port: 8550 }
|
|
23
|
+
options = { target: "mobile", requested_port: 8550, reload: true }
|
|
21
24
|
parser = OptionParser.new do |o|
|
|
22
25
|
o.on("--web") { options[:target] = "web" }
|
|
23
26
|
o.on("--desktop") { options[:target] = "desktop" }
|
|
24
27
|
o.on("--port PORT", Integer) { |v| options[:requested_port] = v }
|
|
28
|
+
o.on("--no-reload") { options[:reload] = false }
|
|
25
29
|
end
|
|
26
30
|
parser.parse!(args)
|
|
27
31
|
|
|
@@ -44,18 +48,32 @@ module Ruflet
|
|
|
44
48
|
assets_dir = File.join(File.dirname(script_path), "assets")
|
|
45
49
|
env["RUFLET_ASSETS_DIR"] = assets_dir if File.directory?(assets_dir)
|
|
46
50
|
|
|
51
|
+
# The backend serves the web client itself so both share one origin.
|
|
52
|
+
if options[:target] == "web"
|
|
53
|
+
web_client_dir = detect_web_client_dir
|
|
54
|
+
if web_client_dir
|
|
55
|
+
env["RUFLET_WEB_CLIENT_DIR"] = web_client_dir
|
|
56
|
+
else
|
|
57
|
+
warn "Web client build not found and prebuilt download failed."
|
|
58
|
+
warn "Build one with `ruflet build web`, or set RUFLET_CLIENT_DIR."
|
|
59
|
+
return 1
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
47
63
|
print_run_banner(target: options[:target], requested_port: options[:requested_port], port: selected_port)
|
|
48
64
|
print_mobile_qr_hint(port: selected_port) if options[:target] == "mobile"
|
|
65
|
+
print_hot_reload_banner if options[:reload]
|
|
49
66
|
|
|
50
67
|
gemfile_path = find_nearest_gemfile(Dir.pwd)
|
|
51
|
-
cmd = build_runtime_command(script_path, gemfile_path: gemfile_path, env: env)
|
|
68
|
+
cmd = build_runtime_command(script_path, gemfile_path: gemfile_path, env: env, reload: options[:reload])
|
|
52
69
|
return 1 unless cmd
|
|
53
70
|
|
|
54
|
-
|
|
71
|
+
run_state = { child_pid: Process.spawn(env, *cmd, pgroup: true), restart: false }
|
|
72
|
+
reload_input_thread = options[:reload] ? start_reload_input_thread(run_state) : nil
|
|
55
73
|
launched_client_pids = launch_target_client(options[:target], selected_port)
|
|
56
74
|
forward_signal = lambda do |signal|
|
|
57
75
|
begin
|
|
58
|
-
Process.kill(signal, -child_pid)
|
|
76
|
+
Process.kill(signal, -run_state[:child_pid])
|
|
59
77
|
rescue Errno::ESRCH
|
|
60
78
|
nil
|
|
61
79
|
end
|
|
@@ -64,15 +82,28 @@ module Ruflet
|
|
|
64
82
|
previous_int = Signal.trap("INT") { forward_signal.call("INT") }
|
|
65
83
|
previous_term = Signal.trap("TERM") { forward_signal.call("TERM") }
|
|
66
84
|
|
|
67
|
-
|
|
68
|
-
|
|
85
|
+
loop do
|
|
86
|
+
_pid, status = Process.wait2(run_state[:child_pid])
|
|
87
|
+
return status.success? ? 0 : (status.exitstatus || 1) unless run_state[:restart]
|
|
88
|
+
|
|
89
|
+
# Full restart requested ("R"): respawn the backend; connected
|
|
90
|
+
# clients reconnect and re-register on their own (Flet-style).
|
|
91
|
+
run_state[:restart] = false
|
|
92
|
+
puts "Restarting app..."
|
|
93
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
94
|
+
run_state[:child_pid] = Process.spawn(env, *cmd, pgroup: true)
|
|
95
|
+
wait_for_server_boot(selected_port)
|
|
96
|
+
elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round
|
|
97
|
+
puts "Restarted in #{elapsed_ms}ms"
|
|
98
|
+
end
|
|
69
99
|
ensure
|
|
100
|
+
reload_input_thread&.kill if defined?(reload_input_thread)
|
|
70
101
|
Signal.trap("INT", previous_int) if defined?(previous_int) && previous_int
|
|
71
102
|
Signal.trap("TERM", previous_term) if defined?(previous_term) && previous_term
|
|
72
103
|
|
|
73
|
-
if defined?(
|
|
104
|
+
if defined?(run_state) && run_state && run_state[:child_pid]
|
|
74
105
|
begin
|
|
75
|
-
Process.kill("TERM", -child_pid)
|
|
106
|
+
Process.kill("TERM", -run_state[:child_pid])
|
|
76
107
|
rescue Errno::ESRCH
|
|
77
108
|
nil
|
|
78
109
|
end
|
|
@@ -94,16 +125,111 @@ module Ruflet
|
|
|
94
125
|
|
|
95
126
|
private
|
|
96
127
|
|
|
97
|
-
def build_runtime_command(script_path, gemfile_path:, env:)
|
|
128
|
+
def build_runtime_command(script_path, gemfile_path:, env:, reload: false)
|
|
129
|
+
entry_script = script_path
|
|
130
|
+
if reload
|
|
131
|
+
env["RUFLET_APP_SCRIPT"] = script_path
|
|
132
|
+
env["RUFLET_WATCH_ROOT"] = File.dirname(script_path)
|
|
133
|
+
env["RUFLET_BOOTSNAP_DIR"] = bootsnap_cache_dir(script_path)
|
|
134
|
+
entry_script = hot_reload_harness_path
|
|
135
|
+
end
|
|
136
|
+
|
|
98
137
|
if gemfile_path
|
|
99
138
|
env["BUNDLE_GEMFILE"] = gemfile_path
|
|
100
139
|
bundle_ready = system(env, RbConfig.ruby, "-S", "bundle", "check", out: File::NULL, err: File::NULL)
|
|
101
140
|
return nil unless bundle_ready || system(env, RbConfig.ruby, "-S", "bundle", "install")
|
|
102
141
|
|
|
103
|
-
return [RbConfig.ruby, "-rbundler/setup",
|
|
142
|
+
return [RbConfig.ruby, "-rbundler/setup", entry_script]
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
[RbConfig.ruby, entry_script]
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def hot_reload_harness_path
|
|
149
|
+
File.expand_path("../hot_reload/harness.rb", __dir__)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Persistent, per-app bootsnap cache so it stays warm across restarts.
|
|
153
|
+
# Kept under ~/.ruflet (not the project, so nothing to gitignore) and
|
|
154
|
+
# keyed by app path + Ruby version so bytecode never crosses apps or
|
|
155
|
+
# incompatible VMs.
|
|
156
|
+
def bootsnap_cache_dir(script_path)
|
|
157
|
+
app_key = File.dirname(File.expand_path(script_path)).gsub(/[^a-zA-Z0-9]+/, "-").delete_prefix("-")
|
|
158
|
+
File.join(Dir.home, ".ruflet", "bootsnap", RUBY_VERSION, app_key)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def manual_reload_supported?
|
|
162
|
+
$stdin.tty? && Signal.list.key?("USR1")
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def print_hot_reload_banner
|
|
166
|
+
hint = manual_reload_supported? ? "; press \"r\" to reload, \"R\" to restart" : ""
|
|
167
|
+
puts "Hot reload: watching *.rb#{hint} (disable with --no-reload)"
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def start_reload_input_thread(run_state)
|
|
171
|
+
return nil unless manual_reload_supported?
|
|
172
|
+
|
|
173
|
+
Thread.new do
|
|
174
|
+
loop do
|
|
175
|
+
key = read_reload_key
|
|
176
|
+
break if key.nil?
|
|
177
|
+
break unless handle_reload_command(key, run_state)
|
|
178
|
+
end
|
|
179
|
+
rescue StandardError
|
|
180
|
+
nil
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Single keypress, no Enter required. getch puts the terminal in raw
|
|
185
|
+
# mode only for the duration of the read; intr keeps Ctrl-C working.
|
|
186
|
+
def read_reload_key
|
|
187
|
+
$stdin.getch(intr: true)
|
|
188
|
+
rescue ArgumentError
|
|
189
|
+
# Older Rubies without the intr keyword.
|
|
190
|
+
$stdin.getch
|
|
191
|
+
rescue StandardError
|
|
192
|
+
nil
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Returns false when the child process is gone and the thread should end.
|
|
196
|
+
def handle_reload_command(command, run_state)
|
|
197
|
+
case command
|
|
198
|
+
when "r"
|
|
199
|
+
Process.kill("USR1", run_state[:child_pid])
|
|
200
|
+
when "R"
|
|
201
|
+
run_state[:restart] = true
|
|
202
|
+
Process.kill("TERM", -run_state[:child_pid])
|
|
203
|
+
escalate_child_shutdown(run_state[:child_pid])
|
|
104
204
|
end
|
|
205
|
+
true
|
|
206
|
+
rescue Errno::ESRCH
|
|
207
|
+
run_state[:restart] = false
|
|
208
|
+
false
|
|
209
|
+
end
|
|
105
210
|
|
|
106
|
-
|
|
211
|
+
# An app can block TERM (bad traps, stuck threads); force the restart
|
|
212
|
+
# through with KILL if the child is still running after the grace period.
|
|
213
|
+
def escalate_child_shutdown(child_pid, grace_seconds: 3)
|
|
214
|
+
Thread.new do
|
|
215
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + grace_seconds
|
|
216
|
+
loop do
|
|
217
|
+
sleep 0.1
|
|
218
|
+
begin
|
|
219
|
+
Process.kill(0, child_pid)
|
|
220
|
+
rescue Errno::ESRCH
|
|
221
|
+
break
|
|
222
|
+
end
|
|
223
|
+
next unless Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
|
|
224
|
+
|
|
225
|
+
begin
|
|
226
|
+
Process.kill("KILL", -child_pid)
|
|
227
|
+
rescue Errno::ESRCH
|
|
228
|
+
nil
|
|
229
|
+
end
|
|
230
|
+
break
|
|
231
|
+
end
|
|
232
|
+
end
|
|
107
233
|
end
|
|
108
234
|
|
|
109
235
|
def apply_local_ruflet_dev_overrides(env)
|
|
@@ -174,34 +300,24 @@ module Ruflet
|
|
|
174
300
|
end
|
|
175
301
|
end
|
|
176
302
|
|
|
303
|
+
# The backend serves the web client on its own port, so the client loads
|
|
304
|
+
# and opens its websocket on the same origin. The explicit url parameter
|
|
305
|
+
# is kept because prebuilt production clients have no default URL and use
|
|
306
|
+
# it to select the websocket transport.
|
|
177
307
|
def launch_web_client(port)
|
|
178
|
-
web_dir = detect_web_client_dir
|
|
179
|
-
unless web_dir
|
|
180
|
-
warn "Web client build not found and prebuilt download failed."
|
|
181
|
-
return []
|
|
182
|
-
end
|
|
183
|
-
|
|
184
|
-
web_port = find_available_port(port + 1)
|
|
185
|
-
web_pid = Process.spawn("python3", "-m", "http.server", web_port.to_s, "--bind", "127.0.0.1", chdir: web_dir, out: File::NULL, err: File::NULL)
|
|
186
|
-
Process.detach(web_pid)
|
|
187
|
-
wait_for_server_boot(web_port)
|
|
188
308
|
backend_url = "http://localhost:#{port}"
|
|
189
|
-
web_url = "
|
|
309
|
+
web_url = "#{backend_url}/?#{URI.encode_www_form(url: backend_url)}"
|
|
190
310
|
browser_pid = open_in_browser_app_mode(web_url)
|
|
191
311
|
open_in_browser(web_url) if browser_pid.nil?
|
|
192
312
|
puts "Ruflet web client: #{web_url}"
|
|
193
313
|
puts "Ruflet backend ws: ws://localhost:#{port}/ws"
|
|
194
|
-
[
|
|
195
|
-
rescue Errno::ENOENT
|
|
196
|
-
warn "python3 is required to host web client locally."
|
|
197
|
-
warn "Install Python 3 and rerun."
|
|
198
|
-
[]
|
|
314
|
+
[browser_pid].compact
|
|
199
315
|
rescue StandardError => e
|
|
200
316
|
warn "Failed to launch web client: #{e.class}: #{e.message}"
|
|
201
317
|
[]
|
|
202
318
|
end
|
|
203
319
|
|
|
204
|
-
def wait_for_server_boot(port, timeout_seconds: 10)
|
|
320
|
+
def wait_for_server_boot(port, timeout_seconds: 10, poll_interval: 0.01)
|
|
205
321
|
Timeout.timeout(timeout_seconds) do
|
|
206
322
|
loop do
|
|
207
323
|
begin
|
|
@@ -210,7 +326,7 @@ module Ruflet
|
|
|
210
326
|
sock.close
|
|
211
327
|
break
|
|
212
328
|
rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH
|
|
213
|
-
sleep
|
|
329
|
+
sleep poll_interval
|
|
214
330
|
end
|
|
215
331
|
end
|
|
216
332
|
end
|
|
@@ -293,7 +409,10 @@ module Ruflet
|
|
|
293
409
|
return
|
|
294
410
|
end
|
|
295
411
|
|
|
296
|
-
|
|
412
|
+
# The Flutter client took the URL as argv; a Ruflet app used as the
|
|
413
|
+
# client reads RUFLET_URL, so it connects instead of showing its own
|
|
414
|
+
# launcher. Pass both so either client auto-connects.
|
|
415
|
+
pid = Process.spawn({ "RUFLET_URL" => url }, *cmd, out: File::NULL, err: File::NULL)
|
|
297
416
|
Process.detach(pid)
|
|
298
417
|
if !pid
|
|
299
418
|
warn "Failed to launch desktop client: #{cmd.first}"
|
|
@@ -306,6 +425,38 @@ module Ruflet
|
|
|
306
425
|
[]
|
|
307
426
|
end
|
|
308
427
|
|
|
428
|
+
# A Ruflet app used as the preview client builds its native project into
|
|
429
|
+
# build/client, so its Flutter output is one level deeper than a bare
|
|
430
|
+
# Flutter client's. Search both, and never assume the app is named
|
|
431
|
+
# ruflet_client: the bundle takes the app's own display name.
|
|
432
|
+
def client_build_roots(root)
|
|
433
|
+
[File.join(root, "build", "client"), root].select { |dir| Dir.exist?(dir) }
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
def executable_file?(path)
|
|
437
|
+
File.file?(path) && File.executable?(path)
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
# A Flutter project's own web/ folder holds a source index.html, so
|
|
441
|
+
# index.html alone would match an unbuilt project. Require a compiled
|
|
442
|
+
# entrypoint as well.
|
|
443
|
+
WEB_BUILD_MARKERS = %w[flutter_bootstrap.js main.dart.js flutter.js].freeze
|
|
444
|
+
|
|
445
|
+
def built_web_client_dir?(dir)
|
|
446
|
+
return false unless Dir.exist?(dir) && File.file?(File.join(dir, "index.html"))
|
|
447
|
+
|
|
448
|
+
WEB_BUILD_MARKERS.any? { |marker| File.file?(File.join(dir, marker)) }
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
def macos_app_executable(app_bundle)
|
|
452
|
+
macos_dir = File.join(app_bundle, "Contents", "MacOS")
|
|
453
|
+
return nil unless Dir.exist?(macos_dir)
|
|
454
|
+
|
|
455
|
+
Dir.children(macos_dir)
|
|
456
|
+
.map { |entry| File.join(macos_dir, entry) }
|
|
457
|
+
.find { |path| executable_file?(path) }
|
|
458
|
+
end
|
|
459
|
+
|
|
309
460
|
def detect_desktop_client_command(url)
|
|
310
461
|
root = ENV["RUFLET_CLIENT_DIR"]
|
|
311
462
|
root = File.expand_path("ruflet_client", Dir.pwd) if root.to_s.strip.empty?
|
|
@@ -314,28 +465,42 @@ module Ruflet
|
|
|
314
465
|
return nil unless root && Dir.exist?(root)
|
|
315
466
|
|
|
316
467
|
host_os = RbConfig::CONFIG["host_os"]
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
.find { |path| File.file?(path)
|
|
338
|
-
|
|
468
|
+
client_build_roots(root).each do |base|
|
|
469
|
+
if host_os.match?(/darwin/i)
|
|
470
|
+
search = %w[Release Debug].map { |config| File.join(base, "build", "macos", "Build", "Products", config) }
|
|
471
|
+
search << File.join(base, "desktop")
|
|
472
|
+
search.each do |dir|
|
|
473
|
+
next unless Dir.exist?(dir)
|
|
474
|
+
|
|
475
|
+
Dir.glob(File.join(dir, "*.app")).sort.each do |app_bundle|
|
|
476
|
+
executable = macos_app_executable(app_bundle)
|
|
477
|
+
return [executable, url] if executable
|
|
478
|
+
end
|
|
479
|
+
end
|
|
480
|
+
elsif host_os.match?(/mswin|mingw|cygwin/i)
|
|
481
|
+
search = [
|
|
482
|
+
File.join(base, "build", "windows", "x64", "runner", "Release"),
|
|
483
|
+
File.join(base, "desktop")
|
|
484
|
+
]
|
|
485
|
+
search.each do |dir|
|
|
486
|
+
next unless Dir.exist?(dir)
|
|
487
|
+
|
|
488
|
+
exe = Dir.glob(File.join(dir, "*.exe")).sort.find { |path| File.file?(path) }
|
|
489
|
+
return [exe, url] if exe
|
|
490
|
+
end
|
|
491
|
+
else
|
|
492
|
+
search = [
|
|
493
|
+
File.join(base, "build", "linux", "x64", "release", "bundle"),
|
|
494
|
+
File.join(base, "desktop")
|
|
495
|
+
]
|
|
496
|
+
search.each do |dir|
|
|
497
|
+
next unless Dir.exist?(dir)
|
|
498
|
+
|
|
499
|
+
candidate = Dir.children(dir).sort
|
|
500
|
+
.map { |entry| File.join(dir, entry) }
|
|
501
|
+
.find { |path| executable_file?(path) }
|
|
502
|
+
return [candidate, url] if candidate
|
|
503
|
+
end
|
|
339
504
|
end
|
|
340
505
|
end
|
|
341
506
|
|
|
@@ -349,10 +514,11 @@ module Ruflet
|
|
|
349
514
|
root ||= ensure_prebuilt_client(web: true)
|
|
350
515
|
return nil unless root && Dir.exist?(root)
|
|
351
516
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
517
|
+
client_build_roots(root).each do |base|
|
|
518
|
+
[File.join(base, "build", "web"), File.join(base, "web")].each do |dir|
|
|
519
|
+
return dir if built_web_client_dir?(dir)
|
|
520
|
+
end
|
|
521
|
+
end
|
|
356
522
|
|
|
357
523
|
nil
|
|
358
524
|
end
|
|
@@ -371,17 +537,29 @@ module Ruflet
|
|
|
371
537
|
return nil if desktop_asset.nil?
|
|
372
538
|
wanted_assets << { kind: :desktop, name: desktop_asset, platform: platform }
|
|
373
539
|
end
|
|
374
|
-
|
|
540
|
+
cache_ready = wanted_assets.empty? || prebuilt_assets_present?(cache_root, web: web, desktop: desktop, platform: platform)
|
|
541
|
+
release = nil
|
|
542
|
+
if !force && cache_ready
|
|
375
543
|
ensure_client_manifest(cache_root, platform: platform)
|
|
376
|
-
|
|
544
|
+
manifest = read_client_manifest(cache_root)
|
|
545
|
+
return cache_root unless client_update_due?(manifest)
|
|
546
|
+
|
|
547
|
+
release = fetch_release_for_version(wanted_assets: wanted_assets)
|
|
548
|
+
if release.nil? || client_release_current?(manifest, release, wanted_assets)
|
|
549
|
+
mark_client_update_checked(cache_root)
|
|
550
|
+
return cache_root
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
force = true
|
|
377
554
|
end
|
|
378
555
|
|
|
379
|
-
release
|
|
556
|
+
release ||= fetch_release_for_version(wanted_assets: wanted_assets)
|
|
380
557
|
return nil unless release
|
|
381
558
|
|
|
382
559
|
assets = release.fetch("assets", [])
|
|
383
560
|
asset_names = assets.map { |a| a["name"].to_s }
|
|
384
561
|
installed_assets = []
|
|
562
|
+
release_revision = client_release_revision(release)
|
|
385
563
|
Dir.mktmpdir("ruflet-prebuilt-") do |tmpdir|
|
|
386
564
|
wanted_assets.each do |wanted|
|
|
387
565
|
asset_name = wanted.fetch(:name)
|
|
@@ -408,7 +586,8 @@ module Ruflet
|
|
|
408
586
|
"kind" => wanted[:kind].to_s,
|
|
409
587
|
"platform" => wanted[:platform] || platform,
|
|
410
588
|
"asset_name" => resolved_name,
|
|
411
|
-
"download_url" => asset.fetch("browser_download_url")
|
|
589
|
+
"download_url" => asset.fetch("browser_download_url"),
|
|
590
|
+
"release_revision" => release_revision
|
|
412
591
|
}
|
|
413
592
|
end
|
|
414
593
|
end
|
|
@@ -467,12 +646,71 @@ module Ruflet
|
|
|
467
646
|
File.join(Dir.home, ".ruflet", "client", ruflet_version, platform.to_s)
|
|
468
647
|
end
|
|
469
648
|
|
|
470
|
-
def fetch_release_for_version
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
release_by_tag(
|
|
475
|
-
|
|
649
|
+
def fetch_release_for_version(wanted_assets: [])
|
|
650
|
+
releases = []
|
|
651
|
+
channel = client_release_channel
|
|
652
|
+
if channel != "stable"
|
|
653
|
+
rolling = release_by_tag(channel)
|
|
654
|
+
releases << rolling if rolling && rolling_release_complete?(rolling)
|
|
655
|
+
end
|
|
656
|
+
releases << release_by_tag("v#{ruflet_version}")
|
|
657
|
+
releases << release_by_tag(ruflet_version)
|
|
658
|
+
releases << release_latest
|
|
659
|
+
releases.compact.find { |release| release_has_wanted_assets?(release, wanted_assets) }
|
|
660
|
+
end
|
|
661
|
+
|
|
662
|
+
def client_release_channel
|
|
663
|
+
value = ENV.fetch("RUFLET_CLIENT_CHANNEL", "prebuild-main").to_s.strip
|
|
664
|
+
value.empty? ? "prebuild-main" : value
|
|
665
|
+
end
|
|
666
|
+
|
|
667
|
+
def rolling_release_complete?(release)
|
|
668
|
+
release.fetch("assets", []).any? { |asset| asset["name"] == CLIENT_CHANNEL_MANIFEST }
|
|
669
|
+
end
|
|
670
|
+
|
|
671
|
+
def release_has_wanted_assets?(release, wanted_assets)
|
|
672
|
+
assets = release.fetch("assets", [])
|
|
673
|
+
wanted_assets.all? do |wanted|
|
|
674
|
+
assets.any? { |asset| asset["name"] == wanted[:name] } || fallback_release_asset(assets, wanted)
|
|
675
|
+
end
|
|
676
|
+
end
|
|
677
|
+
|
|
678
|
+
def client_release_revision(release)
|
|
679
|
+
marker = release.fetch("assets", []).find { |asset| asset["name"] == CLIENT_CHANNEL_MANIFEST }
|
|
680
|
+
source = marker || release
|
|
681
|
+
[source["id"], source["updated_at"] || source["published_at"], source["size"]].compact.join(":")
|
|
682
|
+
end
|
|
683
|
+
|
|
684
|
+
def client_release_current?(manifest, release, wanted_assets)
|
|
685
|
+
return false unless manifest
|
|
686
|
+
|
|
687
|
+
revision = client_release_revision(release)
|
|
688
|
+
targets = Array(manifest["targets"])
|
|
689
|
+
wanted_assets.all? do |wanted|
|
|
690
|
+
targets.any? do |target|
|
|
691
|
+
target["kind"] == wanted[:kind].to_s &&
|
|
692
|
+
(wanted[:platform].nil? || target["platform"] == wanted[:platform]) &&
|
|
693
|
+
target["release_revision"] == revision
|
|
694
|
+
end
|
|
695
|
+
end
|
|
696
|
+
end
|
|
697
|
+
|
|
698
|
+
def client_update_due?(manifest)
|
|
699
|
+
return false if ENV["RUFLET_CLIENT_AUTO_UPDATE"].to_s.match?(/\A(?:0|false|no|off)\z/i)
|
|
700
|
+
return true unless manifest
|
|
701
|
+
|
|
702
|
+
checked_at = manifest["checked_at"] || manifest["installed_at"]
|
|
703
|
+
return true if checked_at.to_s.empty?
|
|
704
|
+
|
|
705
|
+
Time.now.utc - Time.iso8601(checked_at) >= client_update_interval
|
|
706
|
+
rescue ArgumentError
|
|
707
|
+
true
|
|
708
|
+
end
|
|
709
|
+
|
|
710
|
+
def client_update_interval
|
|
711
|
+
Integer(ENV.fetch("RUFLET_CLIENT_UPDATE_INTERVAL", DEFAULT_CLIENT_UPDATE_INTERVAL.to_s), 10).clamp(0, 7 * 24 * 60 * 60)
|
|
712
|
+
rescue ArgumentError
|
|
713
|
+
DEFAULT_CLIENT_UPDATE_INTERVAL
|
|
476
714
|
end
|
|
477
715
|
|
|
478
716
|
def ruflet_version
|
|
@@ -596,18 +834,36 @@ module Ruflet
|
|
|
596
834
|
|
|
597
835
|
def write_client_manifest(root, platform:, release:, assets:)
|
|
598
836
|
FileUtils.mkdir_p(root)
|
|
837
|
+
existing = read_client_manifest(root) || {}
|
|
838
|
+
installed_targets = Array(existing["targets"])
|
|
839
|
+
assets.each do |asset|
|
|
840
|
+
installed_targets.reject! do |target|
|
|
841
|
+
target["kind"] == asset["kind"] && target["platform"] == asset["platform"]
|
|
842
|
+
end
|
|
843
|
+
installed_targets << asset
|
|
844
|
+
end
|
|
599
845
|
payload = {
|
|
600
|
-
"schema" =>
|
|
846
|
+
"schema" => 2,
|
|
601
847
|
"ruflet_version" => ruflet_version,
|
|
602
848
|
"platform" => platform,
|
|
603
|
-
"release_tag" => release && release["tag_name"],
|
|
604
|
-
"
|
|
849
|
+
"release_tag" => release && release["tag_name"] || existing["release_tag"],
|
|
850
|
+
"release_revision" => release && client_release_revision(release) || existing["release_revision"],
|
|
851
|
+
"released_at" => release && release["published_at"] || existing["released_at"],
|
|
852
|
+
"checked_at" => Time.now.utc.iso8601,
|
|
605
853
|
"installed_at" => Time.now.utc.iso8601,
|
|
606
|
-
"targets" =>
|
|
854
|
+
"targets" => installed_targets
|
|
607
855
|
}
|
|
608
856
|
File.write(client_manifest_path(root), JSON.pretty_generate(payload))
|
|
609
857
|
end
|
|
610
858
|
|
|
859
|
+
def mark_client_update_checked(root)
|
|
860
|
+
manifest = read_client_manifest(root)
|
|
861
|
+
return unless manifest
|
|
862
|
+
|
|
863
|
+
manifest["checked_at"] = Time.now.utc.iso8601
|
|
864
|
+
File.write(client_manifest_path(root), JSON.pretty_generate(manifest))
|
|
865
|
+
end
|
|
866
|
+
|
|
611
867
|
def print_mobile_qr_hint(port: 8550)
|
|
612
868
|
host = best_lan_host
|
|
613
869
|
payload = "http://#{host}:#{port}"
|
data/lib/ruflet/cli/templates.rb
CHANGED
|
@@ -7,7 +7,10 @@ module Ruflet
|
|
|
7
7
|
Ruflet.run do |page|
|
|
8
8
|
page.title = "Counter Demo"
|
|
9
9
|
count = 0
|
|
10
|
-
count_text = text(
|
|
10
|
+
count_text = text(
|
|
11
|
+
value: count.to_s,
|
|
12
|
+
style: { size: 40, weight: "w700" }
|
|
13
|
+
)
|
|
11
14
|
page.floating_action_button = fab(
|
|
12
15
|
icon: "add",
|
|
13
16
|
on_click: ->(_e) do
|
|
@@ -23,7 +26,7 @@ module Ruflet
|
|
|
23
26
|
alignment: Ruflet::MainAxisAlignment::CENTER,
|
|
24
27
|
horizontal_alignment: Ruflet::CrossAxisAlignment::CENTER,
|
|
25
28
|
children: [
|
|
26
|
-
text("You have pushed the button this many times:"),
|
|
29
|
+
text(value: "You have pushed the button this many times:"),
|
|
27
30
|
count_text
|
|
28
31
|
]
|
|
29
32
|
)
|
|
@@ -54,14 +57,14 @@ module Ruflet
|
|
|
54
57
|
## Run
|
|
55
58
|
|
|
56
59
|
```bash
|
|
57
|
-
|
|
60
|
+
ruflet run main
|
|
58
61
|
```
|
|
59
62
|
|
|
60
63
|
## Build
|
|
61
64
|
|
|
62
65
|
```bash
|
|
63
|
-
|
|
64
|
-
|
|
66
|
+
ruflet build apk
|
|
67
|
+
ruflet build ios
|
|
65
68
|
```
|
|
66
69
|
MD
|
|
67
70
|
end
|
data/lib/ruflet/cli.rb
CHANGED
|
@@ -66,14 +66,14 @@ module Ruflet
|
|
|
66
66
|
ruflet --version
|
|
67
67
|
ruflet create <appname>
|
|
68
68
|
ruflet new <appname>
|
|
69
|
-
ruflet run [scriptname|path] [--web|--desktop] [--port PORT]
|
|
69
|
+
ruflet run [scriptname|path] [--web|--desktop] [--port PORT] [--no-reload]
|
|
70
70
|
ruflet update [web|desktop|all] [--check] [--force] [--platform PLATFORM]
|
|
71
71
|
ruflet debug [scriptname|path]
|
|
72
72
|
ruflet build <apk|android|ios|aab|web|macos|windows|linux> [--self] [--verbose]
|
|
73
73
|
ruflet install [--device DEVICE_ID] [--verbose]
|
|
74
74
|
ruflet devices
|
|
75
75
|
ruflet emulators
|
|
76
|
-
ruflet doctor
|
|
76
|
+
ruflet doctor [--fix] [--verbose]
|
|
77
77
|
HELP
|
|
78
78
|
end
|
|
79
79
|
|
|
@@ -89,7 +89,7 @@ module Ruflet
|
|
|
89
89
|
private
|
|
90
90
|
|
|
91
91
|
def ensure_first_run_assets(command)
|
|
92
|
-
return
|
|
92
|
+
return unless %w[create new bootstrap init update debug build install doctor].include?(command)
|
|
93
93
|
return unless respond_to?(:download_ruflet_assets, true)
|
|
94
94
|
|
|
95
95
|
send(:download_ruflet_assets)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Child-process entrypoint used by `ruflet run` when hot reload is enabled.
|
|
4
|
+
# The CLI spawns `ruby [-rbundler/setup] harness.rb` with:
|
|
5
|
+
# RUFLET_APP_SCRIPT absolute path to the application script (required)
|
|
6
|
+
# RUFLET_WATCH_ROOT directory to watch for *.rb changes (optional)
|
|
7
|
+
# RUFLET_BOOTSNAP_DIR bootsnap cache directory (optional)
|
|
8
|
+
|
|
9
|
+
# Bootsnap caches compiled bytecode and $LOAD_PATH resolution, which cuts the
|
|
10
|
+
# cold-boot cost paid on every full restart ("R"). It is optional: used only
|
|
11
|
+
# when the app bundles it (add `gem "bootsnap"` to speed restarts up). Set up
|
|
12
|
+
# before requiring the framework so those requires hit the cache.
|
|
13
|
+
if (cache_dir = ENV["RUFLET_BOOTSNAP_DIR"].to_s) && !cache_dir.empty?
|
|
14
|
+
begin
|
|
15
|
+
require "bootsnap"
|
|
16
|
+
Bootsnap.setup(
|
|
17
|
+
cache_dir: cache_dir,
|
|
18
|
+
load_path_cache: true,
|
|
19
|
+
compile_cache_iseq: true,
|
|
20
|
+
compile_cache_yaml: false
|
|
21
|
+
)
|
|
22
|
+
rescue LoadError
|
|
23
|
+
# bootsnap not in the bundle; boot without it.
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
require_relative "../hot_reload"
|
|
28
|
+
|
|
29
|
+
script = ENV["RUFLET_APP_SCRIPT"].to_s
|
|
30
|
+
abort "ruflet hot reload: RUFLET_APP_SCRIPT is not set" if script.empty?
|
|
31
|
+
|
|
32
|
+
Ruflet::HotReload.run(script: script, watch_root: ENV["RUFLET_WATCH_ROOT"])
|