ruflet 0.0.15 → 0.0.16

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 629de90fa5d63db66ce7f96b69fcd83da284621d1bf8f3c179d2ea7b54f1591b
4
- data.tar.gz: 4f91dfe6d31fe054b683f9838120fb6476501ac959e981eff19ef1809c880aa9
3
+ metadata.gz: 653ba520f85466110b49be97b22029f8f1e30629edefcd3057def6ecbf20be2c
4
+ data.tar.gz: 66107e193cfd961763921dafcec2d67f21216f34daad33a22eeffbef279b1b0e
5
5
  SHA512:
6
- metadata.gz: 23a877adf84ca8bc119cb5452a480dd666e05f36fd6f3a1a03f75f51fa5b2ff028f07b5373b8c043a1acd43b49dc68f7885992bcb3596e279be07fed5751cb07
7
- data.tar.gz: c66e78cc467a27803bbb52264444289f4a9126763c4aed1c53df6b945e1c08f0478fff277008a40c742750dde027a67c84a2209ff4d255b6b48483ffa6a21549
6
+ metadata.gz: 73608863d7814a25d421f4a1b26bf07a231673b50679e3815eeb7e22df39c0da3c92bbdc696c207a36af38b427413d1d2ed6d61ba36d2720e8a0f444bb3ac3a7
7
+ data.tar.gz: 9a25b923371b68c7a8f51dbab32a30a71c0034033760c89cdf1773ffb9295707728107072f88523c9a5f7db4b43c5ded66d74096d52fa0547cd35103c99066b1
data/README.md CHANGED
@@ -1,3 +1,42 @@
1
1
  # ruflet
2
2
 
3
- Part of Ruflet monorepo.
3
+ `ruflet` is the command-line package for creating, running, diagnosing, and
4
+ building Ruflet applications.
5
+
6
+ ## Install
7
+
8
+ ```bash
9
+ gem install ruflet
10
+ ```
11
+
12
+ Create a project and run it:
13
+
14
+ ```bash
15
+ ruflet new my_app
16
+ cd my_app
17
+ bundle install
18
+ bundle exec ruflet run --web
19
+ ```
20
+
21
+ Generated projects include `ruflet_core` and `ruflet_server` as application
22
+ dependencies. Use `bundle exec ruflet` inside a project so commands run with
23
+ the versions declared in its `Gemfile`.
24
+
25
+ ## Commands
26
+
27
+ ```bash
28
+ ruflet new <appname>
29
+ ruflet run [scriptname|path] [--web|--desktop] [--port PORT]
30
+ ruflet debug [scriptname|path]
31
+ ruflet devices
32
+ ruflet emulators
33
+ ruflet doctor
34
+ ruflet update [web|desktop|all] [--check] [--force]
35
+ ruflet build <apk|android|ios|aab|web|macos|windows|linux> [--self]
36
+ ruflet install [--device DEVICE_ID]
37
+ ```
38
+
39
+ Run `ruflet install` without `--device` to choose from a numbered list of
40
+ connected devices. Pass `--device DEVICE_ID` to skip the prompt.
41
+
42
+ Run `ruflet help <command>` for all options.
@@ -246,8 +246,9 @@ module Ruflet
246
246
 
247
247
  puts " Installing Android packages: #{missing.join(', ')}"
248
248
  env = sdkmanager_env(java)
249
- output = verbose ? $stdout : File::NULL
250
- system(env, sdkmanager, "--sdk_root=#{sdk_root}", *missing, out: output, err: $stderr)
249
+ # sdkmanager downloads sizeable platform/build-tool archives; stream so
250
+ # the install reports progress instead of appearing to hang.
251
+ system(env, sdkmanager, "--sdk_root=#{sdk_root}", *missing, out: $stdout, err: $stderr)
251
252
  end
252
253
 
253
254
  def android_package_installed?(sdk_root, package)
@@ -3,6 +3,7 @@
3
3
  require "fileutils"
4
4
  require "find"
5
5
  require "json"
6
+ require "open3"
6
7
  require "pathname"
7
8
  require "rbconfig"
8
9
  require "uri"
@@ -28,7 +29,9 @@ module Ruflet
28
29
  "lottie" => { package: "flet_lottie", alias: "ruflet_lottie" },
29
30
  "map" => { package: "flet_map", alias: "ruflet_map" },
30
31
  "permission_handler" => { package: "flet_permission_handler", alias: "ruflet_permission_handler" },
32
+ "rive" => { package: "flet_rive", alias: "ruflet_rive" },
31
33
  "secure_storage" => { package: "flet_secure_storage", alias: "ruflet_secure_storage" },
34
+ "spinkit" => { package: "flet_spinkit", alias: "ruflet_spinkit" },
32
35
  "video" => { package: "flet_video", alias: "ruflet_video" },
33
36
  "webview" => { package: "flet_webview", alias: "ruflet_webview" }
34
37
  }.freeze
@@ -169,6 +172,10 @@ module Ruflet
169
172
  backend_url = configured_backend_url(config)
170
173
  if self_contained
171
174
  build_args += ["--dart-define", "RUFLET_BACKEND_URL=#{backend_url}"] if backend_url
175
+ # Pin the embedded entry project so the runtime doesn't have to guess.
176
+ # Apps that bundle nested projects (e.g. ruflet_studio's standalone_apps,
177
+ # each with their own main.rb) otherwise trip auto-discovery.
178
+ build_args += ["--dart-define", "RUFLET_EMBEDDED_PROJECT=#{self_contained_project_name}"]
172
179
  else
173
180
  unless backend_url
174
181
  warn "build config error: backend_url is required for server-driven builds"
@@ -204,8 +211,16 @@ module Ruflet
204
211
  end
205
212
 
206
213
  tools = ensure_flutter!("install", client_dir: client_dir)
207
- command_env = install_tool_env(tools[:env], client_dir)
214
+ discovery_env = unbundled_command_env(tools[:env])
215
+ device_id ||= select_install_device(
216
+ flutter: tools[:flutter],
217
+ env: discovery_env,
218
+ client_dir: client_dir
219
+ )
220
+ return 1 unless device_id
221
+
208
222
  install_platform = install_platform_for_device(device_id)
