ruby_everywhere 0.6.0 → 0.8.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 (81) hide show
  1. checksums.yaml +4 -4
  2. data/exe/every +2 -2
  3. data/exe/rbe +2 -2
  4. data/lib/everywhere/agents_guide.rb +3 -1
  5. data/lib/everywhere/blake2b.rb +17 -1
  6. data/lib/everywhere/boot.rb +30 -6
  7. data/lib/everywhere/builders/android.rb +152 -64
  8. data/lib/everywhere/builders/base.rb +53 -0
  9. data/lib/everywhere/builders/desktop.rb +32 -40
  10. data/lib/everywhere/builders/ios.rb +268 -36
  11. data/lib/everywhere/builders/native_sources.rb +38 -0
  12. data/lib/everywhere/child_processes.rb +4 -4
  13. data/lib/everywhere/child_supervision.rb +172 -0
  14. data/lib/everywhere/cli.rb +2 -0
  15. data/lib/everywhere/clock.rb +12 -0
  16. data/lib/everywhere/commands/build.rb +132 -66
  17. data/lib/everywhere/commands/clean.rb +8 -3
  18. data/lib/everywhere/commands/dev.rb +91 -242
  19. data/lib/everywhere/commands/doctor.rb +138 -21
  20. data/lib/everywhere/commands/install.rb +49 -8
  21. data/lib/everywhere/commands/platform/auth_status.rb +1 -0
  22. data/lib/everywhere/commands/platform/build.rb +141 -33
  23. data/lib/everywhere/commands/platform/login.rb +20 -6
  24. data/lib/everywhere/commands/platform/logout.rb +1 -0
  25. data/lib/everywhere/commands/platform/runner.rb +170 -25
  26. data/lib/everywhere/commands/preview.rb +286 -0
  27. data/lib/everywhere/commands/publish.rb +22 -2
  28. data/lib/everywhere/commands/release.rb +155 -35
  29. data/lib/everywhere/commands/shell_dir.rb +4 -2
  30. data/lib/everywhere/commands/updates_keygen.rb +25 -1
  31. data/lib/everywhere/config/app.rb +126 -0
  32. data/lib/everywhere/config/auth.rb +108 -0
  33. data/lib/everywhere/config/data.rb +50 -0
  34. data/lib/everywhere/config/deep_linking.rb +107 -0
  35. data/lib/everywhere/config/desktop_ui.rb +153 -0
  36. data/lib/everywhere/config/mobile.rb +211 -0
  37. data/lib/everywhere/config/native_desktop.rb +168 -0
  38. data/lib/everywhere/config/native_mobile.rb +337 -0
  39. data/lib/everywhere/config/shell.rb +57 -0
  40. data/lib/everywhere/config/updates.rb +63 -0
  41. data/lib/everywhere/config.rb +59 -1367
  42. data/lib/everywhere/console.rb +2 -1
  43. data/lib/everywhere/desktop_dev_app.rb +138 -0
  44. data/lib/everywhere/dock/state.rb +3 -1
  45. data/lib/everywhere/engine.rb +24 -0
  46. data/lib/everywhere/entrypoint.rb +24 -0
  47. data/lib/everywhere/error.rb +8 -0
  48. data/lib/everywhere/framework.rb +23 -6
  49. data/lib/everywhere/host.rb +11 -0
  50. data/lib/everywhere/ignore.rb +13 -5
  51. data/lib/everywhere/jump.rb +121 -0
  52. data/lib/everywhere/line_pump.rb +3 -1
  53. data/lib/everywhere/minisign.rb +1 -0
  54. data/lib/everywhere/mobile_config_endpoint.rb +47 -0
  55. data/lib/everywhere/mobile_configs_controller.rb +46 -0
  56. data/lib/everywhere/native_platform.rb +44 -0
  57. data/lib/everywhere/paths.rb +36 -13
  58. data/lib/everywhere/platform/client.rb +46 -7
  59. data/lib/everywhere/platform/credentials.rb +18 -8
  60. data/lib/everywhere/platform/snapshot.rb +12 -0
  61. data/lib/everywhere/plist.rb +17 -0
  62. data/lib/everywhere/png.rb +1 -0
  63. data/lib/everywhere/raw_tty.rb +51 -0
  64. data/lib/everywhere/receipt.rb +55 -10
  65. data/lib/everywhere/s3.rb +1 -0
  66. data/lib/everywhere/shell_pages.rb +109 -0
  67. data/lib/everywhere/shellout.rb +19 -0
  68. data/lib/everywhere/simulator.rb +12 -1
  69. data/lib/everywhere/tab_filter.rb +36 -0
  70. data/lib/everywhere/task_pool.rb +3 -4
  71. data/lib/everywhere/ui.rb +6 -1
  72. data/lib/everywhere/version.rb +6 -4
  73. data/lib/everywhere.rb +1 -2
  74. data/support/mobile/android/app/build.gradle.kts +29 -1
  75. data/support/mobile/ios/App/EverywhereConfig.swift +17 -5
  76. data/support/mobile/ios/App/SceneDelegate.swift +75 -8
  77. data/support/mobile/ios/App.xcodeproj/project.pbxproj +2 -4
  78. data/support/mobile/ios/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +0 -1
  79. data/support/mobile/ios/README.md +13 -6
  80. data/support/release/macos/notarize.sh +3 -0
  81. metadata +40 -1
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require_relative "clock"
5
+ require_relative "child_processes"
6
+ require_relative "relay"
7
+ require_relative "shellout"
8
+ require_relative "ui"
9
+
10
+ module Everywhere
11
+ # The long-lived children a session keeps on a leash, one per key: `every
12
+ # dev`'s dev server, desktop shell and log stream; `every preview`'s server
13
+ # and tunnel.
14
+ #
15
+ # It sits above ChildProcesses — which knows how to signal one pid's process
16
+ # group but nothing about names — and beside Shellout, which knows how to
17
+ # spawn one child but never remembers it. What lives here is the
18
+ # session-scoped {key => pid} registry, and the teardown that must not let a
19
+ # child outlive the CLI that opened it.
20
+ #
21
+ # A mixin rather than a collaborator object: the registry has exactly one
22
+ # owner (the command), and these read as the command's own vocabulary —
23
+ # stop_child(:web), child_pid(:desktop).
24
+ module ChildSupervision
25
+ private
26
+
27
+ # Children are spawned from pool threads as well as the main one.
28
+ def init_children!
29
+ @pids = {}
30
+ @relays = {}
31
+ @children = Mutex.new
32
+ end
33
+
34
+ # Spawn a long-lived child on a pty and relay its output, one tagged line
35
+ # at a time. A pty rather than a pipe so foreman, cargo and friends still
36
+ # believe they're on a terminal and keep their colors; PTY.spawn also
37
+ # setsids, so a Ctrl-C at our terminal can't reach them and teardown
38
+ # decides when they die.
39
+ def supervise(key, env, cmd, chdir:, filter: nil, log: nil, on_line: nil)
40
+ io, pid = spawn_child(env, cmd, chdir: chdir)
41
+ thread = relay_thread(key, io, pid, filter: filter, log: log, on_line: on_line) if io
42
+ Process.detach(pid)
43
+ @children.synchronize do
44
+ @pids[key] = pid
45
+ @relays[key] = thread if thread
46
+ end
47
+ pid
48
+ end
49
+
50
+ # How this command puts a child into the world: [io, pid], where io is the
51
+ # side we read its output from — or nil for a child that was handed our own
52
+ # fds and prints for itself, which gets no relay. Overridden per command,
53
+ # because only the pty path is shared: what to do when there is no pty is
54
+ # exactly where dev and preview differ.
55
+ def spawn_child(env, cmd, chdir:)
56
+ Shellout.spawn_pty(env, cmd, chdir: chdir)
57
+ end
58
+
59
+ def relay_thread(key, io, pid, filter:, log:, on_line:)
60
+ Thread.new do
61
+ Thread.current.name = "relay-#{key}"
62
+ Thread.current.report_on_exception = false
63
+ begin
64
+ Relay.new(io, source: key, log: log, filter: filter, on_line: on_line).run
65
+ rescue StandardError => e
66
+ # Losing a relay costs us that child's output, nothing more. It must
67
+ # not surface later out of the join in teardown.
68
+ Kernel.warn("#{key} output relay stopped: #{e.class}: #{e.message}")
69
+ end
70
+ on_child_exit(key, pid)
71
+ end
72
+ end
73
+
74
+ # The child's output ended, which for a supervised child means it died.
75
+ # Nothing to do by default — the pid is still in the registry either way,
76
+ # and teardown reaps it.
77
+ def on_child_exit(key, pid); end
78
+
79
+ # TERM the child's whole process group, escalate to KILL, and wait for the
80
+ # relay to drain so its last lines aren't lost. The pid is dropped from the
81
+ # registry FIRST, so the relay knows this death was asked for.
82
+ def stop_child(key, grace: 5)
83
+ pid, thread = @children.synchronize { [@pids.delete(key), @relays.delete(key)] }
84
+ return unless pid
85
+
86
+ reap([pid], grace: grace)
87
+ thread&.join(1)
88
+ end
89
+
90
+ # TERM each process group, wait for them together rather than serially,
91
+ # then KILL the stragglers.
92
+ def reap(pids, grace: 5)
93
+ live = pids.compact.select { |pid| ChildProcesses.alive?(pid) }
94
+ return if live.empty?
95
+
96
+ live.each { |pid| ChildProcesses.signal(pid, "TERM") }
97
+ deadline = Clock.monotonic + grace
98
+ sleep(0.1) while live.any? { |pid| ChildProcesses.alive?(pid) } && Clock.monotonic < deadline
99
+ live.select { |pid| ChildProcesses.alive?(pid) }.each { |pid| ChildProcesses.signal(pid, "KILL") }
100
+ end
101
+
102
+ def child_pid(key) = @children.synchronize { @pids[key] }
103
+
104
+ # Teardown must never raise — it runs from an ensure, where an exception
105
+ # would mask whatever actually ended the session — and one failed step
106
+ # must not skip the rest, or a child survives the CLI that spawned it.
107
+ # That ensure can also run before init_children! ever did, which is why the
108
+ # registry is read through a nil guard.
109
+ #
110
+ # `before` is whatever the host has to shut down ahead of its children —
111
+ # for `every dev`, the dock's hold on the terminal and the build pool.
112
+ def teardown_children(grace: 2, before: [])
113
+ @stopping = true
114
+ pids, relays = @children ? @children.synchronize { [@pids.values, @relays.values] } : [[], []]
115
+ (before + [
116
+ # TERM everything, then give it a moment and KILL whatever is left.
117
+ # The CLI exits the instant this returns, so anything still alive is
118
+ # orphaned — and a windowed shell is exactly the kind of child that
119
+ # doesn't drop dead on the first signal.
120
+ -> { reap(pids, grace: grace) },
121
+ -> { relays.each { |thread| thread.join(1) } }
122
+ ]).each do |step|
123
+ step.call
124
+ rescue StandardError => e
125
+ Kernel.warn("teardown: #{e.class}: #{e.message}")
126
+ end
127
+ end
128
+
129
+ # Checked once, up front: Integer() would otherwise raise from inside
130
+ # port_open? — an ArgumentError backtrace after the dock has already
131
+ # taken over the terminal.
132
+ def validate_port!(port)
133
+ number = begin
134
+ Integer(port.to_s, 10)
135
+ rescue ArgumentError, TypeError
136
+ UI.die!("--port must be a number (got #{port.inspect})")
137
+ end
138
+ UI.die!("--port must be between 1 and 65535 (got #{number})") unless (1..65_535).cover?(number)
139
+ number.to_s
140
+ end
141
+
142
+ # Any connect error means "nothing listening yet" for our purposes —
143
+ # ECONNREFUSED is the common one, but a firewall or an exhausted fd table
144
+ # answers differently and must not abort the session.
145
+ def port_open?(port)
146
+ TCPSocket.new("127.0.0.1", Integer(port)).close
147
+ true
148
+ rescue SystemCallError
149
+ false
150
+ end
151
+
152
+ # Watches the child as well as the port: a dev server that dies on
153
+ # startup (no `bundle install`, a bin/dev whose foreman isn't installed)
154
+ # never opens the port, and waiting out the full timeout for it buries
155
+ # the error it already printed under a minute of silence.
156
+ #
157
+ # The hints are appended to the message as-is, for a caller that can say
158
+ # something more specific about why its server never came up.
159
+ def wait_for_port(port, pid: nil, timeout: 60, dead_hint: nil, slow_hint: nil)
160
+ deadline = Clock.monotonic + timeout
161
+ until port_open?(port)
162
+ if pid && !ChildProcesses.alive?(pid)
163
+ UI.die!("the dev server exited before it finished booting — scroll up for its output#{dead_hint}")
164
+ end
165
+ if Clock.monotonic > deadline
166
+ UI.die!("dev server never came up on port #{port} after #{timeout}s#{slow_hint}")
167
+ end
168
+ sleep 0.25
169
+ end
170
+ end
171
+ end
172
+ end
@@ -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
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Everywhere
4
+ # The one clock every deadline in the CLI measures against. Monotonic, so a
5
+ # timeout can't be lengthened or skipped by an NTP step or a DST change
6
+ # mid-build.
7
+ module Clock
8
+ module_function
9
+
10
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
11
+ end
12
+ end
@@ -5,10 +5,12 @@ require "fileutils"
5
5
  require "tmpdir"
