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.
@@ -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
- child_pid = Process.spawn(env, *cmd, pgroup: true)
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
- _pid, status = Process.wait2(child_pid)
68
- status.success? ? 0 : (status.exitstatus || 1)
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?(child_pid) && child_pid
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", script_path]
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
- [RbConfig.ruby, script_path]
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 = "http://localhost:#{web_port}/?#{URI.encode_www_form(url: backend_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
- [web_pid, browser_pid].compact
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 0.15
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
- pid = Process.spawn(*cmd, out: File::NULL, err: File::NULL)
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
- if host_os.match?(/darwin/i)
318
- release_bin = File.join(root, "build", "macos", "Build", "Products", "Release", "ruflet_client.app", "Contents", "MacOS", "ruflet_client")
319
- debug_bin = File.join(root, "build", "macos", "Build", "Products", "Debug", "ruflet_client.app", "Contents", "MacOS", "ruflet_client")
320
- prebuilt_bin = File.join(root, "desktop", "ruflet_client.app", "Contents", "MacOS", "ruflet_client")
321
- executable = [release_bin, debug_bin].find { |p| File.file?(p) && File.executable?(p) }
322
- executable ||= prebuilt_bin if File.file?(prebuilt_bin) && File.executable?(prebuilt_bin)
323
- return [executable, url] if executable
324
- elsif host_os.match?(/mswin|mingw|cygwin/i)
325
- exe = File.join(root, "build", "windows", "x64", "runner", "Release", "ruflet_client.exe")
326
- prebuilt = File.join(root, "desktop", "ruflet_client.exe")
327
- exe = prebuilt if !File.file?(exe) && File.file?(prebuilt)
328
- return [exe, url] if File.file?(exe)
329
- else
330
- direct = File.join(root, "build", "linux", "x64", "release", "bundle", "ruflet_client")
331
- prebuilt_direct = File.join(root, "desktop", "ruflet_client")
332
- direct = prebuilt_direct if !File.file?(direct) && File.file?(prebuilt_direct)
333
- return [direct, url] if File.file?(direct)
334
- bundle_dir = File.join(root, "build", "linux", "x64", "release", "bundle")
335
- if Dir.exist?(bundle_dir)
336
- candidate = Dir.children(bundle_dir).map { |f| File.join(bundle_dir, f) }
337
- .find { |path| File.file?(path) && File.executable?(path) }
338
- return [candidate, url] if candidate
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
- built = File.join(root, "build", "web")
353
- return built if Dir.exist?(built) && File.file?(File.join(built, "index.html"))
354
- prebuilt = File.join(root, "web")
355
- return prebuilt if Dir.exist?(prebuilt) && File.file?(File.join(prebuilt, "index.html"))
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
- if !force && (wanted_assets.empty? || prebuilt_assets_present?(cache_root, web: web, desktop: desktop, platform: platform))
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
- return cache_root
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 = fetch_release_for_version
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
- release_by_tag("v#{ruflet_version}") ||
472
- release_by_tag(ruflet_version) ||
473
- release_by_tag("prebuild") ||
474
- release_by_tag("prebuild-main") ||
475
- release_latest
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" => 1,
846
+ "schema" => 2,
601
847
  "ruflet_version" => ruflet_version,
602
848
  "platform" => platform,
603
- "release_tag" => release && release["tag_name"],
604
- "released_at" => release && release["published_at"],
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" => assets
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}"
@@ -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(count.to_s, style: {size: 40})
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
- bundle exec ruflet run main
60
+ ruflet run main
58
61
  ```
59
62
 
60
63
  ## Build
61
64
 
62
65
  ```bash
63
- bundle exec ruflet build apk
64
- bundle exec ruflet build ios
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 if %w[version -v --version help -h --help].include?(command)
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"])