ruflet 0.0.19 → 0.0.21

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.
@@ -131,7 +131,21 @@ module Ruflet
131
131
  end
132
132
 
133
133
  def fvm_env
134
- { "PATH" => "#{pub_cache_bin_dir}#{File::PATH_SEPARATOR}#{ENV.fetch('PATH', '')}" }
134
+ utf8_locale_env.merge("PATH" => "#{pub_cache_bin_dir}#{File::PATH_SEPARATOR}#{ENV.fetch('PATH', '')}")
135
+ end
136
+
137
+ # CocoaPods aborts with "Unicode Normalization not appropriate for
138
+ # ASCII-8BIT" when it inherits a non-UTF-8 locale, which takes the whole
139
+ # iOS and macOS build with it. Hand the toolchain a UTF-8 locale unless
140
+ # the environment already has one.
141
+ def utf8_locale_env
142
+ return {} if utf8_locale?(ENV["LC_ALL"]) || utf8_locale?(ENV["LANG"])
143
+
144
+ { "LANG" => "en_US.UTF-8", "LC_ALL" => "en_US.UTF-8" }
145
+ end
146
+
147
+ def utf8_locale?(value)
148
+ value.to_s.match?(/utf-?8/i)
135
149
  end
136
150
 
137
151
  def tools_from_flutter_bin(flutter_bin)
@@ -143,11 +157,11 @@ module Ruflet
143
157
  {
144
158
  flutter: flutter_bin,
145
159
  dart: (File.executable?(dart) ? dart : "dart"),
146
- env: {
160
+ env: utf8_locale_env.merge(
147
161
  "PATH" => "#{bin_dir}#{File::PATH_SEPARATOR}#{pub_cache_bin_dir}#{File::PATH_SEPARATOR}#{ENV.fetch('PATH', '')}",
148
162
  "FLUTTER_ROOT" => sdk_root,
149
163
  "FVM_FLUTTER_SDK" => sdk_root
150
- }
164
+ )
151
165
  }
152
166
  end
153
167
 
@@ -25,6 +25,7 @@ module Ruflet
25
25
  "lottie" => { package: "flet_lottie", alias: "ruflet_lottie" },
26
26
  "map" => { package: "flet_map", alias: "ruflet_map" },
27
27
  "permission_handler" => { package: "flet_permission_handler", alias: "ruflet_permission_handler" },
28
+ "qrcode_scanner" => { package: "ruflet_qrcode_scanner", alias: "ruflet_qrcode_scanner" },
28
29
  "rive" => { package: "flet_rive", alias: "ruflet_rive" },
29
30
  "secure_storage" => { package: "flet_secure_storage", alias: "ruflet_secure_storage" },
30
31
  "video" => { package: "flet_video", alias: "ruflet_video" },
@@ -54,11 +55,11 @@ module Ruflet
54
55
  puts "Run:"
55
56
  puts " cd #{project_name}"
56
57
  puts " bundle install"
57
- puts " bundle exec ruflet run main.rb"
58
+ puts " ruflet run main.rb"
58
59
  puts
59
60
  puts "Build:"
60
- puts " bundle exec ruflet build android --self"
61
- puts " bundle exec ruflet build ios --self"
61
+ puts " ruflet build android --self"
62
+ puts " ruflet build ios --self"
62
63
  0
63
64
  end
64
65
 
@@ -81,21 +82,23 @@ module Ruflet
81
82
  end
82
83
 
83
84
  def resolve_ruflet_client_template_root
84
- [
85
- File.expand_path("../../../../../templates/ruflet_flutter_template", __dir__)
86
- ].each do |template|
87
- return template if Dir.exist?(template)
85
+ explicit = ENV["RUFLET_TEMPLATE_ROOT"].to_s.strip
86
+ unless explicit.empty?
87
+ explicit_template = [
88
+ explicit,
89
+ File.join(explicit, "templates", "ruflet_flutter_template")
90
+ ].find { |path| valid_ruflet_template?(path) }
91
+ return explicit_template if explicit_template
88
92
  end
