ruflet 0.0.21 → 0.0.22

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.
@@ -13,23 +13,25 @@ module Ruflet
13
13
  RUNTIME_REPO_URL = ENV.fetch("RUFLET_RUNTIME_REPO_URL", "https://github.com/AdamMusa/ruflet.git")
14
14
 
15
15
  CLIENT_EXTENSION_MAP = {
16
- "audio" => { package: "flet_audio", alias: "ruflet_audio" },
17
- "audio_recorder" => { package: "flet_audio_recorder", alias: "ruflet_audio_recorder" },
18
- "camera" => { package: "flet_camera", alias: "ruflet_camera" },
19
- "charts" => { package: "flet_charts", alias: "ruflet_charts" },
20
- "code_editor" => { package: "flet_code_editor", alias: "ruflet_code_editor" },
21
- "color_pickers" => { package: "flet_color_pickers", alias: "ruflet_color_picker" },
22
- "datatable2" => { package: "flet_datatable2", alias: "ruflet_datatable2" },
23
- "flashlight" => { package: "flet_flashlight", alias: "ruflet_flashlight" },
24
- "geolocator" => { package: "flet_geolocator", alias: "ruflet_geolocator" },
25
- "lottie" => { package: "flet_lottie", alias: "ruflet_lottie" },
26
- "map" => { package: "flet_map", alias: "ruflet_map" },
27
- "permission_handler" => { package: "flet_permission_handler", alias: "ruflet_permission_handler" },
16
+ "ads" => { package: "ruflet_ads", alias: "ruflet_ads" },
17
+ "audio" => { package: "ruflet_audio", alias: "ruflet_audio" },
18
+ "audio_recorder" => { package: "ruflet_audio_recorder", alias: "ruflet_audio_recorder" },
19
+ "camera" => { package: "ruflet_camera", alias: "ruflet_camera" },
20
+ "charts" => { package: "ruflet_charts", alias: "ruflet_charts" },
21
+ "code_editor" => { package: "ruflet_code_editor", alias: "ruflet_code_editor" },
22
+ "color_pickers" => { package: "ruflet_color_pickers", alias: "ruflet_color_picker" },
23
+ "datatable2" => { package: "ruflet_datatable2", alias: "ruflet_datatable2" },
24
+ "flashlight" => { package: "ruflet_flashlight", alias: "ruflet_flashlight" },
25
+ "geolocator" => { package: "ruflet_geolocator", alias: "ruflet_geolocator" },
26
+ "lottie" => { package: "ruflet_lottie", alias: "ruflet_lottie" },
27
+ "map" => { package: "ruflet_map", alias: "ruflet_map" },
28
+ "permission_handler" => { package: "ruflet_permission_handler", alias: "ruflet_permission_handler" },
28
29
  "qrcode_scanner" => { package: "ruflet_qrcode_scanner", alias: "ruflet_qrcode_scanner" },
29
- "rive" => { package: "flet_rive", alias: "ruflet_rive" },
30
- "secure_storage" => { package: "flet_secure_storage", alias: "ruflet_secure_storage" },
31
- "video" => { package: "flet_video", alias: "ruflet_video" },
32
- "webview" => { package: "flet_webview", alias: "ruflet_webview" }
30
+ "rive" => { package: "ruflet_rive", alias: "ruflet_rive" },
31
+ "secure_storage" => { package: "ruflet_secure_storage", alias: "ruflet_secure_storage" },
32
+ "spinkit" => { package: "ruflet_spinkit", alias: "ruflet_spinkit" },
33
+ "video" => { package: "ruflet_video", alias: "ruflet_video" },
34
+ "webview" => { package: "ruflet_webview", alias: "ruflet_webview" }
33
35
  }.freeze
34
36
 
35
37
  def command_new(args)
@@ -51,6 +53,7 @@ module Ruflet
51
53
  File.write(File.join(root, "README.md"), format(Ruflet::CLI::README_TEMPLATE, app_name: File.basename(root)))
52
54
  write_default_ruflet_config(root, File.basename(root))
