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.
- checksums.yaml +4 -4
- data/exe/every +2 -2
- data/exe/rbe +2 -2
- data/lib/everywhere/agents_guide.rb +3 -1
- data/lib/everywhere/blake2b.rb +17 -1
- data/lib/everywhere/boot.rb +30 -6
- data/lib/everywhere/builders/android.rb +152 -64
- data/lib/everywhere/builders/base.rb +53 -0
- data/lib/everywhere/builders/desktop.rb +32 -40
- data/lib/everywhere/builders/ios.rb +268 -36
- data/lib/everywhere/builders/native_sources.rb +38 -0
- data/lib/everywhere/child_processes.rb +4 -4
- data/lib/everywhere/child_supervision.rb +172 -0
- data/lib/everywhere/cli.rb +2 -0
- data/lib/everywhere/clock.rb +12 -0
- data/lib/everywhere/commands/build.rb +132 -66
- data/lib/everywhere/commands/clean.rb +8 -3
- data/lib/everywhere/commands/dev.rb +91 -242
- data/lib/everywhere/commands/doctor.rb +138 -21
- data/lib/everywhere/commands/install.rb +49 -8
- data/lib/everywhere/commands/platform/auth_status.rb +1 -0
- data/lib/everywhere/commands/platform/build.rb +141 -33
- data/lib/everywhere/commands/platform/login.rb +20 -6
- data/lib/everywhere/commands/platform/logout.rb +1 -0
- data/lib/everywhere/commands/platform/runner.rb +170 -25
- data/lib/everywhere/commands/preview.rb +286 -0
- data/lib/everywhere/commands/publish.rb +22 -2
- data/lib/everywhere/commands/release.rb +155 -35
- data/lib/everywhere/commands/shell_dir.rb +4 -2
- data/lib/everywhere/commands/updates_keygen.rb +25 -1
- data/lib/everywhere/config/app.rb +126 -0
- data/lib/everywhere/config/auth.rb +108 -0
- data/lib/everywhere/config/data.rb +50 -0
- data/lib/everywhere/config/deep_linking.rb +107 -0
- data/lib/everywhere/config/desktop_ui.rb +153 -0
- data/lib/everywhere/config/mobile.rb +211 -0
- data/lib/everywhere/config/native_desktop.rb +168 -0
- data/lib/everywhere/config/native_mobile.rb +337 -0
- data/lib/everywhere/config/shell.rb +57 -0
- data/lib/everywhere/config/updates.rb +63 -0
- data/lib/everywhere/config.rb +59 -1367
- data/lib/everywhere/console.rb +2 -1
- data/lib/everywhere/desktop_dev_app.rb +138 -0
- data/lib/everywhere/dock/state.rb +3 -1
- data/lib/everywhere/engine.rb +24 -0
- data/lib/everywhere/entrypoint.rb +24 -0
- data/lib/everywhere/error.rb +8 -0
- data/lib/everywhere/framework.rb +23 -6
- data/lib/everywhere/host.rb +11 -0
- data/lib/everywhere/ignore.rb +13 -5
- data/lib/everywhere/jump.rb +121 -0
- data/lib/everywhere/line_pump.rb +3 -1
- data/lib/everywhere/minisign.rb +1 -0
- data/lib/everywhere/mobile_config_endpoint.rb +47 -0
- data/lib/everywhere/mobile_configs_controller.rb +46 -0
- data/lib/everywhere/native_platform.rb +44 -0
- data/lib/everywhere/paths.rb +36 -13
- data/lib/everywhere/platform/client.rb +46 -7
- data/lib/everywhere/platform/credentials.rb +18 -8
- data/lib/everywhere/platform/snapshot.rb +12 -0
- data/lib/everywhere/plist.rb +17 -0
- data/lib/everywhere/png.rb +1 -0
- data/lib/everywhere/raw_tty.rb +51 -0
- data/lib/everywhere/receipt.rb +55 -10
- data/lib/everywhere/s3.rb +1 -0
- data/lib/everywhere/shell_pages.rb +109 -0
- data/lib/everywhere/shellout.rb +19 -0
- data/lib/everywhere/simulator.rb +12 -1
- data/lib/everywhere/tab_filter.rb +36 -0
- data/lib/everywhere/task_pool.rb +3 -4
- data/lib/everywhere/ui.rb +6 -1
- data/lib/everywhere/version.rb +6 -4
- data/lib/everywhere.rb +1 -2
- data/support/mobile/android/app/build.gradle.kts +29 -1
- data/support/mobile/ios/App/EverywhereConfig.swift +17 -5
- data/support/mobile/ios/App/SceneDelegate.swift +75 -8
- data/support/mobile/ios/App.xcodeproj/project.pbxproj +2 -4
- data/support/mobile/ios/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +0 -1
- data/support/mobile/ios/README.md +13 -6
- data/support/release/macos/notarize.sh +3 -0
- 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
|
data/lib/everywhere/cli.rb
CHANGED
|
@@ -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
|
|
22
|
-
# 1.
|
|
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
|
-
#
|
|
26
|
-
#
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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,
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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 #{
|
|
112
|
-
UI.step("reusing pressed binary #{
|
|
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)
|
|
146
|
+
bundle_app(output_path, app_name, shell_dir, config, target: target)
|
|
118
147
|
|
|
119
|
-
UI.success("built #{
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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:
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
#
|
|
222
|
-
#
|
|
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
|
|
253
|
-
#
|
|
254
|
-
#
|
|
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
|
|
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
|
-
|
|
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 ||
|
|
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
|
-
|
|
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
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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
|
|
10
|
-
#
|
|
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"),
|