223
+ command_env = install_tool_env(tools[:env], client_dir, platform: install_platform)
209
224
  unless sync_built_outputs_for_install(client_dir, platform: install_platform, verbose: !!verbose)
210
225
  warn "Could not find built app outputs under ./build"
211
226
  warn "Run `ruflet build ...` first, then `ruflet install`."
@@ -243,6 +258,59 @@ module Ruflet
243
258
  nil
244
259
  end
245
260
 
261
+ def select_install_device(flutter:, env:, client_dir:, input: $stdin, output: $stdout)
262
+ devices = discover_install_devices(flutter: flutter, env: env, client_dir: client_dir)
263
+ if devices.empty?
264
+ warn "No supported devices are connected."
265
+ warn "Run `ruflet devices` to check device availability."
266
+ return nil
267
+ end
268
+
269
+ output.puts "Available devices:"
270
+ devices.each_with_index do |device, index|
271
+ details = [device["targetPlatform"], device["emulator"] ? "emulator" : "physical"].compact
272
+ output.puts " #{index + 1}) #{device.fetch("name", device["id"])} (#{details.join(", ")}) [#{device["id"]}]"
273
+ end
274
+
275
+ loop do
276
+ output.print "Choose a device [1-#{devices.length}]: "
277
+ output.flush
278
+ choice = input.gets
279
+ unless choice
280
+ warn "Device selection cancelled. Use `--device DEVICE_ID` for noninteractive installs."
281
+ return nil
282
+ end
283
+
284
+ index = Integer(choice.strip, exception: false)
285
+ return devices[index - 1]["id"] if index && index.between?(1, devices.length)
286
+
287
+ output.puts "Enter a number from 1 to #{devices.length}."
288
+ end
289
+ end
290
+
291
+ def discover_install_devices(flutter:, env:, client_dir:)
292
+ stdout, stderr, status = Open3.capture3(
293
+ env,
294
+ flutter,
295
+ "devices",
296
+ "--machine",
297
+ chdir: client_dir
298
+ )
299
+ unless status.success?
300
+ warn "Could not list Flutter devices."
301
+ warn stderr.strip unless stderr.to_s.strip.empty?
302
+ return []
303
+ end
304
+
305
+ devices = JSON.parse(stdout)
306
+ Array(devices).select do |device|
307
+ device.is_a?(Hash) && !device["id"].to_s.empty? && device["isSupported"] != false
308
+ end
309
+ rescue JSON::ParserError => e
310
+ warn "Could not parse Flutter device list: #{e.message}"
311
+ []
312
+ end
313
+
246
314
  def ensure_flutter_client_dir(verbose: false)
247
315
  client_dir = detect_flutter_client_dir
248
316
  return client_dir if client_dir
@@ -264,8 +332,9 @@ module Ruflet
264
332
  apple_env
265
333
  end
266
334
 
267
- def install_tool_env(env, client_dir)
268
- return build_tool_env(env, inferred_install_platform, client_dir) if inferred_install_platform
335
+ def install_tool_env(env, client_dir, platform: nil)
336
+ platform ||= inferred_install_platform
337
+ return build_tool_env(env, platform, client_dir) if platform
269
338
 
270
339
  command_env = unbundled_command_env(env)
271
340
  command_env["PATH"] = apple_build_path(command_env["PATH"])
@@ -998,6 +1067,23 @@ module Ruflet
998
1067
  cmake_path = File.join(client_dir, "linux", "CMakeLists.txt")
999
1068
  replace_in_file(cmake_path, /^set\(BINARY_NAME ".*"\)$/, %(set(BINARY_NAME "#{metadata[:package_name]}")))
1000
1069
  replace_in_file(cmake_path, /^set\(APPLICATION_ID ".*"\)$/, %(set(APPLICATION_ID "#{metadata[:linux_application_id]}")))
1070
+
1071
+ # The GTK runner hardcodes the window title (header-bar and fallback
1072
+ # paths). Without this it shows the package name (e.g. "ruflet_client")
1073
+ # instead of the configured app name. Match the call, not the literal,
1074
+ # so re-runs stay idempotent.
1075
+ my_application_path = File.join(client_dir, "linux", "runner", "my_application.cc")
1076
+ title = c_string_escape(metadata[:display_name])
1077
+ replace_in_file(
1078
+ my_application_path,
1079
+ /gtk_header_bar_set_title\(header_bar, "[^"]*"\)/,
1080
+ %(gtk_header_bar_set_title(header_bar, "#{title}"))
1081
+ )
1082
+ replace_in_file(
1083
+ my_application_path,
1084
+ /gtk_window_set_title\(window, "[^"]*"\)/,
1085
+ %(gtk_window_set_title(window, "#{title}"))
1086
+ )
1001
1087
  end
1002
1088
 
1003
1089
  def apply_dart_metadata(client_dir, metadata)
@@ -1090,6 +1176,13 @@ module Ruflet
1090
1176
  value.to_s.gsub('"', '""')
1091
1177
  end
1092
1178
 