89
93
 
90
- cached_template = cached_ruflet_client_template_root
91
- return cached_template if Dir.exist?(cached_template)
94
+ sibling_template = File.expand_path(
95
+ "../../../../../../ruflet-template/templates/ruflet_flutter_template",
96
+ __dir__
97
+ )
98
+ return sibling_template if valid_ruflet_template?(sibling_template)
92
99
 
93
- [
94
- File.expand_path("../../../ruflet_client", __dir__),
95
- File.expand_path("../../../../../ruflet_client", __dir__)
96
- ].each do |fallback|
97
- return fallback if Dir.exist?(fallback)
98
- end
100
+ cached_template = cached_ruflet_client_template_root
101
+ return cached_template if valid_ruflet_template?(cached_template)
99
102
 
100
103
  nil
101
104
  end
@@ -202,10 +205,16 @@ module Ruflet
202
205
  git_revision(File.expand_path("../..", template_root))
203
206
  end
204
207
 
208
+ # Always record where the client came from. A template resolved from a
209
+ # local checkout may have no readable revision, and skipping the marker
210
+ # entirely left the copied client indistinguishable from one that was
211
+ # never stamped. "local" never matches an expected revision, so the client
212
+ # is refreshed rather than treated as current.
205
213
  def write_client_template_revision(target, revision)
206
- return unless revision
214
+ value = revision.to_s.strip
215
+ value = "local" if value.empty?
207
216
 
208
- File.write(File.join(target, ".ruflet-template-revision"), "#{revision}\n")
217
+ File.write(File.join(target, ".ruflet-template-revision"), "#{value}\n")
209
218
  end
210
219
 
211
220
  def client_template_current?(target, template_root)
@@ -255,12 +264,6 @@ module Ruflet
255
264
  def write_default_ruflet_config(root, app_name)
256
265
  File.write(File.join(root, "ruflet.yaml"), <<~YAML)
257
266
  app:
258
- name: #{app_name}
259
- display_name: #{humanize_name(app_name)}
260
- package_name: #{app_name.gsub(/[^a-zA-Z0-9_]+/, "_").downcase}
261
- organization: com.example
262
- version: 1.0.0+1
263
- description: A new Ruflet app.
264
267
  # Required for server-driven builds: `ruflet build ios`, `apk`, `web`, etc. without `--self`.
265
268
  # Example: https://api.example.com
266
269
  backend_url: ""
@@ -284,6 +287,16 @@ module Ruflet
284
287
  YAML
285
288
 
286
289
  File.write(File.join(root, "services.yaml"), <<~YAML)
290
+ # Application identity used by the Flutter build pipeline. Ruflet derives
291
+ # the mobile identifier as organization.package_name and passes it to
292
+ # change_app_package_name for Android and iOS builds.
293
+ app:
294
+ app_name: #{humanize_name(app_name)}
295
+ package_name: #{app_name.gsub(/[^a-zA-Z0-9_]+/, "_").downcase}
296
+ organization: com.example
297
+ version: 1.0.0+1
298
+ description: A new Ruflet app.
299
+
287
300
  # Native capabilities that require platform permissions. Ruflet activates
288
301
  # the matching client extensions and writes Android/iOS permissions.
289
302
  # Supported services: camera, microphone, location, motion
@@ -20,11 +20,12 @@ module Ruflet
20
20
  DEFAULT_CLIENT_UPDATE_INTERVAL = 6 * 60 * 60
21
21
 
22
22
  def command_run(args)
23
- options = { target: "mobile", requested_port: 8550 }
23
+ options = { target: "mobile", requested_port: 8550, reload: true }
24
24
  parser = OptionParser.new do |o|
25
25
  o.on("--web") { options[:target] = "web" }
26
26
  o.on("--desktop") { options[:target] = "desktop" }
27
27
  o.on("--port PORT", Integer) { |v| options[:requested_port] = v }