53
55
  copy_default_project_assets(root)
56
+ copy_default_apple_extensions(root)
54
57
  project_name = File.basename(root)
55
58
  puts "Run:"
56
59
  puts " cd #{project_name}"
@@ -72,7 +75,16 @@ module Ruflet
72
75
  target = hidden_flutter_client_dir(root)
73
76
  FileUtils.mkdir_p(File.dirname(target))
74
77
  FileUtils.rm_rf(target)
75
- FileUtils.cp_r(template_root, target)
78
+ # `target` is the Flutter project root, not a directory that contains
79
+ # the template root. Copying a source directory onto a target that was
80
+ # already created by FileUtils can produce
81
+ # `build/client/ruflet_flutter_template/...`, leaving `build/client`
82
+ # without pubspec.yaml and making Flutter reject it as a project.
83
+ FileUtils.mkdir_p(target)
84
+ Dir.children(template_root).each do |entry|
85
+ copy_client_template_entry(
86
+ template_root, target, entry)
87
+ end
76
88
  prune_client_template(target)
77
89
  write_client_template_revision(target, installed_template_revision(template_root))
78
90
  end
@@ -261,6 +273,43 @@ module Ruflet
261
273
  end
262
274
  end
263
275
 
276
+ def copy_client_template_entry(template_root, target, relative)
277
+ return if generated_client_template_path?(relative)
278
+
279
+ source = File.join(template_root, relative)
280
+ destination = File.join(target, relative)
281
+ stat = File.lstat(source)
282
+ if stat.symlink?
283
+ FileUtils.mkdir_p(File.dirname(destination))
284
+ FileUtils.ln_s(File.readlink(source), destination)
285
+ elsif stat.directory?
286
+ FileUtils.mkdir_p(destination)
287
+ Dir.children(source).each do |entry|
288
+ copy_client_template_entry(
289
+ template_root, target, File.join(relative, entry))
290
+ end
291
+ else
292
+ FileUtils.mkdir_p(File.dirname(destination))
293
+ FileUtils.cp(source, destination, preserve: true)
294
+ end
295
+ end
296
+
297
+ def generated_client_template_path?(relative)
298
+ components = relative.split(File::SEPARATOR)
299
+ generated_components = %w[
300
+ .build .cache .claude .dart_tool .git .idea .swiftpm .symlinks
301
+ build DerivedData Pods xcuserdata
302
+ ]
303
+ return true if components.any? { |component| generated_components.include?(component) }
304
+ return true if components.any? { |component| component.start_with?(".build-") }
305
+
306
+ generated_files = %w[
307
+ .DS_Store Package.resolved Podfile.lock local.properties
308
+ pubspec_overrides.yaml
309
+ ]
310
+ generated_files.include?(components.last) || components.last.end_with?(".xcuserstate")
311
+ end
312
+
264
313
  def write_default_ruflet_config(root, app_name)
265
314
  File.write(File.join(root, "ruflet.yaml"), <<~YAML)
266
315
  app:
@@ -319,6 +368,21 @@ module Ruflet
319
368
  end
320
369
  end
321
370
 
371
+ def copy_default_apple_extensions(root)
372
+ template_root = resolve_ruflet_client_template_root || ensure_cached_ruflet_client_template!
373
+ source = template_root && File.join(template_root, "apple_extensions")
374
+ return unless source && File.file?(File.join(source, "Package.swift"))
375
+
376
+ destination = File.join(root, "apple_extensions")
377
+ FileUtils.mkdir_p(destination)
378
+ generated_entries = %w[.build .swiftpm .dart_tool .claude]
379
+ Dir.children(source).each do |entry|
380
+ next if generated_entries.include?(entry)
381
+
382
+ FileUtils.cp_r(File.join(source, entry), File.join(destination, entry), preserve: true)
383
+ end
384
+ end
385
+
322
386
  def default_project_assets_root
