ruby_everywhere 0.6.0 → 0.7.0

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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/exe/every +12 -1
  3. data/exe/rbe +12 -1
  4. data/lib/everywhere/agents_guide.rb +3 -1
  5. data/lib/everywhere/blake2b.rb +17 -1
  6. data/lib/everywhere/boot.rb +21 -4
  7. data/lib/everywhere/builders/android.rb +144 -30
  8. data/lib/everywhere/builders/desktop.rb +25 -17
  9. data/lib/everywhere/builders/ios.rb +244 -9
  10. data/lib/everywhere/cli.rb +2 -0
  11. data/lib/everywhere/commands/build.rb +113 -58
  12. data/lib/everywhere/commands/clean.rb +8 -3
  13. data/lib/everywhere/commands/dev.rb +85 -11
  14. data/lib/everywhere/commands/doctor.rb +138 -21
  15. data/lib/everywhere/commands/install.rb +48 -8
  16. data/lib/everywhere/commands/platform/build.rb +136 -27
  17. data/lib/everywhere/commands/platform/login.rb +16 -2
  18. data/lib/everywhere/commands/platform/runner.rb +113 -23
  19. data/lib/everywhere/commands/preview.rb +359 -0
  20. data/lib/everywhere/commands/publish.rb +20 -2
  21. data/lib/everywhere/commands/release.rb +145 -28
  22. data/lib/everywhere/commands/shell_dir.rb +2 -2
  23. data/lib/everywhere/config.rb +88 -3
  24. data/lib/everywhere/console.rb +2 -1
  25. data/lib/everywhere/engine.rb +24 -0
  26. data/lib/everywhere/framework.rb +21 -4
  27. data/lib/everywhere/ignore.rb +3 -1
  28. data/lib/everywhere/jump.rb +121 -0
  29. data/lib/everywhere/mobile_config_endpoint.rb +47 -0
  30. data/lib/everywhere/mobile_configs_controller.rb +46 -0
  31. data/lib/everywhere/paths.rb +13 -6
  32. data/lib/everywhere/platform/client.rb +27 -6
  33. data/lib/everywhere/platform/credentials.rb +18 -8
  34. data/lib/everywhere/platform/snapshot.rb +10 -0
  35. data/lib/everywhere/receipt.rb +55 -10
  36. data/lib/everywhere/shellout.rb +19 -0
  37. data/lib/everywhere/simulator.rb +12 -1
  38. data/lib/everywhere/version.rb +1 -1
  39. data/support/mobile/android/app/build.gradle.kts +29 -1
  40. data/support/mobile/ios/App/EverywhereConfig.swift +17 -5
  41. data/support/mobile/ios/App/SceneDelegate.swift +75 -8
  42. data/support/mobile/ios/App.xcodeproj/project.pbxproj +2 -2
  43. data/support/mobile/ios/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +0 -1
  44. data/support/mobile/ios/README.md +13 -6
  45. metadata +17 -1
@@ -15,30 +15,61 @@ module Everywhere
15
15
  # Xcode template, stamp it (xcconfig + everywhere.json + AppIcon; never
16
16
  # the pbxproj), and hand it to xcodebuild.
17
17
  #
18
- # MVP scope is the Simulator (ad-hoc signed, no certs). Device signing,
19
- # archive/.ipa export and store distribution hang off this same seam
20
- # later — see platform/docs/build-engine.md §5.
18
+ # Two products come out of the same stamped project: the Simulator .app
19
+ # (ad-hoc signed, no certs) and, with device: true, a signed .ipa via
20
+ # xcodebuild archive + -exportArchive — see platform/docs/build-engine.md §5.
21
21
  class Ios
22
22
  # Fixed by the template contract: the target/product is always "App"
23
23
  # (App.app), so build paths never depend on per-app values.
24
24
  SCHEME = "App"
25
25
 
26
- def initialize(config:, root:, target:, template_dir: nil)
26
+ # Device signing arrives through the environment, never everywhere.yml:
27
+ # the platform runner pulls the certificate and profile just-in-time and
28
+ # nothing lands in the app repo.
29
+ REQUIRED_SIGNING_ENV = {
30
+ "EVERY_IOS_TEAM_ID" => "the 10-character Apple Developer team id",
31
+ "EVERY_IOS_PROVISIONING_PROFILE" => "the provisioning profile NAME (not a path or UUID)"
32
+ }.freeze
33
+
34
+ EXPORT_METHODS = %w[app-store-connect app-store ad-hoc development release-testing enterprise].freeze
35
+ DEFAULT_EXPORT_METHOD = "app-store-connect"
36
+
37
+ # Distribution channels whose artifact has to reach App Store Connect: the
38
+ # upload happens here, on the build host, because only this machine has the
39
+ # archive and Xcode.
40
+ STORE_CHANNELS = %w[testflight app_store].freeze
41
+
42
+ # The App Store Connect API key, under the names the platform runner
43
+ # already exports for notarytool, plus explicit aliases for local runs.
44
+ ASC_ENV = {
45
+ key_path: %w[NOTARY_KEY EVERY_ASC_KEY],
46
+ key_id: %w[NOTARY_KEY_ID EVERY_ASC_KEY_ID],
47
+ issuer_id: %w[NOTARY_ISSUER EVERY_ASC_ISSUER_ID]
48
+ }.freeze
49
+
50
+ def initialize(config:, root:, target:, template_dir: nil, channel: "direct")
27
51
  @config = config