1179
+ # Escape a value for embedding inside a C string literal (used for the
1180
+ # GTK runner window title in my_application.cc). Block form avoids
1181
+ # backslash interpretation in the gsub replacement string.
1182
+ def c_string_escape(value)
1183
+ value.to_s.gsub(/[\\"]/) { |ch| "\\#{ch}" }
1184
+ end
1185
+
1093
1186
  def dart_single_quote_escape(value)
1094
1187
  value.to_s.gsub("\\", "\\\\\\").gsub("'", "\\\\'")
1095
1188
  end
@@ -1107,6 +1200,7 @@ module Ruflet
1107
1200
  extension_packages = extension_keys.filter_map { |key| CLIENT_EXTENSION_MAP[key]&.fetch(:package) }.uniq
1108
1201
  extension_aliases = extension_keys.filter_map { |key| CLIENT_EXTENSION_MAP[key]&.fetch(:alias) }.uniq
1109
1202
 
1203
+ sync_client_flet_packages(client_dir, extension_packages)
1110
1204
  pubspec_path = File.join(client_dir, "pubspec.yaml")
1111
1205
  if File.file?(pubspec_path)
1112
1206
  sync_client_extension_dependencies(pubspec_path, extension_packages)
@@ -1293,8 +1387,23 @@ module Ruflet
1293
1387
  return if content.include?("<key>#{key}</key>")
1294
1388
 
1295
1389
  entry = "\t<key>#{key}</key>\n\t#{value_xml}\n"
1296
- updated = content.sub(%r{</dict>}, "#{entry}</dict>")
1297
- File.write(path, updated == content ? "#{content}\n#{entry}" : updated)
1390
+
1391
+ # Insert into the ROOT <dict> (opened right after <plist ...>), never the
1392
+ # first nested </dict>. Modern Flutter Info.plists nest a dict inside
1393
+ # UIApplicationSceneManifest, so subbing the first </dict> would bury
1394
+ # top-level keys (e.g. NS*UsageDescription) where iOS can't read them —
1395
+ # which crashes the app the moment a permission is requested.
1396
+ root_open = content.match(%r{<plist\b[^>]*>\s*<dict>}m)
1397
+ updated =
1398
+ if root_open
1399
+ insert_at = root_open.end(0)
1400
+ content[0...insert_at] + "\n#{entry}" + content[insert_at..]
1401
+ elsif (last = content.rindex("</dict>"))
1402
+ content[0...last] + entry + content[last..]
1403
+ else
1404
+ "#{content}\n#{entry}"
1405
+ end
1406
+ File.write(path, updated)
1298
1407
  end
1299
1408
 
1300
1409
  def remove_plist_entry(path, key)
@@ -1358,18 +1467,20 @@ module Ruflet
1358
1467
  flutter = data["flutter"]
1359
1468
  flutter = data["flutter"] = {} unless flutter.is_a?(Hash)
1360
1469
  assets = Array(flutter["assets"]).map(&:to_s)
1470
+ project_asset_prefix = "assets/#{self_contained_project_name}/"
1471
+ assets.reject! { |asset| asset.start_with?(project_asset_prefix) }
1361
1472
 
1362
1473
  if self_contained
1363
1474
  dependencies["ruby_runtime"] = ruby_runtime_dependency(dependencies["ruby_runtime"])
1364
1475
  assets.delete("assets/main.rb")
1365
1476
  assets.delete("assets/ruby_project/")
1366
- project_asset_path = "assets/#{self_contained_project_name}/"
1367
- assets << project_asset_path unless assets.include?(project_asset_path)
1477
+ project_asset_relative_paths.each do |relative_path|
1478
+ assets << "#{project_asset_prefix}#{relative_path}"
1479
+ end
1368
1480
  else
1369
1481
  dependencies.delete("ruby_runtime")
1370
1482
  assets.delete("assets/main.rb")
1371
1483
  assets.delete("assets/ruby_project/")
1372
- assets.delete("assets/#{self_contained_project_name}/")
1373
1484
  end
1374
1485
 
1375
1486
  flutter["assets"] = assets unless assets.empty?
@@ -1377,7 +1488,7 @@ module Ruflet
1377
1488
  write_pubspec_yaml(pubspec_path, data)
1378
1489
  end
1379
1490
 
1380
- RUBY_RUNTIME_FALLBACK_REQUIREMENT = "^0.0.5"
1491
+ RUBY_RUNTIME_FALLBACK_REQUIREMENT = "^0.0.6"
1381
1492
 
1382
1493
  def ruby_runtime_dependency(current_dependency = nil)
1383
1494
  local_path = explicit_local_ruby_runtime_path || repo_checkout_ruby_runtime_path
@@ -1747,6 +1858,50 @@ module Ruflet
1747
1858
  write_pubspec_yaml(path, data)
1748
1859
  end
1749
1860
 
1861
+ def sync_client_flet_packages(client_dir, selected_packages)
1862
+ template_root =
1863
+ if Ruflet::CLI.respond_to?(:resolve_ruflet_client_template_root, true)
1864
+ Ruflet::CLI.send(:resolve_ruflet_client_template_root)
1865
+ end
1866
+ return unless template_root
1867
+
1868
+ source_root = File.join(template_root, "flet_packages")
1869
+ target_root = File.join(client_dir, "flet_packages")
1870
+ return unless Dir.exist?(source_root)
1871
+ return if File.expand_path(source_root) == File.expand_path(target_root)
1872
+
1873
+ # The repository's standalone client intentionally carries the full
1874
+ # local package catalog. Only generated/template-derived clients are
1875
+ # conditioned for a particular Ruflet application.
1876
+ return if standalone_ruflet_client_source?(client_dir, template_root)
1877
+
1878
+ known_packages = CLIENT_EXTENSION_MAP.values.map { |meta| meta.fetch(:package) }.uniq
1879
+ required_packages = (["flet"] + selected_packages).uniq
1880
+
1881
+ FileUtils.mkdir_p(target_root)
1882
+ required_packages.each do |package_name|
1883
+ source = File.join(source_root, package_name)
1884
+ target = File.join(target_root, package_name)
1885
+ next unless Dir.exist?(source)
1886
+ next if Dir.exist?(target)
1887
+
1888
+ FileUtils.cp_r(source, target)
1889
+ end
1890
+
1891
+ (known_packages - selected_packages).each do |package_name|
1892
+ FileUtils.rm_rf(File.join(target_root, package_name))
1893
+ end
1894
+ end
1895
+
1896
+ def standalone_ruflet_client_source?(client_dir, template_root)
1897
+ client_root = File.expand_path(client_dir)
1898
+ candidates = [
1899
+ File.expand_path("../../ruflet_client", template_root),
1900
+ File.expand_path("../../../../../ruflet_client", __dir__)
1901
+ ]
1902
+ candidates.any? { |candidate| File.expand_path(candidate) == client_root }
1903
+ end
1904
+
1750
1905
  def sync_client_extension_dependencies(path, selected_packages)
1751
1906
  return if selected_packages.empty?
1752
1907
 
@@ -1755,14 +1910,30 @@ module Ruflet
1755
1910
 
1756
1911
  data = YAML.safe_load(File.read(path), aliases: true) || {}
1757
1912
  deps = (data["dependencies"] || {}).dup
1913
+ if selected_packages.include?("flet_rive")
1914
+ deps.delete("rive")
1915
+ deps.delete("rive_native")
1916
+ end
1758
1917
  selected_packages.each do |package_name|
1759
1918
  deps[package_name] = template_deps[package_name] if template_deps.key?(package_name)
1760
1919
  end
1761
1920
 
1762
1921
  data["dependencies"] = deps
1922
+ sync_local_flet_dependency_override(data, deps["flet"])
1763
1923
  write_pubspec_yaml(path, data)
1764
1924
  end
1765
1925
 
1926
+ # Git-backed Flet extensions declare their own Flet source. When Ruflet
1927
+ # vendors the core engine locally, force every extension to resolve that
1928
+ # same copy instead of letting Pub reject the mixed sources.
1929
+ def sync_local_flet_dependency_override(data, flet_dependency)
1930
+ return unless flet_dependency.is_a?(Hash) && key_defined?(flet_dependency, "path")
1931
+
1932
+ overrides = data["dependency_overrides"]
1933
+ overrides = data["dependency_overrides"] = {} unless overrides.is_a?(Hash)
1934
+ overrides["flet"] = flet_dependency
1935
+ end
1936
+
1766
1937
  def template_client_pubspec_dependencies
1767
1938
  template_root =
1768
1939
  if Ruflet::CLI.respond_to?(:resolve_ruflet_client_template_root, true)
@@ -212,8 +212,11 @@ module Ruflet
212
212
  def run_privileged_command(*command, verbose: false)
213
213
  full = sudo_prefix + command
214
214
  puts " $ #{full.join(' ')}"
215
- output = verbose ? $stdout : File::NULL
216
- system(*full, out: output, err: $stderr)
215
+ # Package installs (apt/dnf/pacman...) routinely download hundreds of
216
+ # megabytes and take minutes. Always stream their output so the user
217
+ # can see progress instead of a frozen-looking prompt; `verbose` is
218
+ # left for callers that want to add their own detail elsewhere.
219
+ system(*full, out: $stdout, err: $stderr)
217
220
  end
218
221
 
219
222
  def sudo_prefix
@@ -56,8 +56,7 @@ module Ruflet
56
56
  puts " Flutter: #{flutter_version_summary(tools)}"
57
57
  environment_issues += android_environment_setup!(fix: !!fix, verbose: !!verbose)
58
58
  ok = system(android_build_env(tools[:env]), tools[:flutter], "doctor", *(verbose ? ["-v"] : []))
59
- status = $?.exitstatus if $?
60
- status ||= ok ? 0 : 1
59
+ status = ok ? 0 : ($?&.exitstatus || 1)
61
60
  if environment_issues.any?
62
61
  warn "Unresolved environment issues: #{environment_issues.join('; ')}"
63
62
  status = 1 if status.zero?
@@ -84,8 +84,12 @@ module Ruflet
84
84
  File.write(fvmrc_path, "{\"flutter\":\"#{version}\"}\n")
85
85
  end
86
86
 
87
- system(fvm_env, fvm, "install", version.to_s, chdir: project_dir, out: File::NULL, err: File::NULL)
88
- system(fvm_env, fvm, "use", "--force", version.to_s, chdir: project_dir, out: File::NULL, err: File::NULL)
87
+ # Installing a Flutter SDK through FVM downloads/extracts hundreds of
88
+ # megabytes. Stream FVM's output so the run does not look frozen.
89
+ puts " $ fvm install #{version}"
90
+ system(fvm_env, fvm, "install", version.to_s, chdir: project_dir, out: $stdout, err: $stderr)
91
+ puts " $ fvm use --force #{version}"
92
+ system(fvm_env, fvm, "use", "--force", version.to_s, chdir: project_dir, out: $stdout, err: $stderr)
89
93
 
90
94
  flutter = flutter_bin_path(project_dir)
91
95
  return nil unless File.executable?(flutter)
@@ -107,7 +111,8 @@ module Ruflet
107
111
  end
108
112
  return nil unless dart && File.executable?(dart)
109
113
 
110
- system(dart, "pub", "global", "activate", "fvm", out: File::NULL, err: File::NULL)
114
+ puts " $ dart pub global activate fvm"
115
+ system(dart, "pub", "global", "activate", "fvm", out: $stdout, err: $stderr)
111
116
  installed_fvm = File.join(pub_cache_bin_dir, fvm_executable_name)
112
117
  return installed_fvm if File.executable?(installed_fvm)
113
118
 
@@ -148,10 +153,13 @@ module Ruflet
148
153
  return sdk_root if File.executable?(flutter_bin)
149
154
 
150
155
  FileUtils.mkdir_p(install_root)
156
+ puts " Installing Flutter #{release.fetch('version')} (#{host}) into #{install_root}"
151
157
  Dir.mktmpdir("ruflet-flutter-sdk-") do |tmpdir|
152
158
  archive_path = File.join(tmpdir, File.basename(archive))
153
- download_file("#{RELEASES_BASE}/#{archive}", archive_path)
159
+ download_file("#{RELEASES_BASE}/#{archive}", archive_path, label: "Flutter SDK")
160
+ puts " Extracting #{File.basename(archive)} (this can take a minute)"
154
161
  extract_archive(archive_path, install_root)
162
+ puts " Extracted Flutter SDK"
155
163
  end
156
164
 
157
165
  return sdk_root if File.executable?(flutter_bin)
@@ -366,7 +374,7 @@ module Ruflet
366
374
  nil
367
375
  end
368
376
 
369
- def download_file(url, destination, limit: 5)
377
+ def download_file(url, destination, limit: 5, label: nil)
370
378
  raise "Too many redirects while downloading #{url}" if limit <= 0
371
379
 
372
380
  uri = URI(url)
@@ -376,10 +384,21 @@ module Ruflet
376
384
  http.request(req) do |res|
377
385
  case res
378
386
  when Net::HTTPSuccess
379
- File.open(destination, "wb") { |f| res.read_body { |chunk| f.write(chunk) } }
387
+ name = label || File.basename(destination)
388
+ total = res["content-length"].to_i
389
+ downloaded = 0
390
+ marker = -1
391
+ File.open(destination, "wb") do |f|
392
+ res.read_body do |chunk|
393
+ f.write(chunk)
394
+ downloaded += chunk.bytesize
395
+ marker = report_download_progress(name, downloaded, total, marker)
396
+ end
397
+ end
398
+ finish_download_progress(name, downloaded, total)
380
399
  return destination
381
400
  when Net::HTTPRedirection
382
- return download_file(res["location"], destination, limit: limit - 1)
401
+ return download_file(res["location"], destination, limit: limit - 1, label: label)
383
402
  else
384
403
  raise "Download failed (#{res.code})"
385
404
  end
@@ -389,6 +408,51 @@ module Ruflet
389
408
  curl_download(url, destination)
390
409
  end
391
410
 
411
+ # Net::HTTP gives no progress for a multi-hundred-MB SDK download, which
412
+ # looks like a hang. Report percentage on a TTY (rewritten in place) and
413
+ # at coarse steps when piped, so logs stay readable either way.
414
+ def report_download_progress(name, downloaded, total, marker)
415
+ if total.positive?
416
+ percent = downloaded * 100 / total
417
+ return marker if percent <= marker
418
+
419
+ line = " Downloading #{name}: #{percent}% (#{human_size(downloaded)} / #{human_size(total)})"
420
+ if $stdout.tty?
421
+ $stdout.print("\r#{line}")
422
+ elsif (percent % 10).zero?
423
+ puts line
424
+ end
425
+ percent
426
+ else
427
+ step = downloaded / (5 * 1024 * 1024)
428
+ return marker if step <= marker
429
+
430
+ line = " Downloading #{name}: #{human_size(downloaded)}"
431
+ $stdout.tty? ? $stdout.print("\r#{line}") : puts(line)
432
+ step
433
+ end
434
+ end
435
+
436
+ def finish_download_progress(name, downloaded, total)
437
+ summary = total.positive? ? "#{human_size(downloaded)} / #{human_size(total)}" : human_size(downloaded)
438
+ if $stdout.tty?
439
+ $stdout.print("\r Downloaded #{name}: #{summary}\n")
440
+ else
441
+ puts " Downloaded #{name}: #{summary}"
442
+ end
443
+ end
444
+
445
+ def human_size(bytes)
446
+ units = %w[B KB MB GB TB]
447
+ size = bytes.to_f
448
+ unit = units.shift
449
+ while size >= 1024 && units.any?
450
+ size /= 1024
451
+ unit = units.shift
452
+ end
453
+ format("%.1f %s", size, unit)
454
+ end
455
+
392
456
  def extract_archive(archive, destination)
393
457
  if archive.end_with?(".zip")
394
458
  if windows_host?
@@ -25,6 +25,7 @@ module Ruflet
25
25
  "map" => { package: "flet_map", alias: "ruflet_map" },
26
26
  "permission_handler" => { package: "flet_permission_handler", alias: "ruflet_permission_handler" },
27
27
  "secure_storage" => { package: "flet_secure_storage", alias: "ruflet_secure_storage" },
28
+ "spinkit" => { package: "flet_spinkit", alias: "ruflet_spinkit" },
28
29
  "video" => { package: "flet_video", alias: "ruflet_video" },
29
30
  "webview" => { package: "flet_webview", alias: "ruflet_webview" }
30
31
  }.freeze
@@ -17,12 +17,18 @@ require "yaml"
17
17
  module Ruflet
18
18
  module CLI
19
19
  module RunCommand
20
+ DEFAULT_BACKEND_PORTS = {
21
+ "web" => 8550,
22
+ "desktop" => 8560,
23
+ "mobile" => 8570
24
+ }.freeze
25
+
20
26
  def command_run(args)
21
- options = { target: "mobile", requested_port: 8550 }
27
+ options = { target: "mobile", requested_port: nil }
22
28
  parser = OptionParser.new do |o|
23
29
  o.on("--web") { options[:target] = "web" }
24
30
  o.on("--desktop") { options[:target] = "desktop" }
25
- o.on("--port PORT", Integer) { |v| options[:requested_port] = v }
31
+ o.on("--port PORT", Integer, "Backend port (defaults: web 8550, desktop 8560, mobile 8570)") { |v| options[:requested_port] = v }
26
32
  end
27
33
  parser.parse!(args)
28
34
 
@@ -34,18 +40,33 @@ module Ruflet
34
40
  return 1
35
41
  end
36
42
 
37
- selected_port = resolve_backend_port(options[:target], requested_port: options[:requested_port])
43
+ requested_port = options[:requested_port] || default_backend_port(options[:target])
44
+ selected_port = resolve_backend_port(options[:target], requested_port: requested_port)
38
45
  return 1 unless selected_port
39
46
  env = {
40
47
  "RUFLET_TARGET" => options[:target],
41
48
  "RUFLET_SUPPRESS_SERVER_BANNER" => "1",
42
- "RUFLET_PORT" => selected_port.to_s
49
+ "RUFLET_PORT" => selected_port.to_s,
50
+ "RUFLET_STRICT_PORT" => "1"
43
51
  }
44
52
  apply_local_ruflet_dev_overrides(env)
45
53
  assets_dir = File.join(File.dirname(script_path), "assets")
46
54
  env["RUFLET_ASSETS_DIR"] = assets_dir if File.directory?(assets_dir)
47
55
 
48
- print_run_banner(target: options[:target], requested_port: options[:requested_port], port: selected_port)
56
+ # Web: the Ruby backend serves the Flutter web client itself (same
57
+ # origin/port as /ws), so no separate static server or proxy is needed.
58
+ if options[:target] == "web"
59
+ web_dir = detect_web_client_dir
60
+ if web_dir
61
+ env["RUFLET_WEB_CLIENT_DIR"] = web_dir
62
+ else
63
+ warn "Ruflet web client unavailable for version #{ruflet_version}."
64
+ warn "Install the matching GitHub release client before running --web."
65
+ return 1
66
+ end
67
+ end
68
+
69
+ print_run_banner(target: options[:target], requested_port: requested_port, port: selected_port)
49
70
  print_mobile_qr_hint(port: selected_port) if options[:target] == "mobile"
50
71
 
51
72
  gemfile_path = find_nearest_gemfile(Dir.pwd)
@@ -150,16 +171,14 @@ module Ruflet
150
171
 
151
172
  def print_run_banner(target:, requested_port:, port:)
152
173
  if port != requested_port.to_i
153
- puts "Requested port #{requested_port} is busy; bound to #{port}"
174
+ puts "Port #{requested_port} is busy; using #{port} for #{target}."
154
175
  end
155
176
  if target == "desktop"
156
177
  puts "Ruflet desktop URL: http://localhost:#{port}"
157
178
  elsif target == "mobile"
158
179
  puts "Ruflet target: #{target}"
159
- else
160
- puts "Ruflet target: #{target}"
161
- puts "Ruflet URL: http://localhost:#{port}"
162
180
  end
181
+ # web: launch_web_client prints the single app URL after the server boots
163
182
  end
164
183
 
165
184
  def launch_target_client(target, port)
@@ -175,30 +194,19 @@ module Ruflet
175
194
  end
176
195
  end
177
196
 
197
+ # The Ruby backend (Ruflet::Server) serves the Flutter web client on its
198
+ # own port, so the client loads and opens its websocket on that same
199
+ # origin. The prebuilt web client derives its backend from Uri.base, so
200
+ # the public URL stays clean and dynamic port selection works naturally.
178
201
  def launch_web_client(port)
179
- web_dir = detect_web_client_dir
180
- unless web_dir
181
- warn "Web client build not found and prebuilt download failed."
182
- return []
183
- end
184
-
185
- web_port = find_available_port(port + 1)
186
- 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)
187
- Process.detach(web_pid)
188
- wait_for_server_boot(web_port)
189
- backend_url = "http://localhost:#{port}"
190
- web_url = "http://localhost:#{web_port}/?#{URI.encode_www_form(url: backend_url)}"
191
- browser_pid = open_in_browser_app_mode(web_url)
192
- open_in_browser(web_url) if browser_pid.nil?
193
- puts "Ruflet web client: #{web_url}"
194
- puts "Ruflet backend ws: ws://localhost:#{port}/ws"
195
- [web_pid, browser_pid].compact
196
- rescue Errno::ENOENT
197
- warn "python3 is required to host web client locally."
198
- warn "Install Python 3 and rerun."
199
- []
202
+ url = "http://localhost:#{port}/"
203
+ browser_pid = open_in_browser_app_mode(url)
204
+ open_in_browser(url) if browser_pid.nil?
205
+ puts "Ruflet web app: #{url}"
206
+ [browser_pid].compact
200
207
  rescue StandardError => e