6
6
  require "shellwords"
7
7
  require_relative "../shellout"
8
+ require_relative "../host"
8
9
  require_relative "../log_filter"
9
10
  require_relative "../png"
10
11
  require_relative "../icon"
11
12
  require_relative "../paths"
13
+ require_relative "../plist"
12
14
  require_relative "../desktop_assets"
13
15
  require_relative "../builders/ios"
14
16
  require_relative "../builders/android"
@@ -18,16 +20,16 @@ module Everywhere
18
20
  module Commands
19
21
  # Press the app into a single-file binary and make it runnable on this Mac.
20
22
  #
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
23
+ # Encodes the macOS 26 workarounds discovered in the Phase 0 spike:
24
+ # 1. BUNDLE_FORCE_RUBY_PLATFORM so native gems compile from source
24
25
  # (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)
26
+ # 2. Homebrew bison on PATH + LG_VADDR for tebako itself
27
+ # 3. Post-press ad-hoc re-sign (macOS 26 rejects the pressed signature)
27
28
  class Build < Dry::CLI::Command
28
- SHIM = File.expand_path("~/.tebako-shims/clang++")
29
-
30
- DEFAULT_TARGET = "macos-arm64"
29
+ # Derived from the host, not fixed: on an Intel Mac the pressed binary is
30
+ # x86_64, and a hardcoded arm64 would put that lie in the bundle metadata,
31
+ # the release receipt and every platform override keyed off the target.
32
+ DEFAULT_TARGET = RUBY_PLATFORM.match?(/arm64|aarch64/) ? "macos-arm64" : "macos-x86_64"
31
33
  IOS_TARGET = "ios-arm64"