28
+ o.on("--no-reload") { options[:reload] = false }
28
29
  end
29
30
  parser.parse!(args)
30
31
 
@@ -47,18 +48,32 @@ module Ruflet
47
48
  assets_dir = File.join(File.dirname(script_path), "assets")
48
49
  env["RUFLET_ASSETS_DIR"] = assets_dir if File.directory?(assets_dir)
49
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
+
50
63
  print_run_banner(target: options[:target], requested_port: options[:requested_port], port: selected_port)
51
64
  print_mobile_qr_hint(port: selected_port) if options[:target] == "mobile"
65
+ print_hot_reload_banner if options[:reload]
52
66
 
53
67
  gemfile_path = find_nearest_gemfile(Dir.pwd)
54
- 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])
55
69
  return 1 unless cmd
56
70
 
57
- 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
58
73
  launched_client_pids = launch_target_client(options[:target], selected_port)
59
74
  forward_signal = lambda do |signal|
60
75
  begin
61
- Process.kill(signal, -child_pid)
76
+ Process.kill(signal, -run_state[:child_pid])
62
77
  rescue Errno::ESRCH
63
78
  nil
64
79
  end
@@ -67,15 +82,28 @@ module Ruflet
67
82
  previous_int = Signal.trap("INT") { forward_signal.call("INT") }
68
83
  previous_term = Signal.trap("TERM") { forward_signal.call("TERM") }
69
84
 
70
- _pid, status = Process.wait2(child_pid)
71
- 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
72
99
  ensure
100
+ reload_input_thread&.kill if defined?(reload_input_thread)
73
101
  Signal.trap("INT", previous_int) if defined?(previous_int) && previous_int
74
102
  Signal.trap("TERM", previous_term) if defined?(previous_term) && previous_term
75
103
 
76
- if defined?(child_pid) && child_pid
104
+ if defined?(run_state) && run_state && run_state[:child_pid]
77
105
  begin
78
- Process.kill("TERM", -child_pid)
106
+ Process.kill("TERM", -run_state[:child_pid])
79
107
  rescue Errno::ESRCH
80
108
  nil
81
109
  end
@@ -97,16 +125,111 @@ module Ruflet
97
125
 
98
126
  private
99
127
 
100
- 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
+
101
137
  if gemfile_path
102
138
  env["BUNDLE_GEMFILE"] = gemfile_path
103
139
  bundle_ready = system(env, RbConfig.ruby, "-S", "bundle", "check", out: File::NULL, err: File::NULL)
104
140
  return nil unless bundle_ready || system(env, RbConfig.ruby, "-S", "bundle", "install")
105
141
 
106
- return [RbConfig.ruby, "-rbundler/setup", script_path]
142
+ return [RbConfig.ruby, "-rbundler/setup", entry_script]
107
143
  end
108
144
 
109
- [RbConfig.ruby, script_path]
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])
204
+ end
205
+ true
206
+ rescue Errno::ESRCH
207
+ run_state[:restart] = false
208
+ false
209
+ end
210
+
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
110
233
  end
111
234
 
112
235
  def apply_local_ruflet_dev_overrides(env)
@@ -177,34 +300,23 @@ module Ruflet
177
300
  end
178
301
  end
179
302
 
303
+ # The backend serves the web client on its own port, so the client reads
304
+ # the origin it was loaded from and opens its websocket there. No URL is
305
+ # passed in the query string: nothing in the client parses one.
180
306
  def launch_web_client(port)
181
- web_dir = detect_web_client_dir
182
- unless web_dir
183
- warn "Web client build not found and prebuilt download failed."
184
- return []
185
- end
186
-
187
- web_port = find_available_port(port + 1)
188
- 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)
189
- Process.detach(web_pid)
190
- wait_for_server_boot(web_port)
191
307
  backend_url = "http://localhost:#{port}"
