ruflet 0.0.20 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 78de8fe06ee145d7d509f553ec4905750a27610051ce6da3d1de48dc7b79fb64
4
- data.tar.gz: 2a6527b6605fb968b7b219503e1049bf8cb0c1cbe3b35ed8c0af5f5ae4237d0a
3
+ metadata.gz: 4f32ccf43fe430d641b8f1b72f05a048d6c654a8ec6c4e2ac4c02b6752d67e47
4
+ data.tar.gz: ef7244a5cc788bf2dbfbcf74731e86d4dd1bd61d0a0fc104dffecdfeab444850
5
5
  SHA512:
6
- metadata.gz: 1573aa2e047b0f7e6497e4f9a94f4a4fd81ee842d6c9e17221b814e4b289c84882302e06049274d64882ae2b8c833507d7cd86b8cfbc95da06028371544dec3d
7
- data.tar.gz: 45bc272c824b3a09783e4be9202ce87272ce8f2715bd23e06a6a8db5e3839c3604b70aa629da21a1d98b91daf73beb5e2038b3c68d13cdda3e829b316ad9db23
6
+ metadata.gz: a6618e3cbab937475c14990529be3e96786f21c62de32ddbc0b5d6ca0fcfb2809c1c70084904948881f575d2ad3b73641f2bca30dac64d0f3402e4f57f1e0846
7
+ data.tar.gz: 751bf5286756eab0da77e4be3f2abcafcec4b335ea379fb02628f8ca5af7016530d4052c8e595ab385bef5001ea594d31eadfb9f3fd71cada7aa362c8194b97e
@@ -73,7 +73,7 @@ module Ruflet
73
73
  verbose = args.delete("--verbose") || args.delete("-v")
74
74
  platform = (args.shift || "").downcase
75
75
  if platform.empty?
76
- warn "Usage: ruflet build <apk|android|ios|aab|web|macos|windows|linux> [--self] [--verbose]"
76
+ warn "Usage: ruflet build <apk|android|aab|ios|ipa|web|macos|windows|linux> [--self] [--verbose]"
77
77
  return 1
78
78
  end
79
79
 
@@ -83,6 +83,11 @@ module Ruflet
83
83
  return 1
84
84
  end
85
85
 
86
+ # `ipa` produces the uploadable archive; every other step — pods,
87
+ # signing, icons, package name — is the same as a plain iOS build.
88
+ requested_platform = platform
89
+ platform = "ios" if platform == "ipa"
90
+
86
91
  # The embedded Ruby VM is a native plugin with no browser
87
92
  # implementation, so a self-contained web build produces an app that
88
93
  # cannot start. Say so rather than shipping one that hangs.
@@ -115,7 +120,7 @@ module Ruflet
115
120
  return 1 unless ok
116
121
 
117
122
  build_args = [*flutter_cmd, *args]
118
- build_args << "--codesign" if ios_device_build_needs_codesign_flag?(platform, build_args)
123
+ build_args << "--codesign" if ios_device_build_needs_codesign_flag?(requested_platform, build_args)
119
124
  target_entrypoint = flutter_target_entrypoint(client_dir, self_contained: !!self_contained)
120
125
  build_args += ["--target", target_entrypoint] if target_entrypoint
121
126
  backend_url = configured_backend_url(config)
@@ -437,6 +442,8 @@ module Ruflet
437
442
  return false unless validate_mobile_app_identity(metadata, platform: platform)
438
443
 
439
444
  apply_native_service_permissions(client_dir, config)
445
+ apply_android_signing_config(client_dir, platform, verbose: !!verbose)
446
+ apply_ios_signing_team(client_dir, config) if %w[ios ipa macos].include?(platform.to_s)
440
447
  configured = configure_client_runtime_mode(client_dir, self_contained: self_contained, verbose: verbose)
441
448
  return false if configured == false
442
449
  @ruflet_self_contained_build = self_contained
@@ -740,8 +747,11 @@ module Ruflet
740
747
  if File.file?(services_path)
741
748
  service_config = YAML.safe_load(read_text_file(services_path), aliases: true) || {}
742
749
  if service_config["app"].is_a?(Hash)
743
- config["app"] = (config["app"].is_a?(Hash) ? config["app"] : {}).merge(service_config["app"])
744
- config["_app_identity_source"] = "services.yaml"
750
+ # ruflet.yaml declares the app; services.yaml may still carry an
751
+ # identity from older projects, so it fills gaps rather than
752
+ # overriding what the project states.
753
+ declared = config["app"].is_a?(Hash) ? config["app"] : {}
754
+ config["app"] = service_config["app"].merge(declared)
745
755
  end