32
34
  # The APK/AAB is universal, so the arch half is nominal — it exists only
33
35
  # so every target reads as os-arch.
@@ -37,6 +39,8 @@ module Everywhere
37
39
  # mobile platform is one entry rather than a third copy of resolve_target.
38
40
  SHORTHAND_TARGETS = { ios: IOS_TARGET, android: ANDROID_TARGET }.freeze
39
41
 
42
+ ANDROID_FORMATS = %w[apk aab].freeze
43
+
40
44
  desc "Package the app natively — desktop (tebako press + shell + .app), iOS (--ios) or Android (--android)"
41
45
 
42
46
  option :root, default: ".", desc: "App root directory"
@@ -46,26 +50,51 @@ module Everywhere
46
50
  option :app_name, default: nil, desc: "Bundle name (default: capitalized app dir)"
47
51
  option :target, default: nil, desc: "Build target (os-arch) — selects any platforms: overrides, default: \"#{DEFAULT_TARGET}\""
48
52
  option :shell_dir, default: nil, desc: "Path to the shell's src-tauri directory"
53
+ option :template_dir, default: nil, desc: "Path to the mobile shell template (iOS/Android)"
54
+ option :format, default: nil, desc: "Android artifact format (#{ANDROID_FORMATS.join(" | ")}), default: apk"
55
+ option :channel, default: "direct", desc: "Distribution channel (direct | testflight | app_store | ...)"
49
56
  option :ios, type: :boolean, default: false, desc: "Build the iOS shell (shorthand for --target #{IOS_TARGET})"