201
- warn "Failed to launch web client: #{e.class}: #{e.message}"
208
+ warn "Failed to open web client: #{e.class}: #{e.message}"
209
+ warn "Open it manually: http://localhost:#{port}/"
202
210
  []
203
211
  end
204
212
 
@@ -286,7 +294,7 @@ module Ruflet
286
294
  end
287
295
 
288
296
  def launch_desktop_client(url)
289
- cmd = detect_project_desktop_client_command(url) || detect_desktop_client_command(url)
297
+ cmd = detect_desktop_client_command(url)
290
298
  unless cmd
291
299
  warn "Desktop client executable not found."
292
300
  warn "Set RUFLET_CLIENT_DIR to your client path."
@@ -307,136 +315,37 @@ module Ruflet
307
315
  []
308
316
  end
309
317
 
310
- def detect_project_desktop_client_command(url)
311
- return nil unless project_run_requires_managed_client?
312
- return nil unless respond_to?(:ensure_flutter_client_dir, true)
313
- return nil unless respond_to?(:prepare_flutter_client, true)
314
- return nil unless respond_to?(:ensure_flutter!, true)
315
-
316
- platform = host_platform_name
317
- return nil unless %w[macos windows linux].include?(platform)
318
-
319
- ensure_ruflet_build_assets(verbose: false) if respond_to?(:ensure_ruflet_build_assets, true)
320
- client_dir = ensure_flutter_client_dir(verbose: false)
321
- return nil unless client_dir
322
-
323
- config = project_run_config
324
- tools = ensure_flutter!("run", client_dir: client_dir)
325
- env = build_tool_env(tools[:env], platform, client_dir)
326
- return nil unless prepare_flutter_client(
327
- client_dir,
328
- platform: platform,
329
- tools: tools,
330
- config: config,
331
- self_contained: false,
332
- verbose: false
333
- )
334
-
335
- [
336
- env,
337
- tools[:flutter],
338
- "run",
339
- "-d",
340
- platform,
341
- "--target",
342
- "lib/main.server.dart",
343
- "--dart-define",
344
- "RUFLET_BACKEND_URL=#{url}"
345
- ]
346
- rescue StandardError => e
347
- warn "Project desktop client setup failed: #{e.class}: #{e.message}"
348
- nil
349
- end
350
-
351
- def project_run_requires_managed_client?
352
- extensions = Array(project_run_config["extensions"]).map { |value| normalize_run_extension_key(value) }.compact
353
- protected_extensions =
354
- if defined?(Ruflet::CLI::BuildCommand::SERVICE_EXTENSION_MAP)
355
- Ruflet::CLI::BuildCommand::SERVICE_EXTENSION_MAP.values.flatten
356
- else
357
- []
358
- end
359
- return true if (extensions - protected_extensions).any?
360
-
361
- services =
362
- if respond_to?(:load_service_definitions, true)
363
- send(:load_service_definitions).keys
364
- else
365
- []
366
- end
367
- return false if services.empty?
368
-
369
- known_services =
370
- if defined?(Ruflet::CLI::BuildCommand::DEFAULT_SERVICE_NATIVE_REQUIREMENTS)
371
- Ruflet::CLI::BuildCommand::DEFAULT_SERVICE_NATIVE_REQUIREMENTS.keys
372
- else
373
- []
374
- end
375
- (services & known_services).any?
376
- end
377
-
378
- def project_run_config
379
- config_path = ENV["RUFLET_CONFIG"] || (File.file?("ruflet.yaml") ? "ruflet.yaml" : "ruflet.yml")
380
- return {} unless File.file?(config_path)
381
-
382
- YAML.safe_load(File.read(config_path), aliases: true) || {}
383
- rescue StandardError
384
- {}
385
- end
318
+ def detect_desktop_client_command(url)
319
+ root = configured_client_root
386
320
 