192
- web_url = "http://localhost:#{web_port}/?#{URI.encode_www_form(url: backend_url)}"
308
+ web_url = "#{backend_url}/"
193
309
  browser_pid = open_in_browser_app_mode(web_url)
194
310
  open_in_browser(web_url) if browser_pid.nil?
195
311
  puts "Ruflet web client: #{web_url}"
196
312
  puts "Ruflet backend ws: ws://localhost:#{port}/ws"
197
- [web_pid, browser_pid].compact
198
- rescue Errno::ENOENT
199
- warn "python3 is required to host web client locally."
200
- warn "Install Python 3 and rerun."
201
- []
313
+ [browser_pid].compact
202
314
  rescue StandardError => e
203
315
  warn "Failed to launch web client: #{e.class}: #{e.message}"
204
316
  []
205
317
  end
206
318
 
207
- def wait_for_server_boot(port, timeout_seconds: 10)
319
+ def wait_for_server_boot(port, timeout_seconds: 10, poll_interval: 0.01)
208
320
  Timeout.timeout(timeout_seconds) do
209
321
  loop do
210
322
  begin
@@ -213,7 +325,7 @@ module Ruflet
213
325
  sock.close
214
326
  break
215
327
  rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH
216
- sleep 0.15
328
+ sleep poll_interval
217
329
  end
218
330
  end
219
331
  end
@@ -296,7 +408,10 @@ module Ruflet
296
408
  return
297
409
  end
298
410
 
299
- pid = Process.spawn(*cmd, out: File::NULL, err: File::NULL)
411
+ # The Flutter client took the URL as argv; a Ruflet app used as the
412
+ # client reads RUFLET_URL, so it connects instead of showing its own
413
+ # launcher. Pass both so either client auto-connects.
414
+ pid = Process.spawn({ "RUFLET_URL" => url }, *cmd, out: File::NULL, err: File::NULL)
300
415
  Process.detach(pid)
301
416
  if !pid
302
417
  warn "Failed to launch desktop client: #{cmd.first}"
@@ -309,6 +424,38 @@ module Ruflet
309
424
  []
310
425
  end
311
426
 
427
+ # A Ruflet app used as the preview client builds its native project into
428
+ # build/client, so its Flutter output is one level deeper than a bare
429
+ # Flutter client's. Search both, and never assume the app is named
430
+ # ruflet_client: the bundle takes the app's own display name.
431
+ def client_build_roots(root)
432
+ [File.join(root, "build", "client"), root].select { |dir| Dir.exist?(dir) }
433
+ end
434
+
435
+ def executable_file?(path)
436
+ File.file?(path) && File.executable?(path)
437
+ end
438
+
439
+ # A Flutter project's own web/ folder holds a source index.html, so
440
+ # index.html alone would match an unbuilt project. Require a compiled
441
+ # entrypoint as well.
442
+ WEB_BUILD_MARKERS = %w[flutter_bootstrap.js main.dart.js flutter.js].freeze
443
+
444
+ def built_web_client_dir?(dir)
445
+ return false unless Dir.exist?(dir) && File.file?(File.join(dir, "index.html"))
446
+
447
+ WEB_BUILD_MARKERS.any? { |marker| File.file?(File.join(dir, marker)) }
448
+ end
449
+
450
+ def macos_app_executable(app_bundle)
451
+ macos_dir = File.join(app_bundle, "Contents", "MacOS")
452
+ return nil unless Dir.exist?(macos_dir)
453
+
454
+ Dir.children(macos_dir)
455
+ .map { |entry| File.join(macos_dir, entry) }
456
+ .find { |path| executable_file?(path) }
457
+ end
458
+
312
459
  def detect_desktop_client_command(url)
313
460
  root = ENV["RUFLET_CLIENT_DIR"]
314
461
  root = File.expand_path("ruflet_client", Dir.pwd) if root.to_s.strip.empty?
@@ -317,28 +464,42 @@ module Ruflet
317
464
  return nil unless root && Dir.exist?(root)