50
57
  option :android, type: :boolean, default: false, desc: "Build the Android shell (shorthand for --target #{ANDROID_TARGET})"
58
+ option :device, type: :boolean, default: false, desc: "iOS: archive and export a device .ipa instead of a simulator .app"
51
59
  option :skip_press, type: :boolean, default: false, desc: "Reuse the existing pressed binary, only rebuild the .app"
52
60
 
53
61
  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, **)
62
+ app_name: nil, target: nil, shell_dir: nil, template_dir: nil, format: nil,
63
+ channel: "direct", ios: false, android: false, device: false, skip_press: false, **)
55
64
  target = resolve_target(target, ios: ios, android: android)
56
65
  root_path = File.expand_path(root)
57
66
  config = Everywhere::Config.load(root_path)
58
67
 
68
+ # Before any builder runs: the bundle id names the work dirs they wipe
69
+ # and recreate, so a malformed one is a wrong path, not a wrong label.
70
+ identity_errors = config.identity_errors
71
+ UI.die!("app: in config/everywhere.yml:\n#{identity_errors.join("\n")}") unless identity_errors.empty?
72
+
73
+ # Baked into the shell here, so this is the last moment an unusable
74
+ # feed URL can still be a message instead of a shipped app that never
75
+ # updates.
76
+ updates_errors = config.updates_errors
77
+ UI.die!("updates: in config/everywhere.yml:\n#{updates_errors.join("\n")}") unless updates_errors.empty?
78
+
59
79
  # Builder dispatch on the target's os (build-engine.md §5): macOS is