387
- def normalize_run_extension_key(value)
388
- key = value.to_s.strip.downcase
389
- return nil if key.empty?
321
+ command = desktop_client_command_from_root(root, url)
322
+ return command if command
390
323
 
391
- key.tr!("-", "_")
392
- key.gsub!(/\A(flet_)+/, "")
393
- key.gsub!(/\Aservice_/, "")
394
- key
324
+ cached_root = ensure_prebuilt_client(desktop: true)
325
+ desktop_client_command_from_root(cached_root, url)
395
326
  end
396
327
 
397
- def detect_desktop_client_command(url)
398
- root = ENV["RUFLET_CLIENT_DIR"]
399
- root = File.expand_path("ruflet_client", Dir.pwd) if root.to_s.strip.empty?
400
- root = nil unless Dir.exist?(root)
401
- root ||= ensure_prebuilt_client(desktop: true)
328
+ def desktop_client_command_from_root(root, url)
402
329
  return nil unless root && Dir.exist?(root)
403
330
 
404
331
  host_os = RbConfig::CONFIG["host_os"]
405
332
  if host_os.match?(/darwin/i)
406
- app_path = [
407
- File.join(root, "build", "macos", "Build", "Products", "Release", "ruflet_client.app"),
408
- File.join(root, "build", "macos", "Build", "Products", "Debug", "ruflet_client.app"),
409
- File.join(root, "desktop", "ruflet_client.app")
410
- ].find do |candidate|
411
- bin = File.join(candidate, "Contents", "MacOS", "ruflet_client")
412
- File.file?(bin) && File.executable?(bin) && ensure_macos_file_picker_entitlement(candidate)
413
- end
414
- return [File.join(app_path, "Contents", "MacOS", "ruflet_client"), url] if app_path
333
+ app_path = File.join(root, "desktop", "ruflet_client.app")
334
+ bin = File.join(app_path, "Contents", "MacOS", "ruflet_client")
335
+ return [bin, url] if File.file?(bin) && File.executable?(bin) && ensure_macos_file_picker_entitlement(app_path)
415
336
  elsif host_os.match?(/mswin|mingw|cygwin/i)