318
465
 
319
466
  host_os = RbConfig::CONFIG["host_os"]
320
- if host_os.match?(/darwin/i)
321
- release_bin = File.join(root, "build", "macos", "Build", "Products", "Release", "ruflet_client.app", "Contents", "MacOS", "ruflet_client")
322
- debug_bin = File.join(root, "build", "macos", "Build", "Products", "Debug", "ruflet_client.app", "Contents", "MacOS", "ruflet_client")
323
- prebuilt_bin = File.join(root, "desktop", "ruflet_client.app", "Contents", "MacOS", "ruflet_client")
324
- executable = [release_bin, debug_bin].find { |p| File.file?(p) && File.executable?(p) }
325
- executable ||= prebuilt_bin if File.file?(prebuilt_bin) && File.executable?(prebuilt_bin)
326
- return [executable, url] if executable
327
- elsif host_os.match?(/mswin|mingw|cygwin/i)
328
- exe = File.join(root, "build", "windows", "x64", "runner", "Release", "ruflet_client.exe")
329
- prebuilt = File.join(root, "desktop", "ruflet_client.exe")
330
- exe = prebuilt if !File.file?(exe) && File.file?(prebuilt)
331
- return [exe, url] if File.file?(exe)
332
- else
333
- direct = File.join(root, "build", "linux", "x64", "release", "bundle", "ruflet_client")
334
- prebuilt_direct = File.join(root, "desktop", "ruflet_client")
335
- direct = prebuilt_direct if !File.file?(direct) && File.file?(prebuilt_direct)
336
- return [direct, url] if File.file?(direct)
337
- bundle_dir = File.join(root, "build", "linux", "x64", "release", "bundle")
338
- if Dir.exist?(bundle_dir)
339
- candidate = Dir.children(bundle_dir).map { |f| File.join(bundle_dir, f) }
340
- .find { |path| File.file?(path) && File.executable?(path) }
341
- return [candidate, url] if candidate
467
+ client_build_roots(root).each do |base|
468
+ if host_os.match?(/darwin/i)
469
+ search = %w[Release Debug].map { |config| File.join(base, "build", "macos", "Build", "Products", config) }
470
+ search << File.join(base, "desktop")
471
+ search.each do |dir|
472
+ next unless Dir.exist?(dir)
473
+
474
+ Dir.glob(File.join(dir, "*.app")).sort.each do |app_bundle|
475
+ executable = macos_app_executable(app_bundle)
476
+ return [executable, url] if executable
477
+ end
478
+ end
479
+ elsif host_os.match?(/mswin|mingw|cygwin/i)
480
+ search = [
481
+ File.join(base, "build", "windows", "x64", "runner", "Release"),
482
+ File.join(base, "desktop")
483
+ ]
484
+ search.each do |dir|
485
+ next unless Dir.exist?(dir)
486
+
487
+ exe = Dir.glob(File.join(dir, "*.exe")).sort.find { |path| File.file?(path) }
488
+ return [exe, url] if exe
489
+ end
490
+ else
491
+ search = [
492
+ File.join(base, "build", "linux", "x64", "release", "bundle"),
493
+ File.join(base, "desktop")
494
+ ]
495
+ search.each do |dir|
496
+ next unless Dir.exist?(dir)
497
+
498
+ candidate = Dir.children(dir).sort
499
+ .map { |entry| File.join(dir, entry) }
500
+ .find { |path| executable_file?(path) }
501
+ return [candidate, url] if candidate
502
+ end
342
503
  end
343
504
  end
344
505
 
@@ -352,10 +513,11 @@ module Ruflet
352
513
  root ||= ensure_prebuilt_client(web: true)
353
514
  return nil unless root && Dir.exist?(root)
354
515
 
355
- built = File.join(root, "build", "web")
356
- return built if Dir.exist?(built) && File.file?(File.join(built, "index.html"))
357
- prebuilt = File.join(root, "web")
358
- return prebuilt if Dir.exist?(prebuilt) && File.file?(File.join(prebuilt, "index.html"))
516
+ client_build_roots(root).each do |base|
517
+ [File.join(base, "build", "web"), File.join(base, "web")].each do |dir|
518
+ return dir if built_web_client_dir?(dir)
519
+ end
520
+ end
359
521
 
360
522
  nil
361
523
  end
@@ -440,23 +602,35 @@ module Ruflet
440
602
  nil
441
603
  end
442
604
 
605
+ # This decides both "the download installed correctly" and "the cache is
606
+ # still usable, skip the network". It has to agree with what `ruflet run`
607
+ # will actually accept later, or a half-extracted cache reports success
608
+ # here and then fails to launch -- and, because a present cache short
609
+ # circuits the download, never repairs itself without --force.
443
610
  def prebuilt_assets_present?(root, web:, desktop:, platform: nil)
444
- ok_web = !web || File.file?(File.join(root, "web", "index.html"))
611
+ ok_web = !web || built_web_client_dir?(File.join(root, "web"))
445
612
  ok_desktop = !desktop || prebuilt_desktop_present?(root, platform: platform)
446
613
  ok_web && ok_desktop
447
614
  end
448
615
 
616
+ # The prebuilt client is whatever `ruflet build` produced from Ruflet
617
+ # Explorer, so its bundle and binary carry that project's app name --
618
+ # "Ruflet Explorer.app", not the old "ruflet_client.app". Match on shape
619
+ # the way detect_desktop_client_command does rather than on a fixed name.
449
620
  def prebuilt_desktop_present?(root, platform: nil)
450
621
  platform ||= host_platform_name
451
622
  return false if platform.nil?
452
623
 
624
+ desktop = File.join(root, "desktop")
625
+ return false unless Dir.exist?(desktop)
626
+
453
627
  case platform
454
628
  when "macos"
455
- File.file?(File.join(root, "desktop", "ruflet_client.app", "Contents", "MacOS", "ruflet_client"))
629
+ Dir.glob(File.join(desktop, "*.app")).any? { |app_bundle| macos_app_executable(app_bundle) }
456
630
  when "linux"
457
- File.file?(File.join(root, "desktop", "ruflet_client"))
631
+ Dir.children(desktop).any? { |entry| executable_file?(File.join(desktop, entry)) }
458
632
  when "windows"
459
- File.file?(File.join(root, "desktop", "ruflet_client.exe"))
633
+ Dir.glob(File.join(desktop, "*.exe")).any? { |path| File.file?(path) }
460
634
  else
461
635
  false
462
636
  end
@@ -660,7 +834,7 @@ module Ruflet
660
834
  return if read_client_manifest(root)
661
835
 
662
836
  assets = []
663
- assets << { "kind" => "web", "platform" => platform, "asset_name" => nil } if File.file?(File.join(root, "web", "index.html"))
837
+ assets << { "kind" => "web", "platform" => platform, "asset_name" => nil } if built_web_client_dir?(File.join(root, "web"))
664
838
  if prebuilt_desktop_present?(root, platform: platform)
665
839
  assets << { "kind" => "desktop", "platform" => platform, "asset_name" => nil }
666
840
  end
@@ -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
@@ -108,12 +108,12 @@ module Ruflet
108
108
 
109
109
  installed = true
110
110
  targets.each do |target|
111
- present =
112
- if target == :web
113
- File.file?(File.join(root, "web", "index.html"))
114
- else
115
- prebuilt_desktop_present?(root, platform: platform)
116
- end
111
+ present = prebuilt_assets_present?(
112
+ root,
113
+ web: target == :web,
114
+ desktop: target == :desktop,
115
+ platform: platform
116
+ )
117
117
  installed &&= present
118
118
  puts "#{target}: #{present ? 'installed' : 'missing'}"
119
119
  end
data/lib/ruflet/cli.rb CHANGED
@@ -66,7 +66,7 @@ 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]