60
80
  # the tebako+tauri path below; the mobile targets are Hotwire Native
61
81
  # shells, each built by its own builder. Anything else has no builder yet.
62
82
  case Everywhere::Config.os_of(target)
63
83
  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))
66
- UI.success("built #{rel(app, root_path)}")
84
+ # --output names a file for the pressed desktop binary, but a mobile
85
+ # build's product is whatever the platform toolchain emits — so here it
86
+ # names the directory those products land in.
87
+ dist_dir = output ? File.expand_path(output) : File.expand_path("dist", root_path)
88
+ app = mobile_builder(target).new(config: config, root: root_path, target: target,
89
+ template_dir: template_dir, **channel_option(target, channel))
90
+ .build!(dist_dir: dist_dir, **mobile_options(target, device: device, format: format))
91
+ UI.success("built #{UI.rel_path(app, root_path)}")
67
92
  return
68
- when "macos" # the rest of this method
93
+ when "macos" # ...which is the rest of this method
94
+ # Every step of it ends in codesign and an .app bundle, so a Linux
95
+ # host can only fail — the question is whether it fails here or
96
+ # thirty minutes in, at the first codesign.
97
+ UI.die!("desktop builds are macOS-only for now") unless Host.darwin?
69
98
  else
70
99
  UI.die!("no builder for target #{target} — macos-*, ios-* and android-* are supported")
71
100
  end
@@ -88,7 +117,7 @@ module Everywhere
88
117
  # required locally, nothing to press — just shell + config in a bundle.