416
- exe = File.join(root, "build", "windows", "x64", "runner", "Release", "ruflet_client.exe")
417
- prebuilt = File.join(root, "desktop", "ruflet_client.exe")
418
- exe = prebuilt if !File.file?(exe) && File.file?(prebuilt)
337
+ exe = File.join(root, "desktop", "ruflet_client.exe")
419
338
  return [exe, url] if File.file?(exe)
420
339
  else
421
- direct = File.join(root, "build", "linux", "x64", "release", "bundle", "ruflet_client")
422
- prebuilt_direct = File.join(root, "desktop", "ruflet_client")
423
- direct = prebuilt_direct if !File.file?(direct) && File.file?(prebuilt_direct)
340
+ direct = File.join(root, "desktop", "ruflet_client")
424
341
  return [direct, url] if File.file?(direct)
425
- bundle_dir = File.join(root, "build", "linux", "x64", "release", "bundle")
426
- if Dir.exist?(bundle_dir)
427
- candidate = Dir.children(bundle_dir).map { |f| File.join(bundle_dir, f) }
428
- .find { |path| File.file?(path) && File.executable?(path) }
429
- return [candidate, url] if candidate
430
- end
431
342
  end
432
343
 
433
344
  nil
434
345
  end
435
346
 
436
347
  def detect_web_client_dir