323
387
  [
324
388
  File.expand_path("../../../assets", __dir__),
@@ -11,23 +11,25 @@ require "net/http"
11
11
  require "uri"
12
12
  require "thread"
13
13
  require "io/console"
14
+ require "open3"
14
15
  require "time"
15
16
 
16
17
  module Ruflet
17
18
  module CLI
18
19
  module RunCommand
19
- CLIENT_CHANNEL_MANIFEST = "ruflet_client-manifest.json"
20
+ # Release assets were published as ruflet_client-* before the preview
21
+ # client became Ruflet Explorer. Ask for the current names, but keep
22
+ # recognising the old ones so an existing release still resolves.
23
+ ASSET_PREFIX = "ruflet_explorer"
24
+ LEGACY_ASSET_PREFIX = "ruflet_client"
25
+ CLIENT_CHANNEL_MANIFEST = "#{ASSET_PREFIX}-manifest.json"
26
+ LEGACY_CLIENT_CHANNEL_MANIFEST = "#{LEGACY_ASSET_PREFIX}-manifest.json"
27
+ EXPERIMENTAL_IOS_SIMULATOR_ASSET = "#{ASSET_PREFIX}-ios-experimental-simulator.zip"
28
+ EXPERIMENTAL_MACOS_ASSET = "#{ASSET_PREFIX}-macos-experimental-universal.zip"
20
29
  DEFAULT_CLIENT_UPDATE_INTERVAL = 6 * 60 * 60
21
30
 
22
31
  def command_run(args)
23
- options = { target: "mobile", requested_port: 8550, reload: true }
24
- parser = OptionParser.new do |o|
25
- o.on("--web") { options[:target] = "web" }
26
- o.on("--desktop") { options[:target] = "desktop" }
27
- o.on("--port PORT", Integer) { |v| options[:requested_port] = v }
28
- o.on("--no-reload") { options[:reload] = false }
29
- end
30
- parser.parse!(args)
32
+ options = parse_run_options(args)
31
33
 
32
34
  script_token = args.shift || "main"
33
35
  script_path = resolve_script(script_token)
@@ -37,6 +39,9 @@ module Ruflet
37
39
  return 1
38
40
  end
39
41
 
42
+ experimental_client = prepare_experimental_run_client(options)
43
+ return 1 if options[:experimental] && !experimental_client
44
+
40
45
  selected_port = resolve_backend_port(options[:target], requested_port: options[:requested_port])
41
46
  return 1 unless selected_port
42
47
  env = {
@@ -70,7 +75,10 @@ module Ruflet
70
75
 
71
76
  run_state = { child_pid: Process.spawn(env, *cmd, pgroup: true), restart: false }
72
77
  reload_input_thread = options[:reload] ? start_reload_input_thread(run_state) : nil
73
- launched_client_pids = launch_target_client(options[:target], selected_port)
78
+ launched_client_pids = launch_target_client(
79
+ options[:target], selected_port,
80
+ experimental_client: experimental_client
81
+ )
74
82
  forward_signal = lambda do |signal|
75
83
  begin
76
84
  Process.kill(signal, -run_state[:child_pid])
@@ -87,7 +95,7 @@ module Ruflet
87
95
  return status.success? ? 0 : (status.exitstatus || 1) unless run_state[:restart]
88
96
 
89
97
  # Full restart requested ("R"): respawn the backend; connected
90
- # clients reconnect and re-register on their own (Flet-style).
98
+ # clients reconnect and re-register on their own (Ruflet-style).
91
99
  run_state[:restart] = false
92
100
  puts "Restarting app..."
93
101
  started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
@@ -125,6 +133,72 @@ module Ruflet
125
133
 
126
134
  private
127
135
 
136
+ def parse_run_options(args)
137
+ options = { target: "mobile", requested_port: 8550, reload: true, experimental: false }
138
+ parser = OptionParser.new do |o|
139
+ o.on("--web") { options[:target] = "web" }
140
+ o.on("--desktop") { options[:target] = "desktop" }
141
+ o.on("--port PORT", Integer) { |v| options[:requested_port] = v }
142
+ o.on("--no-reload") { options[:reload] = false }
143
+ o.on("--experimental", "--exp") { options[:experimental] = true }
144
+ end
145
+ parser.parse!(args)
146
+ options
147
+ end
148
+
149
+ def prepare_experimental_run_client(options)
150
+ return nil unless options[:experimental]
151
+
152
+ if options[:target] == "web"
153
+ warn "run config error: --experimental/--exp is supported only for iOS and macOS"
154
+ return nil
155
+ end
156
+ unless host_platform_name == "macos"
157
+ warn "run config error: the experimental Apple client requires macOS"
158
+ return nil
159
+ end
160
+
161
+ if options[:target] == "desktop"
162
+ root = ensure_prebuilt_client(desktop_experimental: true, platform: "macos")
163
+ unless root
164
+ warn "Experimental macOS client is unavailable."
165
+ warn "The release must contain #{EXPERIMENTAL_MACOS_ASSET}."
166
+ return nil
167
+ end
168
+
169
+ return { kind: :desktop, root: root }
170
+ end
171
+
172
+ root = ensure_prebuilt_client(ios_experimental: true, platform: "macos")
173
+ unless root
174
+ warn "Experimental iOS Simulator client is unavailable."
175
+ warn "The release must contain #{EXPERIMENTAL_IOS_SIMULATOR_ASSET}."
176
+ return nil
177
+ end
178
+
179
+ app_bundle = experimental_ios_app_bundle(root)
180
+ simulator = booted_ios_simulator
181
+ unless simulator
182
+ warn "No booted iOS Simulator was found."
183
+ warn "Start the simulator you want to use, then run the command again."
184
+ return nil
185
+ end
186
+
187
+ bundle_identifier = ios_bundle_identifier(app_bundle)
188
+ if bundle_identifier.to_s.empty?
189
+ warn "Experimental client has no CFBundleIdentifier: #{app_bundle}"
190
+ return nil
191
+ end
192
+
193
+ {
194
+ kind: :ios_simulator,
195
+ app_bundle: app_bundle,
196
+ bundle_identifier: bundle_identifier,
197
+ simulator_udid: simulator.fetch("udid"),
198
+ simulator_name: simulator.fetch("name", simulator.fetch("udid"))
199
+ }
200
+ end
201
+
128
202
  def build_runtime_command(script_path, gemfile_path:, env:, reload: false)
129
203
  entry_script = script_path
130
204
  if reload
@@ -287,9 +361,20 @@ module Ruflet
287
361
  end
288
362
  end
289
363
 
290
- def launch_target_client(target, port)
364
+ def launch_target_client(target, port, experimental_client: nil)
291
365
  wait_for_server_boot(port)
292
366
 
367
+ if experimental_client
368
+ if experimental_client[:kind] == :desktop
369
+ return launch_desktop_client(
370
+ "http://localhost:#{port}",
371
+ root: experimental_client.fetch(:root), experimental: true)
372
+ end
373
+
374
+ return launch_experimental_mobile_client(
375
+ "http://#{best_lan_host}:#{port}", client: experimental_client)
376
+ end
377
+
293
378
  case target
294
379
  when "web"
295
380
  launch_web_client(port)
@@ -300,6 +385,33 @@ module Ruflet
300
385
  end
301
386
  end
302
387
 
388
+ def launch_experimental_mobile_client(url, client:)
389
+ udid = client.fetch(:simulator_udid)
390
+ app_bundle = client.fetch(:app_bundle)
391
+ bundle_identifier = client.fetch(:bundle_identifier)
392
+
393
+ unless system("xcrun", "simctl", "install", udid, app_bundle)
394
+ warn "Failed to install the experimental client on #{client[:simulator_name] || udid}."
395
+ return []
396
+ end
397
+
398
+ launched = system(
399
+ { "SIMCTL_CHILD_RUFLET_URL" => url },
400
+ "xcrun", "simctl", "launch", "--terminate-running-process", udid, bundle_identifier
401
+ )
402
+ unless launched
403
+ warn "Failed to launch the experimental client on #{client[:simulator_name] || udid}."
404
+ return []
405
+ end
406
+
407
+ puts "Ruflet experimental client: #{client[:simulator_name] || udid}"
408
+ puts "Ruflet experimental backend: #{url}"
409
+ []
410
+ rescue StandardError => e
411
+ warn "Failed to launch experimental client: #{e.class}: #{e.message}"
412
+ []
413
+ end
414
+
303
415
  # The backend serves the web client on its own port, so the client reads
304
416
  # the origin it was loaded from and opens its websocket there. No URL is
305
417
  # passed in the query string: nothing in the client parses one.
@@ -399,8 +511,8 @@ module Ruflet
399
511
  nil
400
512
  end
401
513
 
402
- def launch_desktop_client(url)
403
- cmd = detect_desktop_client_command(url)
514
+ def launch_desktop_client(url, root: nil, experimental: false)
515
+ cmd = detect_desktop_client_command(url, root: root, experimental: experimental)
404
516
  unless cmd
405
517
  warn "Desktop client executable not found."
406
518
  warn "Set RUFLET_CLIENT_DIR to your client path."
@@ -456,18 +568,20 @@ module Ruflet
456
568
  .find { |path| executable_file?(path) }
457
569
  end
458
570
 
459
- def detect_desktop_client_command(url)
460
- root = ENV["RUFLET_CLIENT_DIR"]
461
- root = File.expand_path("ruflet_client", Dir.pwd) if root.to_s.strip.empty?
462
- root = nil unless Dir.exist?(root)
463
- root ||= ensure_prebuilt_client(desktop: true)
571
+ def detect_desktop_client_command(url, root: nil, experimental: false)
572
+ unless root
573
+ root = ENV["RUFLET_CLIENT_DIR"]
574
+ root = File.expand_path("ruflet_client", Dir.pwd) if root.to_s.strip.empty?
575
+ root = nil unless Dir.exist?(root)
576
+ root ||= ensure_prebuilt_client(desktop: true)
577
+ end
464
578
  return nil unless root && Dir.exist?(root)
465
579
 
466
580
  host_os = RbConfig::CONFIG["host_os"]
467
581
  client_build_roots(root).each do |base|
468
582
  if host_os.match?(/darwin/i)
469
583
  search = %w[Release Debug].map { |config| File.join(base, "build", "macos", "Build", "Products", config) }
470
- search << File.join(base, "desktop")
584
+ search << File.join(base, experimental ? "desktop-experimental" : "desktop")
471
585
  search.each do |dir|
472
586
  next unless Dir.exist?(dir)
473
587
 
@@ -522,7 +636,10 @@ module Ruflet
522
636
  nil
523
637
  end
524
638
 
525
- def ensure_prebuilt_client(web: false, desktop: false, platform: nil, force: false)
639
+ def ensure_prebuilt_client(
640
+ web: false, desktop: false, desktop_experimental: false,
641
+ ios_experimental: false, platform: nil, force: false
642
+ )
526
643
  platform ||= host_platform_name
527
644
  return nil if platform.nil?
528
645
 
@@ -530,13 +647,38 @@ module Ruflet
530
647
  FileUtils.mkdir_p(cache_root)
531
648
 
532
649
  wanted_assets = []
533
- wanted_assets << { kind: :web, name: "ruflet_client-web.tar.gz" } if web
650
+ wanted_assets << { kind: :web, name: "#{ASSET_PREFIX}-web.tar.gz" } if web
534
651
  if desktop
535
652
  desktop_asset = desktop_asset_name_for(platform)
536
653
  return nil if desktop_asset.nil?
537
654
  wanted_assets << { kind: :desktop, name: desktop_asset, platform: platform }
538
655
  end
539
- cache_ready = wanted_assets.empty? || prebuilt_assets_present?(cache_root, web: web, desktop: desktop, platform: platform)
656
+ if desktop_experimental
657
+ return nil unless platform == "macos"
658
+
659
+ wanted_assets << {
660
+ kind: :desktop_experimental,
661
+ name: EXPERIMENTAL_MACOS_ASSET,
662
+ platform: platform
663
+ }
664
+ end
665
+ if ios_experimental
666
+ return nil unless platform == "macos"
667
+
668
+ wanted_assets << {
669
+ kind: :ios_experimental,
670
+ name: EXPERIMENTAL_IOS_SIMULATOR_ASSET,
671
+ platform: platform
672
+ }
673
+ end
674
+ cache_ready = wanted_assets.empty? || prebuilt_assets_present?(
675
+ cache_root,
676
+ web: web,
677
+ desktop: desktop,
678
+ desktop_experimental: desktop_experimental,
679
+ ios_experimental: ios_experimental,
680
+ platform: platform
681
+ )
540
682
  release = nil
541
683
  if !force && cache_ready
542
684
  ensure_client_manifest(cache_root, platform: platform)
@@ -573,7 +715,12 @@ module Ruflet
573
715
  puts "Downloading prebuilt client asset: #{resolved_name}"
574
716
  archive_path = File.join(tmpdir, resolved_name)
575
717
  download_file(asset.fetch("browser_download_url"), archive_path)
576
- subdir = wanted[:kind] == :web ? "web" : "desktop"
718
+ subdir = case wanted[:kind]
719
+ when :web then "web"
720
+ when :desktop then "desktop"
721
+ when :desktop_experimental then "desktop-experimental"
722
+ when :ios_experimental then "ios-experimental"
723
+ end
577
724
  target = File.join(cache_root, subdir)
578
725
  FileUtils.rm_rf(target) if force && Dir.exist?(target)
579
726
  FileUtils.mkdir_p(target)
@@ -591,7 +738,14 @@ module Ruflet
591
738
  end
592
739
  end
593
740
 
594
- if prebuilt_assets_present?(cache_root, web: web, desktop: desktop, platform: platform)
741
+ if prebuilt_assets_present?(
742
+ cache_root,
743
+ web: web,
744
+ desktop: desktop,
745
+ desktop_experimental: desktop_experimental,
746
+ ios_experimental: ios_experimental,
747
+ platform: platform
748
+ )
595
749
  write_client_manifest(cache_root, platform: platform, release: release, assets: installed_assets)
596
750
  return cache_root
597
751
  end
@@ -607,10 +761,61 @@ module Ruflet
607
761
  # will actually accept later, or a half-extracted cache reports success
608
762
  # here and then fails to launch -- and, because a present cache short
609
763
  # circuits the download, never repairs itself without --force.
610
- def prebuilt_assets_present?(root, web:, desktop:, platform: nil)
764
+ def prebuilt_assets_present?(
765
+ root, web:, desktop:, desktop_experimental: false,
766
+ ios_experimental: false, platform: nil
767
+ )
611
768
  ok_web = !web || built_web_client_dir?(File.join(root, "web"))
612
769
  ok_desktop = !desktop || prebuilt_desktop_present?(root, platform: platform)
613
- ok_web && ok_desktop
770
+ ok_desktop_experimental = !desktop_experimental ||
771
+ prebuilt_experimental_desktop_present?(root, platform: platform)
772
+ ok_ios_experimental = !ios_experimental || !experimental_ios_app_bundle(root).nil?
773
+ ok_web && ok_desktop && ok_desktop_experimental && ok_ios_experimental
774
+ end
775
+
776
+ def experimental_ios_app_bundle(root)
777
+ ios_root = File.join(root, "ios-experimental")
778
+ return nil unless Dir.exist?(ios_root)
779
+
780
+ Dir.glob(File.join(ios_root, "*.app")).sort.find do |app_bundle|
781
+ File.file?(File.join(app_bundle, "Info.plist")) && ios_app_executable(app_bundle)
782
+ end
783
+ end
784
+
785
+ def ios_app_executable(app_bundle)
786
+ return nil unless Dir.exist?(app_bundle)
787
+
788
+ Dir.children(app_bundle)
789
+ .map { |entry| File.join(app_bundle, entry) }
790
+ .find { |path| executable_file?(path) }
791
+ end
792
+
793
+ def booted_ios_simulator
794
+ output, status = Open3.capture2e("xcrun", "simctl", "list", "devices", "booted", "--json")
795
+ return nil unless status.success?
796
+
797
+ devices = JSON.parse(output).fetch("devices", {})
798
+ devices.each do |runtime, candidates|
799
+ next unless runtime.to_s.include?("SimRuntime.iOS")
800
+
801
+ match = Array(candidates).find do |device|
802
+ device["state"] == "Booted" && device.fetch("isAvailable", true)
803
+ end
804
+ return match if match
805
+ end
806
+ nil
807
+ rescue StandardError
808
+ nil
809
+ end
810
+
811
+ def ios_bundle_identifier(app_bundle)
812
+ plist = File.join(app_bundle, "Info.plist")
813
+ output, status = Open3.capture2e(
814
+ "/usr/bin/plutil", "-extract", "CFBundleIdentifier", "raw", "-o", "-", plist
815
+ )
816
+ status.success? ? output.strip : nil
817
+ rescue StandardError
818
+ nil
614
819
  end
615
820
 
616
821
  # The prebuilt client is whatever `ruflet build` produced from Ruflet
@@ -636,6 +841,17 @@ module Ruflet
636
841
  end
637
842
  end
638
843
 
844
+ def prebuilt_experimental_desktop_present?(root, platform: nil)
845
+ return false unless (platform || host_platform_name) == "macos"
846
+
847
+ desktop = File.join(root, "desktop-experimental")
848
+ return false unless Dir.exist?(desktop)
849
+
850
+ Dir.glob(File.join(desktop, "*.app")).any? do |app_bundle|
851
+ macos_app_executable(app_bundle)
852
+ end
853
+ end
854
+
639
855
  def host_platform_name
640
856
  host_os = RbConfig::CONFIG["host_os"]
641
857
  return "macos" if host_os.match?(/darwin/i)
@@ -647,9 +863,9 @@ module Ruflet
647
863
 
648
864
  def desktop_asset_name_for(platform)
649
865
  case platform
650
- when "macos" then "ruflet_client-macos-universal.zip"
651
- when "linux" then "ruflet_client-linux-x64.tar.gz"
652
- when "windows" then "ruflet_client-windows-x64.zip"
866
+ when "macos" then "#{ASSET_PREFIX}-macos-universal.zip"
867
+ when "linux" then "#{ASSET_PREFIX}-linux-x64.tar.gz"
868
+ when "windows" then "#{ASSET_PREFIX}-windows-x64.zip"
653
869
  end
654
870
  end
655
871
 
@@ -659,7 +875,7 @@ module Ruflet
659
875
 
660
876
  def fetch_release_for_version(wanted_assets: [])
661
877
  releases = []
662
- channel = client_release_channel
878
+ channel = client_release_channel(wanted_assets: wanted_assets)
663
879
  if channel != "stable"
664
880
  rolling = release_by_tag(channel)
665
881
  releases << rolling if rolling && rolling_release_complete?(rolling)
@@ -670,13 +886,24 @@ module Ruflet
670
886
  releases.compact.find { |release| release_has_wanted_assets?(release, wanted_assets) }
671
887
  end
672
888
 
673
- def client_release_channel
674
- value = ENV.fetch("RUFLET_CLIENT_CHANNEL", "prebuild-main").to_s.strip
675
- value.empty? ? "prebuild-main" : value
889
+ def client_release_channel(wanted_assets: [])
890
+ default_channel = wanted_assets.any? do |wanted|
891
+ %i[desktop_experimental ios_experimental].include?(wanted[:kind])
892
+ end ? "prebuild-experimental" : "prebuild-main"
893
+ value = ENV.fetch("RUFLET_CLIENT_CHANNEL", default_channel).to_s.strip
894
+ value.empty? ? default_channel : value
676
895
  end
677
896
 
678
897
  def rolling_release_complete?(release)
679
- release.fetch("assets", []).any? { |asset| asset["name"] == CLIENT_CHANNEL_MANIFEST }
898
+ !channel_manifest_asset(release).nil?
899
+ end
900
+
901
+ # The manifest is written last, so its presence means the run finished
902
+ # uploading every platform. Accept either naming for releases published
903
+ # before the assets were renamed.
904
+ def channel_manifest_asset(release)
905
+ names = [CLIENT_CHANNEL_MANIFEST, LEGACY_CLIENT_CHANNEL_MANIFEST]
906
+ release.fetch("assets", []).find { |asset| names.include?(asset["name"]) }
680
907
  end
681
908
 
682
909
  def release_has_wanted_assets?(release, wanted_assets)
@@ -687,8 +914,7 @@ module Ruflet
687
914
  end
688
915
 
689
916
  def client_release_revision(release)
690
- marker = release.fetch("assets", []).find { |asset| asset["name"] == CLIENT_CHANNEL_MANIFEST }
691
- source = marker || release
917
+ source = channel_manifest_asset(release) || release
692
918
  [source["id"], source["updated_at"] || source["published_at"], source["size"]].compact.join(":")
693
919
  end
694
920
 
@@ -750,15 +976,23 @@ module Ruflet
750
976
 
751
977
  def release_asset_matches?(name, kind, platform)
752
978
  n = name.to_s.downcase
753
- return false unless n.include?("ruflet_client")
979
+ return false unless n.include?(ASSET_PREFIX) || n.include?(LEGACY_ASSET_PREFIX)
754
980
 
755
981
  if kind == :web
756
982
  return n.include?("web") && (n.end_with?(".tar.gz") || n.end_with?(".zip"))
757
983
  end
758
984
 
985
+ if kind == :ios_experimental
986
+ return n.include?("ios") && n.include?("experimental") && n.include?("simulator") && n.end_with?(".zip")
987
+ end
988
+
989
+ if kind == :desktop_experimental
990
+ return n.include?("macos") && n.include?("experimental") && n.end_with?(".zip")
991
+ end
992
+
759
993
  case platform
760
994
  when "macos"
761
- n.include?("macos") && n.end_with?(".zip")
995
+ n.include?("macos") && !n.include?("experimental") && n.end_with?(".zip")
762
996
  when "linux"
763
997
  n.include?("linux") && (n.end_with?(".tar.gz") || n.end_with?(".tgz"))
764
998
  when "windows"
@@ -838,6 +1072,12 @@ module Ruflet
838
1072
  if prebuilt_desktop_present?(root, platform: platform)
839
1073
  assets << { "kind" => "desktop", "platform" => platform, "asset_name" => nil }
840
1074
  end
1075
+ if prebuilt_experimental_desktop_present?(root, platform: platform)
1076
+ assets << { "kind" => "desktop_experimental", "platform" => platform, "asset_name" => nil }
1077
+ end
1078
+ if experimental_ios_app_bundle(root)
1079
+ assets << { "kind" => "ios_experimental", "platform" => platform, "asset_name" => nil }
1080
+ end
841
1081
  return if assets.empty?
842
1082
 
843
1083
  write_client_manifest(root, platform: platform, release: nil, assets: assets)
data/lib/ruflet/cli.rb CHANGED
@@ -66,14 +66,19 @@ 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] [--no-reload]
69
+ ruflet run [scriptname|path] [--web|--desktop] [--experimental|--exp] [--port PORT] [--no-reload]
70
70
  ruflet update [web|desktop|all] [--check] [--force] [--platform PLATFORM]
71
71
  ruflet debug [scriptname|path]
72
- ruflet build <apk|android|ios|aab|web|macos|windows|linux> [--self] [--verbose]
72
+ ruflet build <apk|android|ios|ipa|aab|web|macos|windows|linux> [--lite|--full|--self] [--experimental|--exp] [--verbose]
73
73
  ruflet install [--device DEVICE_ID] [--verbose]
74
74
  ruflet devices
75
75
  ruflet emulators
76
76
  ruflet doctor [--fix] [--verbose]
77
+
78
+ Build profiles:
79
+ --self Self-contained lite build (default profile)
80
+ --self --full Self-contained full CRuby build with locked project gems
81
+ --full Also implies --self
77
82
  HELP
78
83
  end
79
84