89
118
  if config.remote?
90
119
  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))
120
+ bundle_app(nil, app_name, shell_dir, config, target: target, dist_dir: File.expand_path("dist", root_path))
92
121
  return
93
122
  end
94
123
 
@@ -108,15 +137,15 @@ module Everywhere
108
137
  UI.step("#{UI.bold(framework.name)} app at #{framework.root}")
109
138
 
110
139
  if skip_press
111
- UI.die!("--skip-press given but #{rel(output_path, root_path)} doesn't exist") unless File.exist?(output_path)
112
- UI.step("reusing pressed binary #{rel(output_path, root_path)}")
140
+ UI.die!("--skip-press given but #{UI.rel_path(output_path, root_path)} doesn't exist") unless File.exist?(output_path)
141
+ UI.step("reusing pressed binary #{UI.rel_path(output_path, root_path)}")
113
142
  else
114
143
  press!(framework, entry, ruby, output_path)
115
144
  end
116
145
 
117
- bundle_app(output_path, app_name, shell_dir, config, target: target) if RUBY_PLATFORM.include?("darwin")
146
+ bundle_app(output_path, app_name, shell_dir, config, target: target)
118
147
 
119
- UI.success("built #{rel(output_path, root_path)} (#{(File.size(output_path) / 1024.0 / 1024.0).round}MB)")
148
+ UI.success("built #{UI.rel_path(output_path, root_path)} (#{(File.size(output_path) / 1024.0 / 1024.0).round}MB)")
120
149
  end
121
150
 
122
151
  private
@@ -128,6 +157,34 @@ module Everywhere
128
157
  Everywhere::Config.os_of(target) == "android" ? Builders::Android : Builders::Ios
129
158
  end
130
159
 
160
+ # Only iOS distributes from the build host — the store upload rides along
161
+ # with the archive that's already on this machine. Android's Play upload
162
+ # happens platform-side, so its builder has no channel to know about.
163
+ def channel_option(target, channel)
164
+ Everywhere::Config.os_of(target) == "android" ? {} : { channel: channel }
165
+ end
166
+
167
+ # Each mobile builder takes the one flag that only makes sense for it, so
168
+ # `--format aab` on an iOS build is a mistake we name rather than drop.
169
+ def mobile_options(target, device:, format:)
170
+ if Everywhere::Config.os_of(target) == "android"
171
+ UI.die!("--device is an iOS flag — Android always builds an installable artifact") if device
172
+ { format: android_format(format) }
173
+ else
174
+ UI.die!("--format is an Android flag (#{ANDROID_FORMATS.join("/")}) — iOS builds a .app or .ipa") if format
175
+ { device: device }
176
+ end
177
+ end
178
+
179
+ def android_format(format)
180
+ return :apk if format.nil?
181
+
182
+ unless ANDROID_FORMATS.include?(format.to_s)
183
+ UI.die!("--format must be one of #{ANDROID_FORMATS.join(", ")} (got #{format})")
184
+ end
185
+ format.to_s.to_sym
186
+ end
187
+
131
188
  # --ios/--android are shorthand for their target, and disagreeing with an
132
189
  # explicit --target is a mistake worth naming rather than silently
133
190
  # resolving one way: both spellings were deliberate, so we can't know
@@ -149,8 +206,6 @@ module Everywhere
149
206
  target
150
207
  end
151
208
 
152
- def rel(path, root_path) = UI.short_path(path.sub("#{root_path}/", "").sub("#{Dir.pwd}/", ""))
153
-
154
209
  # The precompile step runs against the HOST Ruby — unlike tebako press,
155
210
  # which installs the app's gems itself inside the packaged Ruby. A fresh
156
211
  # machine (a hosted build runner, a brand-new checkout) has Gemfile.lock
@@ -173,6 +228,7 @@ module Everywhere
173
228
  end
174
229
 
175
230
  def press!(framework, entry, ruby, output_path)
231
+ bison_prefix! # fail here, not forty minutes deep in tebako's log
176
232
  ensure_gems!(framework) if framework.precompile?