746
756
  config["services"] = service_config["services"] if service_config.key?("services")
747
757
  end
@@ -1200,10 +1210,10 @@ module Ruflet
1200
1210
  first_present(app["bundle_identifier"], config["bundle_identifier"], "#{organization}.#{package_name}")
1201
1211
  )
1202
1212
  identity_errors = []
1203
- identity_errors << "app.app_name" if app["app_name"].to_s.strip.empty?
1213
+ named = first_present(app["name"], app["app_name"], app["display_name"])
1214
+ identity_errors << "app.name" if named.nil?
1204
1215
  identity_errors << "app.package_name" if app["package_name"].to_s.strip.empty?
1205
- identity_errors << "app.organization" if app["organization"].to_s.strip.empty?
1206
- identity_errors << "services.yaml app section" unless config["_app_identity_source"] == "services.yaml"
1216
+ identity_errors << "app.organization" if first_present(app["organization"], app["org"]).nil?
1207
1217
 
1208
1218
  {
1209
1219
  package_name: package_name,
@@ -1237,8 +1247,8 @@ module Ruflet
1237
1247
  errors = Array(metadata[:mobile_identity_errors])
1238
1248
  return true if errors.empty?
1239
1249
 
1240
- warn "build config error: services.yaml must define #{errors.join(', ')}"
1241
- warn "Set app.app_name, app.package_name, and app.organization before building a mobile app."
1250
+ warn "build config error: ruflet.yaml must define #{errors.join(', ')}"
1251
+ warn "A mobile build needs app.name, app.package_name and app.organization."
1242
1252
  false
1243
1253
  end
1244
1254
 
@@ -1271,6 +1281,33 @@ module Ruflet
1271
1281
  )
1272
1282
  end
1273
1283
 
1284
+ # The signing team belongs to whoever ships the app, so it comes from the
1285
+ # project rather than being baked into the client.
1286
+ def apply_ios_signing_team(client_dir, config)
1287
+ team = ios_signing_team(config)
1288
+ pbxproj_path = File.join(client_dir, "ios", "Runner.xcodeproj", "project.pbxproj")
1289
+ return unless File.file?(pbxproj_path)
1290
+
1291
+ if team.to_s.strip.empty?
1292
+ build_note("No ios.team_id configured; Xcode will pick the signing team")
1293
+ replace_in_file(pbxproj_path, /DEVELOPMENT_TEAM = [^;]*;/, "DEVELOPMENT_TEAM = \"\";")
1294
+ return
1295
+ end
1296
+
1297
+ replace_in_file(pbxproj_path, /DEVELOPMENT_TEAM = [^;]*;/, "DEVELOPMENT_TEAM = #{team};")
1298
+ build_note("iOS signing team set to #{team}")
1299
+ end
1300
+
1301
+ def ios_signing_team(config)
1302
+ app = config["app"].is_a?(Hash) ? config["app"] : {}
1303
+ first_present(
1304
+ platform_build_config(config, "ios")["team_id"],
1305
+ app["ios_team_id"],
1306
+ app["team_id"],
1307
+ ENV["RUFLET_IOS_TEAM_ID"]
1308
+ )
1309
+ end
1310
+
1274
1311
  def apply_ios_metadata(client_dir, metadata)
1275
1312
  info_plist_path = File.join(client_dir, "ios", "Runner", "Info.plist")
1276
1313
  replace_plist_value(info_plist_path, "CFBundleDisplayName", metadata[:display_name])
@@ -1508,17 +1545,109 @@ module Ruflet
1508
1545
  extension_packages = extension_keys.filter_map { |key| CLIENT_EXTENSION_MAP[key]&.fetch(:package) }.uniq
1509
1546
  extension_aliases = extension_keys.filter_map { |key| CLIENT_EXTENSION_MAP[key]&.fetch(:alias) }.uniq
1510
1547
 
1548
+ external = external_extension_entries(config)
1549
+
1511
1550
  pubspec_path = File.join(client_dir, "pubspec.yaml")
1512
1551
  if File.file?(pubspec_path)
1513
1552
  sync_client_extension_dependencies(pubspec_path, extension_packages)
1514
1553
  prune_client_pubspec(pubspec_path, extension_packages)
1554
+ sync_external_extension_dependencies(pubspec_path, external)
1515
1555
  end
1516
1556
  client_entrypoint_paths(client_dir).each do |entrypoint|