437
- root = ENV["RUFLET_CLIENT_DIR"]
438
- root = File.expand_path("ruflet_client", Dir.pwd) if root.to_s.strip.empty?
439
- root = nil unless Dir.exist?(root)
348
+ root = configured_client_root
440
349
  root ||= ensure_prebuilt_client(web: true)
441
350
  return nil unless root && Dir.exist?(root)
442
351
 
@@ -448,6 +357,14 @@ module Ruflet
448
357
  nil
449
358
  end
450
359
 
360
+ def configured_client_root
361
+ root = ENV["RUFLET_CLIENT_DIR"].to_s.strip
362
+ return nil if root.empty?
363
+
364
+ expanded = File.expand_path(root)
365
+ Dir.exist?(expanded) ? expanded : nil
366
+ end
367
+
451
368
  def ensure_prebuilt_client(web: false, desktop: false, platform: nil, force: false)
452
369
  platform ||= host_platform_name
453
370
  return nil if platform.nil?
@@ -462,10 +379,12 @@ module Ruflet
462
379
  return nil if desktop_asset.nil?
463
380
  wanted_assets << { kind: :desktop, name: desktop_asset, platform: platform }
464
381
  end
465
- if !force && (wanted_assets.empty? || prebuilt_assets_present?(cache_root, web: web, desktop: desktop, platform: platform))
466
- ensure_client_manifest(cache_root, platform: platform)
382
+ cached = prebuilt_assets_present?(cache_root, web: web, desktop: desktop, platform: platform)
383
+ compatible_cache = cached && compatible_client_cache?(cache_root, platform: platform)
384
+ if !force && (wanted_assets.empty? || compatible_cache)
467
385
  return cache_root
468
386
  end
387
+ force = true if cached && !compatible_cache
469
388
 