28
52
  @root = root
29
53
  @target = target
30
54
  @template_dir = template_dir
55
+ @channel = channel.to_s
31
56
  end
32
57
 
33
58
  # Build the Simulator .app and copy it into dist_dir. `dev` builds Debug
34
59
  # and relaxes the remote-mode requirement (the dev URL arrives at launch
35
60
  # via SIMCTL_CHILD_EVERYWHERE_DEV_URL, not baked into the bundle).
36
- # Returns the path of the built .app.
37
- def build!(dist_dir:, dev: false)
38
- preflight!(dev: dev)
61
+ # `device` archives and exports a signed dist/<Name>.ipa instead.
62
+ # Returns the path of the built product.
63
+ def build!(dist_dir:, dev: false, device: false)
64
+ preflight!(dev: dev, device: device)
39
65
  template = @template_dir ? File.expand_path(@template_dir) : Paths.ios_dir!
40
66
  work = stage(template)
41
67
  stamp!(work)
68
+ if device
69
+ archive!(work, dist_dir)
70
+ return export!(work, dist_dir)
71
+ end
72
+
42
73
  configuration = dev ? "Debug" : "Release"
43
74
  xcodebuild!(work, configuration, dist_dir)
44
75
  collect(configuration, dist_dir)
@@ -49,18 +80,70 @@ module Everywhere
49
80
  def name = @config.name(target: @target)
50
81
  def bundle_id = @config.bundle_id(target: @target)
51
82
 
52
- def preflight!(dev:)
83
+ def preflight!(dev:, device:)
53
84
  unless dev || @config.remote?
54
85
  UI.die!("iOS apps are remote mode only for now — set remote.url in config/everywhere.yml " \
55
86
  "(the shell wraps your deployed app; nothing is packaged locally)")
56
87
  end
57
88
  UI.die!("iOS builds need macOS with Xcode installed") unless RUBY_PLATFORM.include?("darwin")
89
+ return device_preflight! if device
58
90
  return if Shellout.run?("xcrun", "--sdk", "iphonesimulator", "--show-sdk-path", quiet: true)
59
91
 
60
92
  UI.die!("no iOS Simulator SDK — install full Xcode (not just the Command Line Tools) " \
61
93
  "and run: xcode-select --switch /Applications/Xcode.app")
62
94
  end
63
95
 
96
+ # Signing is checked before anything is staged: xcodebuild only notices a
97
+ # missing team or profile minutes in, at the codesign step, and says so in
98
+ # its own vocabulary.
99
+ def device_preflight!
100
+ unless Shellout.run?("xcrun", "--sdk", "iphoneos", "--show-sdk-path", quiet: true)
101
+ UI.die!("no iOS device SDK — install full Xcode (not just the Command Line Tools) " \
102
+ "and run: xcode-select --switch /Applications/Xcode.app")
103
+ end
104
+
105
+ missing = REQUIRED_SIGNING_ENV.reject { |name, _| signing_env(name) }
106
+ unless missing.empty?
107
+ UI.die!("device builds need signing credentials in the environment:\n" \
108
+ "#{missing.map { |name, hint| " #{name} — #{hint}" }.join("\n")}")
109
+ end
110
+ unless EXPORT_METHODS.include?(export_method)
111
+ UI.die!("EVERY_IOS_EXPORT_METHOD=#{export_method} isn't an Xcode export method — " \
112
+ "use one of: #{EXPORT_METHODS.join(", ")}")
113
+ end
114
+ asc_preflight! if store_channel?
115
+ end
116
+
117
+ # A store channel that can't reach App Store Connect has to fail now: the
118
+ # archive it would otherwise produce takes minutes and is unusable.
119
+ def asc_preflight!
120
+ missing = ASC_ENV.reject { |field, _names| asc_env(field) }
121
+ .map { |_field, names| names.first }
122
+ unless missing.empty?
123
+ UI.die!("--channel #{@channel} uploads to App Store Connect, which needs an API key " \
124
+ "in the environment — missing: #{missing.join(", ")}")
125
+ end
126
+ return if export_method == DEFAULT_EXPORT_METHOD
127
+
128
+ UI.die!("--channel #{@channel} uploads to App Store Connect, which only accepts " \
129
+ "#{DEFAULT_EXPORT_METHOD} builds — unset EVERY_IOS_EXPORT_METHOD=#{export_method}")
130
+ end
131
+
132
+ def signing_env(name)
133
+ value = ENV[name].to_s.strip
134
+ value.empty? ? nil : value
135
+ end
136
+
137
+ def asc_env(field) = ASC_ENV.fetch(field).filter_map { |name| signing_env(name) }.first
138
+
139
+ def store_channel? = STORE_CHANNELS.include?(@channel)
140
+ def upload_to_asc? = store_channel? && ASC_ENV.each_key.all? { |field| asc_env(field) }
141
+
142
+ def team_id = signing_env("EVERY_IOS_TEAM_ID")
143
+ def provisioning_profile = signing_env("EVERY_IOS_PROVISIONING_PROFILE")
144
+ def signing_identity = signing_env("APPLE_SIGNING_IDENTITY")
145
+ def export_method = signing_env("EVERY_IOS_EXPORT_METHOD") || DEFAULT_EXPORT_METHOD
146
+
64
147
  # Copy the template into the app's persistent work dir. Persistent (not
65
148
  # a tmpdir) so the project path is stable — Xcode's incremental state
66
149
  # stays valid and users can open the stamped project directly.
@@ -360,7 +443,7 @@ module Everywhere
360
443
  MARKETING_VERSION = #{@config.version(target: @target)}
361
444
  CURRENT_PROJECT_VERSION = 1
362
445
  INFOPLIST_KEY_CFBundleDisplayName = #{name}
363
- DEVELOPMENT_TEAM =
446
+ DEVELOPMENT_TEAM =#{" #{team_id}" if team_id}
364
447
  #{entitlements}
365
448
  XCCONFIG
366
449
  end
@@ -415,6 +498,158 @@ module Everywhere
415
498
  log: log, filter: LogFilter.for(:xcodebuild))
416
499
  end
417
500
 
501
+ # Archive for a real device. The signing settings are passed as
502
+ # command-line build settings because those override the project file —
503
+ # the template ships CODE_SIGN_STYLE = Automatic and the stamp-only
504
+ # contract forbids editing its pbxproj. CODE_SIGN_IDENTITY is left to
505
+ # xcodebuild ("Apple Distribution") unless the runner names one.
506
+ def archive!(work, dist_dir)
507
+ UI.step("archiving iOS app #{UI.dim("(xcodebuild archive, team #{team_id})")}")
508
+ FileUtils.mkdir_p(dist_dir)
509
+ FileUtils.rm_rf(archive_path(work))
510
+ Shellout.run_logged!({},
511
+ ["xcodebuild", "archive",
512
+ "-project", File.join(work, "App.xcodeproj"),
513
+ "-scheme", SCHEME,
514
+ "-configuration", "Release",
515
+ "-destination", "generic/platform=iOS",
516
+ "-archivePath", archive_path(work),
517
+ "-derivedDataPath", Paths.ios_derived_data_dir,
518
+ "-clonedSourcePackagesDirPath", Paths.ios_packages_dir,
519
+ "CODE_SIGN_STYLE=Manual",
520
+ "DEVELOPMENT_TEAM=#{team_id}",
521
+ "PROVISIONING_PROFILE_SPECIFIER=#{provisioning_profile}",
522
+ *("CODE_SIGN_IDENTITY=#{signing_identity}" if signing_identity)],
523
+ log: File.join(dist_dir, "ios-archive.log"), filter: LogFilter.for(:xcodebuild))
524
+ end
525
+
526
+ # Two passes over the same archive: the local .ipa (the artifact everything
527
+ # downstream expects) and, for a store channel, the upload to App Store
528
+ # Connect. Xcode won't do both from one -exportArchive — `destination` is
529
+ # either export or upload — and the .ipa must exist either way.
530
+ def export!(work, dist_dir)
531
+ FileUtils.mkdir_p(dist_dir)
532
+ # A receipt left by a previous run would otherwise read as proof that
533
+ # THIS build reached App Store Connect.
534
+ FileUtils.rm_f(upload_receipt_path(dist_dir))
535
+ ipa = export_local!(work, dist_dir)
536
+ upload_to_asc!(work, dist_dir) if upload_to_asc?
537
+ ipa
538
+ end
539
+
540
+ def export_local!(work, dist_dir)
541
+ export = File.join(work, "build", "export")
542
+ FileUtils.rm_rf(export)
543
+ UI.step("exporting .ipa #{UI.dim("(#{export_method}, profile #{provisioning_profile})")}")
544
+ Shellout.run_logged!({},
545
+ ["xcodebuild", "-exportArchive",
546
+ "-archivePath", archive_path(work),
547
+ "-exportOptionsPlist", write_export_options(work),
548
+ "-exportPath", export],
549
+ log: File.join(dist_dir, "ios-export.log"), filter: LogFilter.for(:xcodebuild))
550
+ collect_ipa(export, dist_dir)
551
+ end
552
+
553
+ # -allowProvisioningUpdates is what lets the auth-key trio stand in for a
554
+ # signed-in Xcode account; `destination: upload` in the plist is what makes
555
+ # this an upload rather than a second export.
556
+ def upload_to_asc!(work, dist_dir)
557
+ upload = File.join(work, "build", "upload")
558
+ FileUtils.rm_rf(upload)
559
+ UI.step("uploading to App Store Connect #{UI.dim("(#{@channel}, key #{asc_env(:key_id)})")}")
560
+ Shellout.run_logged!({},
561
+ ["xcodebuild", "-exportArchive",
562
+ "-archivePath", archive_path(work),
563
+ "-exportOptionsPlist", write_export_options(work, destination: "upload"),
564
+ "-exportPath", upload,
565
+ "-allowProvisioningUpdates",
566
+ "-authenticationKeyPath", asc_env(:key_path),
567
+ "-authenticationKeyID", asc_env(:key_id),
568
+ "-authenticationKeyIssuerID", asc_env(:issuer_id)],
569
+ log: File.join(dist_dir, "ios-upload.log"), filter: LogFilter.for(:xcodebuild))
570
+ write_upload_receipt(work, dist_dir)
571
+ end
572
+
573
+ def upload_receipt_path(dist_dir) = File.join(dist_dir, "ios-upload.json")
574
+
575
+ # The contract `every release` folds into the receipt's signing bag. Written
576
+ # only after the upload pass returned — run_logged! dies on failure — so the
577
+ # file existing IS the proof that App Store Connect accepted the build.
578
+ def write_upload_receipt(work, dist_dir)
579
+ require "json"
580
+ require "time"
581
+ File.write(upload_receipt_path(dist_dir), "#{JSON.pretty_generate(
582
+ "uploaded" => true,
583
+ "destination" => "app-store-connect",
584
+ "method" => export_method,
585
+ "channel" => @channel,
586
+ "build_number" => archive_build_number(work),
587
+ "uploaded_at" => Time.now.utc.iso8601
588
+ )}\n")
589
+ end
590
+
591
+ # What ASC will show as the build: the archive's own CFBundleVersion, which
592
+ # the upload pass is pinned to by manageAppVersionAndBuildNumber = false.
593
+ def archive_build_number(work)
594
+ out, status = Shellout.capture("plutil", "-extract", "ApplicationProperties.CFBundleVersion",
595
+ "raw", "-o", "-", File.join(archive_path(work), "Info.plist"))
596
+ status&.success? ? out.strip : nil
597
+ end
598
+
599
+ def archive_path(work) = File.join(work, "build", "App.xcarchive")
600
+
601
+ # `destination` picks the pass: "export" writes an .ipa, "upload" hands the
602
+ # archive to App Store Connect. manageAppVersionAndBuildNumber is
603
+ # LOAD-BEARING on the upload pass — left at its default Xcode silently bumps
604
+ # CFBundleVersion, and the uploaded build stops matching the stored .ipa.
605
+ # testFlightInternalTestingOnly is deliberately never emitted: it would
606
+ # permanently bar the build from external TestFlight and the App Store.
607
+ def write_export_options(work, destination: "export")
608
+ upload = destination == "upload"
609
+ path = File.join(work, "build", upload ? "ExportOptions-upload.plist" : "ExportOptions.plist")
610
+ managed = "\t<key>manageAppVersionAndBuildNumber</key>\n\t<false/>\n" if upload
611
+ FileUtils.mkdir_p(File.dirname(path))
612
+ File.write(path, <<~PLIST)
613
+ <?xml version="1.0" encoding="UTF-8"?>
614
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
615
+ <plist version="1.0">
616
+ <dict>
617
+ \t<key>method</key>
618
+ \t<string>#{export_method}</string>
619
+ \t<key>teamID</key>
620
+ \t<string>#{team_id}</string>
621
+ \t<key>signingStyle</key>
622
+ \t<string>manual</string>
623
+ \t<key>provisioningProfiles</key>
624
+ \t<dict>
625
+ \t\t<key>#{bundle_id}</key>
626
+ \t\t<string>#{xml_escape(provisioning_profile)}</string>
627
+ \t</dict>
628
+ \t<key>destination</key>
629
+ \t<string>#{destination}</string>
630
+ #{managed}\t<key>uploadSymbols</key>
631
+ \t<true/>
632
+ </dict>
633
+ </plist>
634
+ PLIST
635
+ path
636
+ end
637
+
638
+ # Profile names are free-form and come from outside the CLI, so this is
639
+ # the one interpolated plist value that can't be trusted as XML.
640
+ def xml_escape(value) = value.to_s.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
641
+
642
+ def collect_ipa(export, dist_dir)
643
+ built = File.join(export, "App.ipa")
644
+ built = Dir.glob(File.join(export, "*.ipa")).min unless File.file?(built)
645
+ UI.die!("xcodebuild reported success but no .ipa in #{UI.short_path(export)}") unless built && File.file?(built)
646
+
647
+ dest = File.join(dist_dir, "#{name}.ipa")
648
+ FileUtils.rm_f(dest)
649
+ FileUtils.cp(built, dest)
650
+ dest
651
+ end
652
+
418
653
  # Copy the product out of DerivedData. The ".simulator.app" suffix keeps
419
654
  # it visibly distinct from the desktop dist/<Name>.app (and out of
420
655
  # release.rb's dist/*.app glob).
@@ -6,6 +6,7 @@ require_relative "framework"
6
6
  require_relative "commands/build"
7
7
  require_relative "commands/clean"
8
8
  require_relative "commands/dev"
9
+ require_relative "commands/preview"
9
10
  require_relative "commands/logs"
10
11
  require_relative "commands/icon"
11
12
  require_relative "commands/doctor"
@@ -38,6 +39,7 @@ module Everywhere
38
39
  register "doctor", Commands::Doctor
39
40
  register "install", Commands::Install
40
41
  register "dev", Commands::Dev
42
+ register "preview", Commands::Preview
41
43
  register "logs", Commands::Logs
42
44
  register "build", Commands::Build
43
45
  register "clean", Commands::Clean
@@ -18,16 +18,16 @@ module Everywhere
18
18
  module Commands
19
19
  # Press the app into a single-file binary and make it runnable on this Mac.
20
20
  #
21
- # Encodes the Xcode 26 / macOS 26 workarounds discovered in the Phase 0 spike:
22
- # 1. CXX compiler shim (fmt consteval + folly __compressed_pair fixes)
23
- # 2. BUNDLE_FORCE_RUBY_PLATFORM so native gems compile from source
21
+ # Encodes the macOS 26 workarounds discovered in the Phase 0 spike:
22
+ # 1. BUNDLE_FORCE_RUBY_PLATFORM so native gems compile from source
24
23
  # (tebako's strip corrupts precompiled gems' signatures -> SIGKILL)
25
- # 3. Homebrew bison on PATH + LG_VADDR for tebako itself
26
- # 4. Post-press ad-hoc re-sign (macOS 26 rejects the pressed signature)
24
+ # 2. Homebrew bison on PATH + LG_VADDR for tebako itself
25
+ # 3. Post-press ad-hoc re-sign (macOS 26 rejects the pressed signature)
27
26
  class Build < Dry::CLI::Command
28
- SHIM = File.expand_path("~/.tebako-shims/clang++")
29
-
30
- DEFAULT_TARGET = "macos-arm64"
27
+ # Derived from the host, not fixed: on an Intel Mac the pressed binary is
28
+ # x86_64, and a hardcoded arm64 would put that lie in the bundle metadata,
29
+ # the release receipt and every platform override keyed off the target.
30
+ DEFAULT_TARGET = RUBY_PLATFORM.match?(/arm64|aarch64/) ? "macos-arm64" : "macos-x86_64"
31
31
  IOS_TARGET = "ios-arm64"
32
32
  # The APK/AAB is universal, so the arch half is nominal — it exists only
33
33
  # so every target reads as os-arch.
@@ -37,6 +37,8 @@ module Everywhere
37
37
  # mobile platform is one entry rather than a third copy of resolve_target.
38
38
  SHORTHAND_TARGETS = { ios: IOS_TARGET, android: ANDROID_TARGET }.freeze
39
39
 
40
+ ANDROID_FORMATS = %w[apk aab].freeze
41
+
40
42
  desc "Package the app natively — desktop (tebako press + shell + .app), iOS (--ios) or Android (--android)"
41
43
 
42
44
  option :root, default: ".", desc: "App root directory"
@@ -46,12 +48,17 @@ module Everywhere
46
48
  option :app_name, default: nil, desc: "Bundle name (default: capitalized app dir)"
47
49
  option :target, default: nil, desc: "Build target (os-arch) — selects any platforms: overrides, default: \"#{DEFAULT_TARGET}\""
48
50
  option :shell_dir, default: nil, desc: "Path to the shell's src-tauri directory"
51
+ option :template_dir, default: nil, desc: "Path to the mobile shell template (iOS/Android)"
52
+ option :format, default: nil, desc: "Android artifact format (#{ANDROID_FORMATS.join(" | ")}), default: apk"
53
+ option :channel, default: "direct", desc: "Distribution channel (direct | testflight | app_store | ...)"
49
54
  option :ios, type: :boolean, default: false, desc: "Build the iOS shell (shorthand for --target #{IOS_TARGET})"
50
55
  option :android, type: :boolean, default: false, desc: "Build the Android shell (shorthand for --target #{ANDROID_TARGET})"
56
+ option :device, type: :boolean, default: false, desc: "iOS: archive and export a device .ipa instead of a simulator .app"
51
57
  option :skip_press, type: :boolean, default: false, desc: "Reuse the existing pressed binary, only rebuild the .app"
52
58
 
53
59
  def call(root: ".", output: nil, ruby: "4.0.6", entry: "native_boot.rb",
54
- app_name: nil, target: nil, shell_dir: nil, ios: false, android: false, skip_press: false, **)
60
+ app_name: nil, target: nil, shell_dir: nil, template_dir: nil, format: nil,
61
+ channel: "direct", ios: false, android: false, device: false, skip_press: false, **)
55
62
  target = resolve_target(target, ios: ios, android: android)
56
63
  root_path = File.expand_path(root)
57
64
  config = Everywhere::Config.load(root_path)
@@ -61,11 +68,20 @@ module Everywhere
61
68
  # shells, each built by its own builder. Anything else has no builder yet.
62
69
  case Everywhere::Config.os_of(target)
63
70
  when "ios", "android"
64
- app = mobile_builder(target).new(config: config, root: root_path, target: target)
65
- .build!(dist_dir: File.expand_path("dist", root_path))
71
+ # --output names a file for the pressed desktop binary, but a mobile
72
+ # build's product is whatever the platform toolchain emits — so here it
73
+ # names the directory those products land in.
74
+ dist_dir = output ? File.expand_path(output) : File.expand_path("dist", root_path)
75
+ app = mobile_builder(target).new(config: config, root: root_path, target: target,
76
+ template_dir: template_dir, **channel_option(target, channel))
77
+ .build!(dist_dir: dist_dir, **mobile_options(target, device: device, format: format))
66
78
  UI.success("built #{rel(app, root_path)}")
67
79
  return
68
- when "macos" # the rest of this method
80
+ when "macos" # ...which is the rest of this method
81
+ # Every step of it ends in codesign and an .app bundle, so a Linux
82
+ # host can only fail — the question is whether it fails here or
83
+ # thirty minutes in, at the first codesign.
84
+ UI.die!("desktop builds are macOS-only for now") unless RUBY_PLATFORM.include?("darwin")
69
85
  else
70
86
  UI.die!("no builder for target #{target} — macos-*, ios-* and android-* are supported")
71
87
  end
@@ -88,7 +104,7 @@ module Everywhere
88
104
  # required locally, nothing to press — just shell + config in a bundle.
89
105
  if config.remote?
90
106
  UI.step("#{UI.bold("remote")} mode → #{UI.cyan(config.remote_url)} #{UI.dim("(no local server packaged)")}")
91
- app_dir = bundle_app(nil, app_name, shell_dir, config, target: target, dist_dir: File.expand_path("dist", root_path))
107
+ bundle_app(nil, app_name, shell_dir, config, target: target, dist_dir: File.expand_path("dist", root_path))
92
108
  return
93
109
  end
94
110
 
@@ -114,7 +130,7 @@ module Everywhere
114
130
  press!(framework, entry, ruby, output_path)
115
131
  end
116
132
 
117
- bundle_app(output_path, app_name, shell_dir, config, target: target) if RUBY_PLATFORM.include?("darwin")
133
+ bundle_app(output_path, app_name, shell_dir, config, target: target)
118
134
 
119
135
  UI.success("built #{rel(output_path, root_path)} (#{(File.size(output_path) / 1024.0 / 1024.0).round}MB)")
120
136
  end
@@ -128,6 +144,34 @@ module Everywhere
128
144
  Everywhere::Config.os_of(target) == "android" ? Builders::Android : Builders::Ios
129
145
  end
130
146
 
147
+ # Only iOS distributes from the build host — the store upload rides along
148
+ # with the archive that's already on this machine. Android's Play upload
149
+ # happens platform-side, so its builder has no channel to know about.
150
+ def channel_option(target, channel)
151
+ Everywhere::Config.os_of(target) == "android" ? {} : { channel: channel }
152
+ end
153
+
154
+ # Each mobile builder takes the one flag that only makes sense for it, so
155
+ # `--format aab` on an iOS build is a mistake we name rather than drop.
156
+ def mobile_options(target, device:, format:)
157
+ if Everywhere::Config.os_of(target) == "android"
158
+ UI.die!("--device is an iOS flag — Android always builds an installable artifact") if device
159
+ { format: android_format(format) }
160
+ else
161
+ UI.die!("--format is an Android flag (#{ANDROID_FORMATS.join("/")}) — iOS builds a .app or .ipa") if format
162
+ { device: device }
163
+ end
164
+ end
165
+
166
+ def android_format(format)
167
+ return :apk if format.nil?
168
+
169
+ unless ANDROID_FORMATS.include?(format.to_s)
170
+ UI.die!("--format must be one of #{ANDROID_FORMATS.join(", ")} (got #{format})")
171
+ end
172
+ format.to_s.to_sym
173
+ end
174
+
131
175
  # --ios/--android are shorthand for their target, and disagreeing with an
132
176
  # explicit --target is a mistake worth naming rather than silently
133
177
  # resolving one way: both spellings were deliberate, so we can't know
@@ -173,6 +217,7 @@ module Everywhere
173
217
  end
174
218
 
175
219
  def press!(framework, entry, ruby, output_path)
220
+ bison_prefix! # fail here, not forty minutes deep in tebako's log
176
221
  ensure_gems!(framework) if framework.precompile?
177
222
 
178
223
  UI.step("precompiling assets")
@@ -184,21 +229,24 @@ module Everywhere
184
229
  # into the new binary. dist/ is ours — clear it.
185
230
  FileUtils.rm_rf(out_dir) if out_dir.start_with?(framework.root + File::SEPARATOR)
186
231
  FileUtils.mkdir_p(out_dir)
187
- # Press into a staging dir OUTSIDE the app root for the same reason
188
- # (tebako prints a big warning about exactly this).
189
- staging = File.join(Dir.mktmpdir("everywhere-press"), File.basename(output_path))
190
232
  log = "#{output_path}.build.log"
191
233
 
192
234
  UI.step("rbe-tebako press #{UI.dim("(Ruby #{ruby} — grab a coffee on first run)")}")
193
235
  UI.detail("full build log: #{UI.short_path(log)}")
194
- Shellout.run_logged!(press_env, ["rbe-tebako", "press",
195
- "--root=#{framework.root}", "--entry-point=#{entry}",
196
- "--Ruby=#{ruby}", "--output=#{staging}"], log: log, filter: LogFilter.for(:tebako))
197
-
198
- # Tebako's final strip step only warns (and still exits 0) if it can't
199
- # write the output, so verify the artifact instead of trusting exit codes.
200
- UI.die!("tebako reported success but no binary was created") unless File.exist?(staging)
201
- FileUtils.mv(staging, output_path)
236
+ # Press into a staging dir OUTSIDE the app root for the same reason
237
+ # (tebako prints a big warning about exactly this). Block form: the
238
+ # staging copy is a 100MB+ binary, and a failed press never reaches the mv.
239
+ Dir.mktmpdir("everywhere-press") do |staging_dir|
240
+ staging = File.join(staging_dir, File.basename(output_path))
241
+ Shellout.run_logged!(press_env, ["rbe-tebako", "press",
242
+ "--root=#{framework.root}", "--entry-point=#{entry}",
243
+ "--Ruby=#{ruby}", "--output=#{staging}"], log: log, filter: LogFilter.for(:tebako))
244
+
245
+ # Tebako's final strip step only warns (and still exits 0) if it can't
246
+ # write the output, so verify the artifact instead of trusting exit codes.
247
+ UI.die!("tebako reported success but no binary was created") unless File.exist?(staging)
248
+ FileUtils.mv(staging, output_path)
249
+ end
202
250
 
203
251
  UI.step("re-signing for macOS")
204
252
  Shellout.run!({}, "codesign", "--remove-signature", output_path)
@@ -215,11 +263,16 @@ module Everywhere
215
263
  # Assemble dist/<App Name>.app: the release shell binary + the pressed
216
264
  # Rails binary (as "app-server") + Info.plist + icon. An .app bundle is
217
265
  # just a directory layout, so we build it by hand — no tauri-cli needed.
218
- def bundle_app(server_binary, app_name, shell_dir, config, target: "macos-arm64", dist_dir: nil)
219
- # An app with native.desktop gets a stamped per-app shell; everyone else
220
- # compiles the gem's copy against the shared target dir, exactly as
221
- # before. Builders::Desktop decides which, and the rest of this method
222
- # doesn't need to know.
266
+ def bundle_app(server_binary, app_name, shell_dir, config, target: DEFAULT_TARGET, dist_dir: nil)
267
+ Shellout.ensure_tool!("cargo", "the desktop shell is built with Rust install it from https://rustup.rs")
268
+
269
+ # cargo always runs in a STAGED copy of the shell under
270
+ # ~/.rubyeverywhere, never in the gem (or in whatever --shell-dir
271
+ # points at): the icon swap below and cargo's own Cargo.lock would
272
+ # otherwise write into the installed gem — Errno::EACCES on a
273
+ # system-wide install, and one app's branding in the next app's build.
274
+ # An app with native.desktop additionally gets its Rust stamped in and a
275
+ # private target dir; the rest of this method doesn't need to know which.
223
276
  prepared = Builders::Desktop.new(config: config, root: config.root, template_dir: shell_dir).prepare!
224
277
  shell_dir = prepared.dir
225
278
  dist_dir ||= File.dirname(server_binary)
@@ -249,21 +302,15 @@ module Everywhere
249
302
 
250
303
  # Tauri bakes icons/icon.png into the shell binary and sets it as the
251
304
  # Dock icon at runtime (stomping the bundle's icns after launch), so the
252
- # shell must be COMPILED with the app's icon. Swap in the shaped master
253
- # for the build, restore the placeholder after; touch build.rs so
254
- # tauri-build re-embeds.
255
- #
256
- # The restore only matters on the fast path, where shell_dir is the
257
- # INSTALLED GEM and every app shares it — leaving one app's icon there
258
- # would brand the next app's build. A stamped work dir belongs to this
259
- # app alone, so its icon can stay: restoring would only force another
260
- # re-embed on the next build.
305
+ # shell must be COMPILED with the app's icon. Swap the shaped master into
306
+ # the staged copy and touch build.rs so tauri-build re-embeds. Nothing to
307
+ # restore: staging re-seeds the copy from the template on every build, so
308
+ # the placeholder comes back by itself when app.icon goes away.
261
309
  shell_icon = File.join(shell_dir, "icons", "icon.png")
262
- placeholder_backup = "#{shell_icon}.placeholder"
263
310
  if icon_source
264
- FileUtils.cp(shell_icon, placeholder_backup) unless File.exist?(placeholder_backup)
265
311
  # The shaped master is already RGBA; fall back to raw RGBA conversion
266
- # if shaping failed, and to the placeholder if even that fails.
312
+ # if shaping failed. ensure_rgba refuses before writing, so a failure
313
+ # leaves the staged placeholder untouched.
267
314
  dock_ok = if macos_icon == icon_source
268
315
  Everywhere::PNG.ensure_rgba(icon_source, shell_icon)
269
316
  else
@@ -272,21 +319,13 @@ module Everywhere
272
319
  end
273
320
  unless dock_ok
274
321
  UI.warn "couldn't convert #{File.basename(icon_source)} to RGBA (unusual PNG flavor) — Dock icon will use the placeholder after launch"
275
- FileUtils.cp(placeholder_backup, shell_icon)
276
322
  end
277
323
  FileUtils.touch(File.join(shell_dir, "build.rs"))
278
324
  end
279
325
 
280
326
  UI.step("building shell #{UI.dim(prepared.stamped? ? "(cargo release, per-app)" : "(cargo release)")}")
281
327
  target_dir = prepared.target_dir
282
- begin
283
- Shellout.run!({ "CARGO_TARGET_DIR" => target_dir }, "cargo", "build", "--release", chdir: shell_dir)
284
- ensure
285
- if !prepared.stamped? && icon_source && File.exist?(placeholder_backup)
286
- FileUtils.mv(placeholder_backup, shell_icon)
287
- FileUtils.touch(File.join(shell_dir, "build.rs"))
288
- end
289
- end
328
+ Shellout.run!({ "CARGO_TARGET_DIR" => target_dir }, "cargo", "build", "--release", chdir: shell_dir)
290
329
  shell_bin = Dir[File.join(target_dir, "release/*")]
291
330
  .find { |f| File.file?(f) && File.executable?(f) && !f.end_with?(".d") }
292
331
  UI.die!("couldn't find release shell binary in #{target_dir}/release") unless shell_bin
@@ -312,7 +351,7 @@ module Everywhere
312
351
  UI.warn "splash #{splash} not found — using the default splash"
313
352
  end
314
353
  end
315
- icns = make_icns(macos_icon || File.join(shell_dir, "icons", "icon.png"), resources)
354
+ icns = make_icns(macos_icon || shell_icon, resources)
316
355
  File.write(File.join(app_dir, "Contents", "Info.plist"),
317
356
  info_plist(app_name, exe_name, icns, config.bundle_id(target: target), config.version(target: target)))
318
357
 
@@ -404,14 +443,30 @@ module Everywhere
404
443
  end
405
444
 
406
445
  def press_env
407
- env = {
446
+ {
408
447
  "BUNDLE_FORCE_RUBY_PLATFORM" => "true",
409
- "LG_VADDR" => "39"
448
+ "LG_VADDR" => "39",
449
+ "PATH" => "#{bison_prefix!}/bin:#{ENV.fetch("PATH")}"
410
450
  }
411
- env["CXX"] = SHIM if File.exist?(SHIM)
412
- bison = `brew --prefix bison 2>/dev/null`.strip
413
- env["PATH"] = "#{bison}/bin:#{ENV.fetch("PATH")}" unless bison.empty?
414
- env
451
+ end
452
+
453
+ # tebako builds Ruby from source and macOS still ships bison 2.3 (2006),
454
+ # which fails deep inside that build with no hint of the cause. Homebrew's
455
+ # is the only one that works, so a missing one is a hard stop rather than
456
+ # a silently dropped PATH entry.
457
+ def bison_prefix!
458
+ @bison_prefix ||= brew_bison_prefix ||
459
+ UI.die!("tebako needs Homebrew bison — brew install bison")
460
+ end
461
+
462
+ def brew_bison_prefix
463
+ return nil unless Shellout.tool?("brew")
464
+
465
+ prefix, status = Shellout.capture("brew", "--prefix", "bison")
466
+ prefix = prefix.to_s.strip
467
+ # `brew --prefix <formula>` answers with the opt path whether or not the
468
+ # formula is installed, so the bin dir has to be checked too.
469
+ prefix if status&.success? && !prefix.empty? && File.directory?(File.join(prefix, "bin"))
415
470
  end
416
471
 
417
472
  end
@@ -6,9 +6,9 @@ require "fileutils"
6
6
  module Everywhere
7
7
  module Commands
8
8
  # Wipes the shared build caches under ~/.rubyeverywhere (or
9
- # $RUBYEVERYWHERE_HOME): the cargo shell cache, the iOS DerivedData / SPM /
10
- # stamped-project dirs, and the Android Gradle home / icon font / stamped
11
- # projects. Safe to run any time: everything here is derived output, so the
9
+ # $RUBYEVERYWHERE_HOME): the cargo shell cache, the staged desktop shells
10
+ # and their dev staging dirs, the iOS DerivedData / SPM / stamped-project
11
+ # dirs, and the Android Gradle home / icon font / stamped projects. Safe to run any time: everything here is derived output, so the
12
12
  # next `every dev`/`every build` rebuilds it — cold, and for Android that
13
13
  # includes re-downloading Gradle and the Material icon font.
14
14
  class Clean < Dry::CLI::Command
@@ -16,6 +16,11 @@ module Everywhere
16
16
 
17
17
  def call(**)
18
18
  caches = [Everywhere::Paths.cargo_target_dir,
19
+ File.join(Everywhere::Paths.cache_dir, "desktop"),
20
+ File.join(Everywhere::Paths.cache_dir, "desktop-target"),
21
+ File.join(Everywhere::Paths.cache_dir, "desktop-app"),
22
+ File.join(Everywhere::Paths.cache_dir, "desktop-icon"),
23
+ File.join(Everywhere::Paths.cache_dir, "desktop-assets"),
19
24
  Everywhere::Paths.ios_derived_data_dir,
20
25
  Everywhere::Paths.ios_packages_dir,
21
26
  File.join(Everywhere::Paths.cache_dir, "ios"),