177
233
 
178
234
  UI.step("precompiling assets")
@@ -184,21 +240,24 @@ module Everywhere
184
240
  # into the new binary. dist/ is ours — clear it.
185
241
  FileUtils.rm_rf(out_dir) if out_dir.start_with?(framework.root + File::SEPARATOR)
186
242
  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
243
  log = "#{output_path}.build.log"
191
244
 
192
245
  UI.step("rbe-tebako press #{UI.dim("(Ruby #{ruby} — grab a coffee on first run)")}")
193
246
  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)
247
+ # Press into a staging dir OUTSIDE the app root for the same reason
248
+ # (tebako prints a big warning about exactly this). Block form: the
249
+ # staging copy is a 100MB+ binary, and a failed press never reaches the mv.
250
+ Dir.mktmpdir("everywhere-press") do |staging_dir|
251
+ staging = File.join(staging_dir, File.basename(output_path))
252
+ Shellout.run_logged!(press_env, ["rbe-tebako", "press",
253
+ "--root=#{framework.root}", "--entry-point=#{entry}",
254
+ "--Ruby=#{ruby}", "--output=#{staging}"], log: log, filter: LogFilter.for(:tebako))
255
+
256
+ # Tebako's final strip step only warns (and still exits 0) if it can't
257
+ # write the output, so verify the artifact instead of trusting exit codes.
258
+ UI.die!("tebako reported success but no binary was created") unless File.exist?(staging)
259
+ FileUtils.mv(staging, output_path)
260
+ end
202
261
 
203
262
  UI.step("re-signing for macOS")
204
263
  Shellout.run!({}, "codesign", "--remove-signature", output_path)
@@ -215,11 +274,16 @@ module Everywhere
215
274
  # Assemble dist/<App Name>.app: the release shell binary + the pressed
216
275
  # Rails binary (as "app-server") + Info.plist + icon. An .app bundle is
217
276
  # 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.
277
+ def bundle_app(server_binary, app_name, shell_dir, config, target: DEFAULT_TARGET, dist_dir: nil)
278
+ Shellout.ensure_tool!("cargo", "the desktop shell is built with Rust install it from https://rustup.rs")
279
+
280
+ # cargo always runs in a STAGED copy of the shell under
281
+ # ~/.rubyeverywhere, never in the gem (or in whatever --shell-dir
282
+ # points at): the icon swap below and cargo's own Cargo.lock would
283
+ # otherwise write into the installed gem — Errno::EACCES on a
284
+ # system-wide install, and one app's branding in the next app's build.
285
+ # An app with native.desktop additionally gets its Rust stamped in and a
286
+ # private target dir; the rest of this method doesn't need to know which.
223
287
  prepared = Builders::Desktop.new(config: config, root: config.root, template_dir: shell_dir).prepare!
224
288
  shell_dir = prepared.dir
225
289
  dist_dir ||= File.dirname(server_binary)
@@ -249,21 +313,15 @@ module Everywhere
249
313
 
250
314
  # Tauri bakes icons/icon.png into the shell binary and sets it as the
251
315
  # 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.
316
+ # shell must be COMPILED with the app's icon. Swap the shaped master into
317
+ # the staged copy and touch build.rs so tauri-build re-embeds. Nothing to
318
+ # restore: staging re-seeds the copy from the template on every build, so
319
+ # the placeholder comes back by itself when app.icon goes away.
261
320
  shell_icon = File.join(shell_dir, "icons", "icon.png")
262
- placeholder_backup = "#{shell_icon}.placeholder"
263
321
  if icon_source
264
- FileUtils.cp(shell_icon, placeholder_backup) unless File.exist?(placeholder_backup)
265
322
  # The shaped master is already RGBA; fall back to raw RGBA conversion
266
- # if shaping failed, and to the placeholder if even that fails.
323
+ # if shaping failed. ensure_rgba refuses before writing, so a failure
324
+ # leaves the staged placeholder untouched.
267
325
  dock_ok = if macos_icon == icon_source
268
326
  Everywhere::PNG.ensure_rgba(icon_source, shell_icon)
269
327
  else
@@ -272,21 +330,13 @@ module Everywhere
272
330
  end
273
331
  unless dock_ok
274
332
  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
333
  end
277
334
  FileUtils.touch(File.join(shell_dir, "build.rs"))
278
335
  end
279
336
 
280
337
  UI.step("building shell #{UI.dim(prepared.stamped? ? "(cargo release, per-app)" : "(cargo release)")}")
281
338
  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
339
+ Shellout.run!({ "CARGO_TARGET_DIR" => target_dir }, "cargo", "build", "--release", chdir: shell_dir)
290
340
  shell_bin = Dir[File.join(target_dir, "release/*")]
291
341
  .find { |f| File.file?(f) && File.executable?(f) && !f.end_with?(".d") }
292
342
  UI.die!("couldn't find release shell binary in #{target_dir}/release") unless shell_bin
@@ -312,7 +362,7 @@ module Everywhere
312
362
  UI.warn "splash #{splash} not found — using the default splash"
313
363
  end
314
364
  end
315
- icns = make_icns(macos_icon || File.join(shell_dir, "icons", "icon.png"), resources)
365
+ icns = make_icns(macos_icon || shell_icon, resources)
316
366
  File.write(File.join(app_dir, "Contents", "Info.plist"),
317
367
  info_plist(app_name, exe_name, icns, config.bundle_id(target: target), config.version(target: target)))
318
368
 
@@ -361,6 +411,8 @@ module Everywhere
361
411
  # and CFBundleVersion come from `app.version` in everywhere.yml (via
362
412
  # config.version), so the bundle and the release.json receipt agree.
363
413
  def info_plist(app_name, exe_name, icon, identifier, version)
414
+ app_name, exe_name, icon, identifier, version =
415
+ [app_name, exe_name, icon, identifier, version].map { |v| Everywhere::Plist.escape(v) if v }
364
416
  icon_keys = icon ? " <key>CFBundleIconFile</key><string>#{icon}</string>\n" : ""
365
417
  <<~PLIST
366
418
  <?xml version="1.0" encoding="UTF-8"?>
@@ -381,8 +433,6 @@ module Everywhere
381
433
  PLIST
382
434
  end
383
435
 
384
- private
385
-
386
436
  # Tebako's output is thousands of lines; re-surface the warnings that
387
437
  # actually matter to the app developer after the build finishes.
388
438
  def surface_warnings(log)
@@ -404,14 +454,30 @@ module Everywhere
404
454
  end
405
455
 
406
456
  def press_env
407
- env = {
457
+ {
408
458
  "BUNDLE_FORCE_RUBY_PLATFORM" => "true",
409
- "LG_VADDR" => "39"
459
+ "LG_VADDR" => "39",
460
+ "PATH" => "#{bison_prefix!}/bin:#{ENV.fetch("PATH")}"
410
461
  }
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
462
+ end
463
+
464
+ # tebako builds Ruby from source and macOS still ships bison 2.3 (2006),
465
+ # which fails deep inside that build with no hint of the cause. Homebrew's
466
+ # is the only one that works, so a missing one is a hard stop rather than
467
+ # a silently dropped PATH entry.
468
+ def bison_prefix!
469
+ @bison_prefix ||= brew_bison_prefix ||
470
+ UI.die!("tebako needs Homebrew bison — brew install bison")
471
+ end
472
+
473
+ def brew_bison_prefix
474
+ return nil unless Shellout.tool?("brew")
475
+
476
+ prefix, status = Shellout.capture("brew", "--prefix", "bison")
477
+ prefix = prefix.to_s.strip
478
+ # `brew --prefix <formula>` answers with the opt path whether or not the
479
+ # formula is installed, so the bin dir has to be checked too.
480
+ prefix if status&.success? && !prefix.empty? && File.directory?(File.join(prefix, "bin"))
415
481
  end
416
482
 
417
483
  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"),