470
389
  release = fetch_release_for_version
471
390
  return nil unless release
@@ -561,11 +480,33 @@ module Ruflet
561
480
  end
562
481
 
563
482
  def fetch_release_for_version
564
- release_by_tag("v#{ruflet_version}") ||
565
- release_by_tag(ruflet_version) ||
566
- release_by_tag("prebuild") ||
567
- release_by_tag("prebuild-main") ||
568
- release_latest
483
+ client_release_tags.each do |tag|
484
+ release = release_by_tag(tag)
485
+ return release if release
486
+ end
487
+
488
+ nil
489
+ end
490
+
491
+ def client_release_tags
492
+ channel = ENV["RUFLET_CLIENT_CHANNEL"].to_s.strip
493
+ unless channel.empty?
494
+ raise ArgumentError, "Invalid RUFLET_CLIENT_CHANNEL" unless channel.match?(/\A[A-Za-z0-9._-]+\z/)
495
+
496
+ return [channel]
497
+ end
498
+
499
+ ["v#{ruflet_version}", ruflet_version]
500
+ end
501
+
502
+ def compatible_client_cache?(root, platform:)
503
+ manifest = read_client_manifest(root)
504
+ return false unless manifest
505
+
506
+ manifest["schema"] == 1 &&
507
+ manifest["ruflet_version"] == ruflet_version &&
508
+ manifest["platform"] == platform &&
509
+ client_release_tags.include?(manifest["release_tag"])
569
510
  end
570
511
 
571
512
  def ruflet_version
@@ -575,10 +516,6 @@ module Ruflet
575
516
  Ruflet::VERSION
576
517
  end
577
518
 
578
- def release_latest
579
- github_get_json("https://api.github.com/repos/AdamMusa/Ruflet/releases/latest")
580
- end
581
-
582
519
  def release_by_tag(tag)
583
520
  github_get_json("https://api.github.com/repos/AdamMusa/Ruflet/releases/tags/#{tag}")
584
521
  rescue StandardError
@@ -748,19 +685,6 @@ module Ruflet
748
685
  nil
749
686
  end
750
687
 
751
- def ensure_client_manifest(root, platform:)
752
- return if read_client_manifest(root)
753
-
754
- assets = []
755
- assets << { "kind" => "web", "platform" => platform, "asset_name" => nil } if File.file?(File.join(root, "web", "index.html"))
756
- if prebuilt_desktop_present?(root, platform: platform)
757
- assets << { "kind" => "desktop", "platform" => platform, "asset_name" => nil }
758
- end
759
- return if assets.empty?
760
-
761
- write_client_manifest(root, platform: platform, release: nil, assets: assets)
762
- end
763
-
764
688
  def write_client_manifest(root, platform:, release:, assets:)
765
689
  FileUtils.mkdir_p(root)
766
690
  payload = {
@@ -793,26 +717,24 @@ module Ruflet
793
717
  port = start_port.to_i
794
718
 
795
719
  max_attempts.times do
796
- begin
797
- begin
798
- probe = TCPServer.new("0.0.0.0", port)
799
- rescue Errno::EACCES, Errno::EPERM
800
- probe = TCPServer.new("127.0.0.1", port)
801
- end
802
- probe.close
803
- return port
804
- rescue Errno::EADDRINUSE, Errno::EACCES, Errno::EPERM
805
- port += 1
806
- end
720
+ return port if port_available?(port)
721
+
722
+ port += 1
807
723
  end
808
724
 
809
- start_port
725
+ nil
726
+ end
727
+
728
+ def default_backend_port(target)
729
+ DEFAULT_BACKEND_PORTS.fetch(target.to_s, DEFAULT_BACKEND_PORTS.fetch("mobile"))
810
730
  end
811
731
 
812
- def resolve_backend_port(_target, requested_port: 8550)
732
+ def resolve_backend_port(target, requested_port: nil)
813
733
  base = requested_port.to_i
814
- base = 8550 if base <= 0
815
- find_available_port(base)
734
+ base = default_backend_port(target) if base <= 0
735
+ selected = find_available_port(base)
736
+ warn "No available Ruflet port found starting at #{base}." unless selected
737
+ selected
816
738
  end
817
739
 
818
740
  def port_available?(port)
@@ -824,7 +746,7 @@ module Ruflet
824
746
  probe = TCPServer.new("127.0.0.1", port)
825
747
  end
826
748
  true
827
- rescue Errno::EADDRINUSE
749
+ rescue Errno::EADDRINUSE, Errno::EACCES, Errno::EPERM
828
750
  false
829
751
  ensure
830
752
  probe&.close
data/lib/ruflet/cli.rb CHANGED
@@ -6,6 +6,13 @@ require "optparse"
6
6
  # depend on the caller's locale for reading them.
7
7
  Encoding.default_external = Encoding::UTF_8 if Encoding.default_external == Encoding::US_ASCII
8
8
 
9
+ # Provisioning (downloads, package installs, SDK extraction) can run for
10
+ # minutes. When stdout is not a TTY (CI logs, pipes, editor consoles) Ruby
11
+ # buffers it, so progress notes would only appear once the whole run finishes.
12
+ # Unbuffer both streams so the pipeline reports progress as it happens.
13
+ $stdout.sync = true
14
+ $stderr.sync = true
15
+
9
16
  require_relative "version"
10
17
  require_relative "cli/templates"
11
18
  require_relative "cli/new_command"
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ruflet
4
- VERSION = "0.0.15" unless const_defined?(:VERSION)
4
+ VERSION = "0.0.16" unless const_defined?(:VERSION)
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruflet
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.15
4
+ version: 0.0.16
5
5
  platform: ruby
6
6
  authors:
7
7
  - AdamMusa
@@ -49,7 +49,8 @@ files:
49
49
  - lib/ruflet/version.rb
50
50
  - lib/ruflet_cli.rb
51
51
  homepage: https://github.com/AdamMusa/Ruflet
52
- licenses: []
52
+ licenses:
53
+ - MIT
53
54
  metadata: {}
54
55
  rdoc_options: []
55
56
  require_paths: