Class: Everywhere::Commands::Build

Inherits:
Dry::CLI::Command
  • Object
show all
Defined in:
lib/everywhere/commands/build.rb

Overview

Press the app into a single-file binary and make it runnable on this Mac.

Encodes the macOS 26 workarounds discovered in the Phase 0 spike:

1. BUNDLE_FORCE_RUBY_PLATFORM so native gems compile from source
 (tebako's strip corrupts precompiled gems' signatures -> SIGKILL)
2. Homebrew bison on PATH + LG_VADDR for tebako itself
3. Post-press ad-hoc re-sign (macOS 26 rejects the pressed signature)

Constant Summary collapse

DEFAULT_TARGET =

Derived from the host, not fixed: on an Intel Mac the pressed binary is x86_64, and a hardcoded arm64 would put that lie in the bundle metadata, the release receipt and every platform override keyed off the target.

RUBY_PLATFORM.match?(/arm64|aarch64/) ? "macos-arm64" : "macos-x86_64"
IOS_TARGET =
"ios-arm64"
ANDROID_TARGET =

The APK/AAB is universal, so the arch half is nominal — it exists only so every target reads as os-arch.

"android-arm64"
SHORTHAND_TARGETS =

The --ios/--android shorthands and the os they stand for, so adding a mobile platform is one entry rather than a third copy of resolve_target.

{ ios: IOS_TARGET, android: ANDROID_TARGET }.freeze
ANDROID_FORMATS =
%w[apk aab].freeze
CHANNELS =

Valid --channel per target os — the pairing the Platform accepts, minus nothing: macOS has no store channel (every macOS release signs with Developer ID and notarizes regardless), iOS direct exports an .ipa without the App Store Connect upload, and Android's play selects the .aab path even though the Play upload itself happens platform-side.

{
  "macos"   => %w[direct],
  "ios"     => %w[direct testflight app_store],
  "android" => %w[direct play]
}.freeze

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.check_channel!(os, channel) ⇒ Object

An os/channel pair nothing implements has to be refused before the build runs — it used to land in the receipt, where the only thing reading it (a store upload, a Platform distributor) has no idea what it means. Unknown os falls through: builder dispatch owns that error.



59
60
61
62
63
64
# File 'lib/everywhere/commands/build.rb', line 59

def self.check_channel!(os, channel)
  allowed = CHANNELS[os] || return
  return if allowed.include?(channel)

  UI.die!("unsupported --channel #{channel} for #{os} — one of #{allowed.join(", ")}")
end

Instance Method Details

#call(root: ".", output: nil, ruby: "4.0.6", entry: "native_boot.rb", app_name: nil, target: nil, shell_dir: nil, template_dir: nil, format: nil, channel: "direct", ios: false, android: false, device: false, skip_press: false) ⇒ Object



83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
# File 'lib/everywhere/commands/build.rb', line 83

def call(root: ".", output: nil, ruby: "4.0.6", entry: "native_boot.rb",
         app_name: nil, target: nil, shell_dir: nil, template_dir: nil, format: nil,
         channel: "direct", ios: false, android: false, device: false, skip_press: false, **)
  target = resolve_target(target, ios: ios, android: android)
  self.class.check_channel!(Everywhere::Config.os_of(target), channel)
  root_path = File.expand_path(root)
  config = Everywhere::Config.load(root_path)

  # Before any builder runs: the bundle id names the work dirs they wipe
  # and recreate, so a malformed one is a wrong path, not a wrong label.
  identity_errors = config.identity_errors
  UI.die!("app: in config/everywhere.yml:\n#{identity_errors.join("\n")}") unless identity_errors.empty?

  check_updates!(config)

  # Builder dispatch on the target's os (build-engine.md §5): macOS is
  # the tebako+tauri path below; the mobile targets are Hotwire Native
  # shells, each built by its own builder. Anything else has no builder yet.
  case Everywhere::Config.os_of(target)
  when "ios", "android"
    # --output names a file for the pressed desktop binary, but a mobile
    # build's product is whatever the platform toolchain emits — so here it
    # names the directory those products land in.
    dist_dir = output ? File.expand_path(output) : File.expand_path("dist", root_path)
    app = mobile_builder(target).new(config: config, root: root_path, target: target,
                                     template_dir: template_dir, **channel_option(target, channel))
                                .build!(dist_dir: dist_dir, **mobile_options(target, device: device, format: format))
    UI.success("built #{UI.rel_path(app, root_path)}")
    return
  when "macos" # ...which is the rest of this method
    # Every step of it ends in codesign and an .app bundle, so a Linux
    # host can only fail — the question is whether it fails here or
    # thirty minutes in, at the first codesign.
    UI.die!("desktop builds are macOS-only for now") unless Host.darwin?
  else
    UI.die!("no builder for target #{target} — macos-*, ios-* and android-* are supported")
  end

  # Desktop chrome is validated here rather than in the shell: a typo in
  # `window:` would otherwise fall through Rust's match arms as a silent
  # default, and you'd learn about it by squinting at a title bar after a
  # full press+compile. The mobile builders validate their own sections
  # the same way (Builders::Ios#write_extensions).
  window_errors = config.window_errors
  unless window_errors.empty?
    UI.die!("window: in config/everywhere.yml:\n#{window_errors.join("\n")}")
  end

  # The os of `target` selects any per-platform overrides (bundle id,
  # version, name) — local builds always target the host, macOS today.
  app_name ||= config.name(target: target)

  # Remote mode: thin shell around an already-deployed app. No Rails app
  # required locally, nothing to press — just shell + config in a bundle.
  if config.remote?
    UI.step("#{UI.bold("remote")} mode → #{UI.cyan(config.remote_url)} #{UI.dim("(no local server packaged)")}")
    bundle_app(nil, app_name, shell_dir, config, target: target, dist_dir: File.expand_path("dist", root_path))
    return
  end

  framework = Framework.detect(root_path)
  # dist/ belongs to the APP, not to the shell's cwd: `every platform
  # runner` invokes us with --root <snapshot> from its own working
  # directory, and `every release` looks for the .app under <root>/dist.
  # Resolving a relative default against Dir.pwd scattered the build
  # products into the runner's checkout instead.
  output_path = output ? File.expand_path(output) : File.join(root_path, "dist", File.basename(root_path))
  entry_path = File.join(framework.root, entry)

  unless File.exist?(entry_path)
    UI.die!("#{entry} not found in #{framework.root} — the app needs a packaged-boot entry point")
  end

  UI.step("#{UI.bold(framework.name)} app at #{framework.root}")

  if skip_press
    UI.die!("--skip-press given but #{UI.rel_path(output_path, root_path)} doesn't exist") unless File.exist?(output_path)
    UI.step("reusing pressed binary #{UI.rel_path(output_path, root_path)}")
  else
    press!(framework, entry, ruby, output_path)
  end

  bundle_app(output_path, app_name, shell_dir, config, target: target)

  UI.success("built #{UI.rel_path(output_path, root_path)} (#{(File.size(output_path) / 1024.0 / 1024.0).round}MB)")
end