1517
- sync_client_main_extensions(entrypoint, extension_aliases) if File.file?(entrypoint)
1518
- prune_client_main(entrypoint, extension_aliases) if File.file?(entrypoint)
1557
+ next unless File.file?(entrypoint)
1558
+
1559
+ sync_client_main_extensions(entrypoint, extension_aliases)
1560
+ prune_client_main(entrypoint, extension_aliases)
1561
+ sync_external_extension_registrations(entrypoint, external)
1562
+ end
1563
+ end
1564
+
1565
+ # An extension may name a package the template does not bundle, declared
1566
+ # with the source to fetch it from:
1567
+ #
1568
+ # extensions:
1569
+ # - charts
1570
+ # - my_package:
1571
+ # git:
1572
+ # url: https://github.com/owner/my_package
1573
+ # ref: main
1574
+ #
1575
+ # `branch` is accepted for `ref`, and `path` for a local checkout.
1576
+ def external_extension_entries(config)
1577
+ Array(config["extensions"]).filter_map do |entry|
1578
+ next unless entry.is_a?(Hash)
1579
+ next unless entry.size == 1
1580
+
1581
+ name, source = entry.first
1582
+ package = name.to_s.strip
1583
+ next if package.empty?
1584
+
1585
+ dependency = external_extension_dependency(source)
1586
+ next unless dependency
1587
+
1588
+ { name: package, dependency: dependency }
1519
1589
  end
1520
1590
  end
1521
1591
 
1592
+ def external_extension_dependency(source)
1593
+ return nil unless source.is_a?(Hash)
1594
+
1595
+ normalized = source.each_with_object({}) { |(key, value), out| out[key.to_s] = value }
1596
+ git = normalized["git"] || normalized["github"] || normalized["repository"]
1597
+ git = normalized if git.nil? && normalized.key?("url")
1598
+
1599
+ if git.is_a?(String)
1600
+ return { "git" => git }
1601
+ elsif git.is_a?(Hash)
1602
+ git = git.each_with_object({}) { |(key, value), out| out[key.to_s] = value }
1603
+ url = git["url"].to_s.strip
1604
+ return nil if url.empty?
1605
+
1606
+ spec = { "url" => url }
1607
+ ref = (git["ref"] || git["branch"] || git["tag"]).to_s.strip
1608
+ spec["ref"] = ref unless ref.empty?
1609
+ path = git["path"].to_s.strip
1610
+ spec["path"] = path unless path.empty?
1611
+ return { "git" => spec }
1612
+ end
1613
+
1614
+ local = normalized["path"]
1615
+ return { "path" => local.to_s } if local.is_a?(String) && !local.to_s.strip.empty?
1616
+
1617
+ nil
1618
+ end
1619
+
1620
+ def sync_external_extension_dependencies(pubspec_path, entries)
1621
+ return if entries.empty?
1622
+
1623
+ data = YAML.safe_load(read_text_file(pubspec_path), aliases: true) || {}
1624
+ dependencies = (data["dependencies"] || {}).dup
1625
+ entries.each { |entry| dependencies[entry[:name]] = entry[:dependency] }
1626
+ data["dependencies"] = dependencies
1627
+ write_pubspec_yaml(pubspec_path, data)
1628
+ build_note("Added #{entries.map { |e| e[:name] }.join(', ')} from the extension configuration")
1629
+ end
1630
+
1631
+ # Flet extension packages expose an Extension class from a library named
1632
+ # after the package, so the import and registration can be derived.
1633
+ def sync_external_extension_registrations(path, entries)
1634
+ return if entries.empty?
1635
+
1636
+ content = read_text_file(path)
1637
+ original = content.dup
1638
+
1639
+ entries.each do |entry|
1640
+ name = entry[:name]
1641
+ import_line = %(import 'package:#{name}/#{name}.dart' as #{name};\n)
1642
+ extension_line = " #{name}.Extension(),\n"
1643
+
1644
+ content = insert_missing_import(content, import_line) unless content.include?("package:#{name}/#{name}.dart")
1645
+ content = insert_missing_extension(content, extension_line) unless content.match?(/^\s*#{Regexp.escape(name)}\.Extension\(\),/)
1646
+ end
1647
+
1648
+ write_text_file(path, content) unless content == original
1649
+ end
1650
+
1522
1651
  def configured_service_entries(config)
1523
1652
  Array(config["services"]).filter_map do |entry|
1524
1653
  case entry
@@ -1536,6 +1665,79 @@ module Ruflet
1536
1665
  end
1537
1666
  end
1538
1667
 
1668
+ ANDROID_SIGNING_KEYS = {
1669
+ "storeFile" => "RUFLET_ANDROID_KEYSTORE",
1670
+ "storePassword" => "RUFLET_ANDROID_KEYSTORE_PASSWORD",
1671
+ "keyAlias" => "RUFLET_ANDROID_KEY_ALIAS",
1672
+ "keyPassword" => "RUFLET_ANDROID_KEY_PASSWORD"
1673
+ }.freeze
1674
+
1675
+ # Release signing is read from the project, never from the managed client,
1676
+ # so nobody has to edit build/client by hand. Either keep an
1677
+ # android/key.properties beside the app, or set the RUFLET_ANDROID_*
1678
+ # variables. Without one of those, Gradle falls back to the debug key.
1679
+ def apply_android_signing_config(client_dir, platform, verbose: false)
1680
+ return unless %w[apk android aab appbundle].include?(platform.to_s)
1681
+
1682
+ android_dir = File.join(client_dir, "android")
1683
+ return unless Dir.exist?(android_dir)
1684
+
1685
+ destination = File.join(android_dir, "key.properties")
1686
+ properties = android_signing_properties
1687
+ if properties.empty?
1688
+ File.delete(destination) if File.file?(destination)
1689
+ build_note("No Android signing configured; the release build will use the debug key")
1690
+ return
1691
+ end
1692
+
1693
+ missing = ANDROID_SIGNING_KEYS.keys - properties.keys
1694
+ unless missing.empty?
1695
+ warn "build config error: Android signing is missing #{missing.join(', ')}"
1696
+ warn "Set them in android/key.properties or the matching RUFLET_ANDROID_* variables."
1697
+ return
1698
+ end
1699
+
1700
+ body = ANDROID_SIGNING_KEYS.keys.map { |key| "#{key}=#{properties.fetch(key)}" }.join("\n")
1701
+ write_text_file(destination, "#{body}\n")
1702
+ build_log(verbose, "wrote #{destination}")
1703
+ build_note("Android release signing configured from #{properties.fetch("_source")}")
1704
+ end
1705
+
1706
+ def android_signing_properties
1707
+ from_env = ANDROID_SIGNING_KEYS.each_with_object({}) do |(key, variable), out|
1708
+ value = ENV[variable].to_s.strip
1709
+ out[key] = value unless value.empty?
1710
+ end
1711
+ unless from_env.empty?
1712
+ from_env["storeFile"] = File.expand_path(from_env["storeFile"]) if from_env["storeFile"]
1713
+ return from_env.merge("_source" => "the RUFLET_ANDROID_* environment")
1714
+ end
1715
+
1716
+ source = project_android_key_properties_path
1717
+ return {} unless source
1718
+
1719
+ parsed = read_text_file(source).each_line.with_object({}) do |line, out|
1720
+ next if line.strip.empty? || line.strip.start_with?("#")
1721
+
1722
+ key, value = line.split("=", 2)
1723
+ out[key.to_s.strip] = value.to_s.strip unless value.nil?
1724
+ end
1725
+ return {} if parsed.empty?
1726
+
1727
+ # storeFile is written relative to the project; the client sits deeper.
1728
+ if parsed["storeFile"] && !Pathname.new(parsed["storeFile"]).absolute?
1729
+ parsed["storeFile"] = File.expand_path(parsed["storeFile"], File.dirname(source))
1730
+ end
1731
+ parsed.merge("_source" => source)
1732
+ end
1733
+
1734
+ def project_android_key_properties_path
1735
+ [
1736
+ File.join(Dir.pwd, "android", "key.properties"),
1737
+ File.join(Dir.pwd, "key.properties")
1738
+ ].find { |path| File.file?(path) }
1739
+ end
1740
+
1539
1741
  def apply_native_service_permissions(client_dir, config)
1540
1742
  entries = configured_service_entries(config)
1541
1743
  configured_extensions = Array(config["extensions"]).filter_map { |entry| normalize_extension_key(entry) }
@@ -1708,7 +1910,10 @@ module Ruflet
1708
1910
  "lib/connection_probe_io.dart",
1709
1911
  "lib/connection_probe_stub.dart",
1710
1912
  "ios/Podfile",
1711
- "windows/CMakeLists.txt"
1913
+ "windows/CMakeLists.txt",
1914
+ # Release signing lives here; an existing client would otherwise keep
1915
+ # signing release builds with the debug key.
1916
+ "android/app/build.gradle.kts"
1712
1917
  ]
1713
1918
 
1714
1919
  managed_files.each do |relative_path|
@@ -1848,7 +2053,7 @@ module Ruflet
1848
2053
  # app, so signing keys and local environment files must never be copied
1849
2054
  # in even when a project keeps them beside its source.
1850
2055
  SECRET_ASSET_EXTENSIONS = %w[.p8 .p12 .pem .key .jks .keystore .mobileprovision].freeze
1851
- SECRET_ASSET_BASENAMES = %w[.env .netrc].freeze
2056
+ SECRET_ASSET_BASENAMES = %w[.env .netrc key.properties].freeze
1852
2057
 
1853
2058
  def secret_project_asset?(relative)
1854
2059
  basename = File.basename(relative)
@@ -2145,6 +2350,8 @@ module Ruflet
2145
2350
  ["build", "appbundle"]
2146
2351
  when "ios"
2147
2352
  ["build", "ios"]
2353
+ when "ipa"
2354
+ ["build", "ipa"]
2148
2355
  when "web"
2149
2356
  ["build", "web"]
2150
2357
  when "macos"
@@ -205,10 +205,16 @@ module Ruflet
205
205
  git_revision(File.expand_path("../..", template_root))
206
206
  end
207
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.
208
213
  def write_client_template_revision(target, revision)
209
- return unless revision
214
+ value = revision.to_s.strip
215
+ value = "local" if value.empty?
210
216
 
211
- File.write(File.join(target, ".ruflet-template-revision"), "#{revision}\n")
217
+ File.write(File.join(target, ".ruflet-template-revision"), "#{value}\n")
212
218
  end
213
219
 
214
220
  def client_template_current?(target, template_root)
@@ -300,13 +300,12 @@ module Ruflet
300
300
  end
301
301
  end
302
302
 
303
- # The backend serves the web client on its own port, so the client loads
304
- # and opens its websocket on the same origin. The explicit url parameter
305
- # is kept because prebuilt production clients have no default URL and use
306
- # it to select the websocket transport.
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.
307
306
  def launch_web_client(port)
308
307
  backend_url = "http://localhost:#{port}"
309
- web_url = "#{backend_url}/?#{URI.encode_www_form(url: backend_url)}"
308
+ web_url = "#{backend_url}/"
310
309
  browser_pid = open_in_browser_app_mode(web_url)
311
310
  open_in_browser(web_url) if browser_pid.nil?
312
311
  puts "Ruflet web client: #{web_url}"
@@ -603,23 +602,35 @@ module Ruflet
603
602
  nil
604
603
  end
605
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.
606
610
  def prebuilt_assets_present?(root, web:, desktop:, platform: nil)
607
- ok_web = !web || File.file?(File.join(root, "web", "index.html"))
611
+ ok_web = !web || built_web_client_dir?(File.join(root, "web"))
608
612
  ok_desktop = !desktop || prebuilt_desktop_present?(root, platform: platform)
609
613
  ok_web && ok_desktop
610
614
  end
611
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.
612
620
  def prebuilt_desktop_present?(root, platform: nil)
613
621
  platform ||= host_platform_name
614
622
  return false if platform.nil?
615
623
 
624
+ desktop = File.join(root, "desktop")
625
+ return false unless Dir.exist?(desktop)
626
+
616
627
  case platform
617
628
  when "macos"
618
- 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) }
619
630
  when "linux"
620
- File.file?(File.join(root, "desktop", "ruflet_client"))
631
+ Dir.children(desktop).any? { |entry| executable_file?(File.join(desktop, entry)) }
621
632
  when "windows"
622
- File.file?(File.join(root, "desktop", "ruflet_client.exe"))
633
+ Dir.glob(File.join(desktop, "*.exe")).any? { |path| File.file?(path) }
623
634
  else
624
635
  false
625
636
  end
@@ -823,7 +834,7 @@ module Ruflet
823
834
  return if read_client_manifest(root)
824
835
 
825
836
  assets = []
826
- 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"))
827
838
  if prebuilt_desktop_present?(root, platform: platform)
828
839
  assets << { "kind" => "desktop", "platform" => platform, "asset_name" => nil }
829
840
  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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ruflet
4
- VERSION = "0.0.20" unless const_defined?(:VERSION)
4
+ VERSION = "0.0.21" 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.20
4
+ version: 0.0.21
5
5
  platform: ruby
6
6
  authors:
7
7
  - AdamMusa