ruby_everywhere 0.0.1 → 0.1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 39645f3a9d06b112aacb1d2f6863914e355b027f73601de21a04fb2d45b0f1a9
4
- data.tar.gz: 87e978358814f098d3f1c596edc5d75b1f843608bde316fc0cebf159a57ffb5f
3
+ metadata.gz: 75b09aedaa84953cf39a5b6bc7e793f660998f297d5c7cc74ed453fef68172f3
4
+ data.tar.gz: f1970350eaaedaf2fed2fd6123a25c152f196019256d5c2ff848a0a262f589c7
5
5
  SHA512:
6
- metadata.gz: bc57bb378e7b44fc08e5a2e5a5929a240ef6be7ac7bc506302a597aaa829c116fb8df391313dcb879824a420b0befeb6b72ee66f8993b1ab393fc274fc0ffcb4
7
- data.tar.gz: 1aae4125b5d23220f900c0650b88102f09b274213aea8c201e0fb435ebc8b38bb7bd3b4a546b58a805f92ee3533e6f7305ab53b5cfdd51a0fe80946485d72558
6
+ metadata.gz: 3f611fb7632f07a4931b659166ad6c1e0e773b7fa979b38c66f109cb0c22ace4c9710bd977e112d40bceee6efdd358fe5283262d7218f60e36f267e4456e6f5f
7
+ data.tar.gz: '08def4faa79e14355864dd66369a8cfb06884b062186234d96478c9cbe3edd9957f14e46319bd2276f5927728ccea0ce442ae6d216b2bc851cd059de815f84a3'
data/exe/every ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "everywhere/cli"
5
+
6
+ Dry::CLI.new(Everywhere::CLI).call
data/exe/rbe ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "everywhere/cli"
5
+
6
+ Dry::CLI.new(Everywhere::CLI).call
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "securerandom"
5
+
6
+ module Everywhere
7
+ # Runtime boot for the packaged desktop build. Called from the app's
8
+ # generated native_boot.rb stub (after the bundle is set up):
9
+ #
10
+ # Everywhere.boot!(root: __dir__)
11
+ #
12
+ # This core is framework-neutral. Its responsibilities:
13
+ # 1. Resolve a writable per-user app-data directory (packaged FS is read-only)
14
+ # 2. Generate a per-install SECRET_KEY_BASE on first boot
15
+ # 3. Copy static assets to a writable dir (the memfs truncates large files)
16
+ # 4. Watch the shell process and shut down if it dies
17
+ #
18
+ # Everything framework-specific — loading the app, preparing the database, and
19
+ # starting the server — is delegated to the detected Framework adapter, so
20
+ # Rails, Sinatra, and Hanami each own their own boot path.
21
+ #
22
+ # The desktop shell passes NATIVE_PORT and NATIVE_APPDATA; standalone runs
23
+ # (debugging) get a free port and a config-derived app-data dir.
24
+ class Boot
25
+ def self.call(root:)
26
+ new(root).call
27
+ end
28
+
29
+ attr_reader :root, :config
30
+
31
+ def initialize(root)
32
+ @root = File.expand_path(root)
33
+ @config = Config.load(@root)
34
+ end
35
+
36
+ def call
37
+ ENV["NATIVE_PACKAGED"] ||= "1"
38
+
39
+ framework = Framework.detect(root)
40
+ framework.prepare_runtime_env
41
+
42
+ prepare_app_data
43
+ ensure_secret_key_base
44
+ extract_public_dir
45
+
46
+ Dir.chdir(root)
47
+ watch_shell
48
+
49
+ port = ENV["NATIVE_PORT"] || free_port
50
+ puts "[everywhere] framework: #{framework.name}"
51
+ puts "[everywhere] app data: #{app_data}"
52
+ puts "[everywhere] app running at http://127.0.0.1:#{port}"
53
+
54
+ framework.boot!(port: port)
55
+ end
56
+
57
+ private
58
+
59
+ def app_data
60
+ @app_data ||= ENV["NATIVE_APPDATA"] || default_app_data
61
+ end
62
+
63
+ # Same layout the desktop shell uses: platform data dir + bundle_id.
64
+ def default_app_data
65
+ case RUBY_PLATFORM
66
+ when /darwin/
67
+ File.join(Dir.home, "Library", "Application Support", config.bundle_id)
68
+ when /mingw|mswin/
69
+ File.join(ENV.fetch("APPDATA"), config.bundle_id)
70
+ else
71
+ File.join(ENV.fetch("XDG_DATA_HOME", File.join(Dir.home, ".local", "share")),
72
+ config.bundle_id)
73
+ end
74
+ end
75
+
76
+ def prepare_app_data
77
+ storage = File.join(app_data, "storage")
78
+ FileUtils.mkdir_p(storage)
79
+ ENV["NATIVE_STORAGE_DIR"] = storage
80
+ end
81
+
82
+ # Files larger than ~64KB truncate when streamed from the tebako memfs
83
+ # (Rack::Files sends an empty body), so static assets are served from a
84
+ # real-disk copy in app-data instead. The everywhere initializer points
85
+ # Rails.public_path here via NATIVE_PUBLIC_DIR.
86
+ def extract_public_dir
87
+ return unless ENV["NATIVE_PACKAGED"] == "1"
88
+
89
+ source = File.join(root, "public")
90
+ return unless File.directory?(source)
91
+
92
+ target = File.join(app_data, "public")
93
+ FileUtils.rm_rf(target)
94
+ FileUtils.cp_r(source, target)
95
+ ENV["NATIVE_PUBLIC_DIR"] = target
96
+ end
97
+
98
+ def ensure_secret_key_base
99
+ secret_file = File.join(app_data, "secret_key_base")
100
+ unless File.exist?(secret_file)
101
+ File.write(secret_file, SecureRandom.hex(64))
102
+ File.chmod(0o600, secret_file)
103
+ end
104
+ ENV["SECRET_KEY_BASE"] ||= File.read(secret_file).strip
105
+ end
106
+
107
+ # If the desktop shell dies without cleanup (crash, force-quit, SIGKILL),
108
+ # shut the server down rather than lingering as an orphan. TERM to self
109
+ # gives Puma a graceful stop, which also stops Solid Queue's processes.
110
+ def watch_shell
111
+ shell_pid = ENV["NATIVE_SHELL_PID"].to_i
112
+ return unless shell_pid.positive?
113
+
114
+ Thread.new do
115
+ loop do
116
+ sleep 2
117
+ begin
118
+ Process.kill(0, shell_pid)
119
+ rescue Errno::ESRCH
120
+ Process.kill("TERM", Process.pid)
121
+ sleep 10
122
+ exit!(1)
123
+ end
124
+ end
125
+ end
126
+ end
127
+
128
+ def free_port
129
+ require "socket"
130
+ server = TCPServer.new("127.0.0.1", 0)
131
+ server.addr[1].to_s.tap { server.close }
132
+ end
133
+ end
134
+
135
+ def self.boot!(root:)
136
+ Boot.call(root: root)
137
+ end
138
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/cli"
4
+ require_relative "../everywhere"
5
+ require_relative "framework"
6
+ require_relative "commands/build"
7
+ require_relative "commands/dev"
8
+ require_relative "commands/doctor"
9
+ require_relative "commands/install"
10
+ require_relative "commands/release"
11
+ require_relative "commands/platform/login"
12
+ require_relative "commands/platform/logout"
13
+ require_relative "commands/platform/auth_status"
14
+ require_relative "commands/platform/build"
15
+ require_relative "commands/platform/runner"
16
+
17
+ module Everywhere
18
+ module Commands
19
+ class Version < Dry::CLI::Command
20
+ desc "Print version"
21
+
22
+ def call(**)
23
+ puts "ruby_everywhere #{Everywhere::VERSION}"
24
+ end
25
+ end
26
+ end
27
+
28
+ module CLI
29
+ extend Dry::CLI::Registry
30
+
31
+ register "version", Commands::Version, aliases: ["-v", "--version"]
32
+ register "doctor", Commands::Doctor
33
+ register "install", Commands::Install
34
+ register "dev", Commands::Dev
35
+ register "build", Commands::Build
36
+ register "release", Commands::Release
37
+
38
+ register "platform login", Commands::PlatformLogin
39
+ register "platform logout", Commands::PlatformLogout
40
+ register "platform auth status", Commands::PlatformAuthStatus
41
+ register "platform build", Commands::PlatformBuild
42
+ register "platform runner", Commands::PlatformRunner
43
+ end
44
+ end
@@ -0,0 +1,272 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/cli"
4
+ require "fileutils"
5
+ require "tmpdir"
6
+ require "shellwords"
7
+ require_relative "../shellout"
8
+ require_relative "../png"
9
+
10
+ module Everywhere
11
+ module Commands
12
+ # Press the app into a single-file binary and make it runnable on this Mac.
13
+ #
14
+ # Encodes the Xcode 26 / macOS 26 workarounds discovered in the Phase 0 spike:
15
+ # 1. CXX compiler shim (fmt consteval + folly __compressed_pair fixes)
16
+ # 2. BUNDLE_FORCE_RUBY_PLATFORM so native gems compile from source
17
+ # (tebako's strip corrupts precompiled gems' signatures -> SIGKILL)
18
+ # 3. Homebrew bison on PATH + LG_VADDR for tebako itself
19
+ # 4. Post-press ad-hoc re-sign (macOS 26 rejects the pressed signature)
20
+ class Build < Dry::CLI::Command
21
+ SHIM = File.expand_path("~/.tebako-shims/clang++")
22
+
23
+ desc "Package the app into a double-clickable desktop app (tebako press + shell + .app bundle)"
24
+
25
+ option :root, default: ".", desc: "App root directory"
26
+ option :output, default: nil, desc: "Output binary path (default: dist/<app>)"
27
+ option :ruby, default: "4.0.6", desc: "Ruby version to package"
28
+ option :entry, default: "native_boot.rb", desc: "Entry point script relative to root"
29
+ option :app_name, default: nil, desc: "Bundle name (default: capitalized app dir)"
30
+ option :shell_dir, default: nil, desc: "Path to the shell's src-tauri directory"
31
+ option :skip_press, type: :boolean, default: false, desc: "Reuse the existing pressed binary, only rebuild the .app"
32
+
33
+ def call(root: ".", output: nil, ruby: "4.0.6", entry: "native_boot.rb",
34
+ app_name: nil, shell_dir: nil, skip_press: false, **)
35
+ root_path = File.expand_path(root)
36
+ config = Everywhere::Config.load(root_path)
37
+ app_name ||= config.name
38
+
39
+ # Remote mode: thin shell around an already-deployed app. No Rails app
40
+ # required locally, nothing to press — just shell + config in a bundle.
41
+ if config.remote?
42
+ UI.step("#{UI.bold("remote")} mode → #{UI.cyan(config.remote_url)} #{UI.dim("(no local server packaged)")}")
43
+ app_dir = bundle_app(nil, app_name, shell_dir, config, dist_dir: File.expand_path("dist", root_path))
44
+ return
45
+ end
46
+
47
+ framework = Framework.detect(root)
48
+ output ||= File.join("dist", File.basename(framework.root))
49
+ output_path = File.expand_path(output)
50
+ entry_path = File.join(framework.root, entry)
51
+
52
+ unless File.exist?(entry_path)
53
+ UI.die!("#{entry} not found in #{framework.root} — the app needs a packaged-boot entry point")
54
+ end
55
+
56
+ UI.step("#{UI.bold(framework.name)} app at #{framework.root}")
57
+
58
+ if skip_press
59
+ UI.die!("--skip-press given but #{output} doesn't exist") unless File.exist?(output_path)
60
+ UI.step("reusing pressed binary #{output}")
61
+ else
62
+ press!(framework, entry, ruby, output_path)
63
+ end
64
+
65
+ bundle_app(output_path, app_name, shell_dir, config) if RUBY_PLATFORM.include?("darwin")
66
+
67
+ UI.success("built #{output} (#{(File.size(output_path) / 1024.0 / 1024.0).round}MB)")
68
+ end
69
+
70
+ private
71
+
72
+ def press!(framework, entry, ruby, output_path)
73
+ UI.step("precompiling assets")
74
+ Shellout.run!(framework.precompile_env, framework.precompile_command, chdir: framework.root)
75
+
76
+ out_dir = File.dirname(output_path)
77
+ # Tebako packages the whole app root, so stale build products in an
78
+ # in-root dist/ (previous binaries, .app bundles, logs) would get baked
79
+ # into the new binary. dist/ is ours — clear it.
80
+ FileUtils.rm_rf(out_dir) if out_dir.start_with?(framework.root + File::SEPARATOR)
81
+ FileUtils.mkdir_p(out_dir)
82
+ # Press into a staging dir OUTSIDE the app root for the same reason
83
+ # (tebako prints a big warning about exactly this).
84
+ staging = File.join(Dir.mktmpdir("everywhere-press"), File.basename(output_path))
85
+ log = "#{output_path}.build.log"
86
+
87
+ UI.step("rbe-tebako press (Ruby #{ruby}) #{UI.dim("— grab a coffee on first run")}")
88
+ UI.step(UI.dim("full build log: #{log}"))
89
+ Shellout.run_logged!(press_env, ["rbe-tebako", "press",
90
+ "--root=#{framework.root}", "--entry-point=#{entry}",
91
+ "--Ruby=#{ruby}", "--output=#{staging}"], log: log)
92
+
93
+ # Tebako's final strip step only warns (and still exits 0) if it can't
94
+ # write the output, so verify the artifact instead of trusting exit codes.
95
+ UI.die!("tebako reported success but no binary was created") unless File.exist?(staging)
96
+ FileUtils.mv(staging, output_path)
97
+
98
+ UI.step("re-signing for macOS")
99
+ Shellout.run!({}, "codesign", "--remove-signature", output_path)
100
+ Shellout.run!({}, "codesign", "-s", "-", output_path)
101
+
102
+ # Leaving precompiled assets behind shadows dev-mode assets (new JS
103
+ # wouldn't load in `every dev` until someone clobbers by hand).
104
+ UI.step("cleaning up precompiled assets")
105
+ Shellout.run!(framework.precompile_env, framework.cleanup_command, chdir: framework.root)
106
+
107
+ surface_warnings(log)
108
+ end
109
+
110
+ # Assemble dist/<App Name>.app: the release shell binary + the pressed
111
+ # Rails binary (as "app-server") + Info.plist + icon. An .app bundle is
112
+ # just a directory layout, so we build it by hand — no tauri-cli needed.
113
+ def bundle_app(server_binary, app_name, shell_dir, config, dist_dir: nil)
114
+ shell_dir ||= find_shell_dir
115
+ dist_dir ||= File.dirname(server_binary)
116
+
117
+ icon_source = config.icon
118
+ if icon_source && !File.exist?(icon_source)
119
+ UI.warn "app icon #{icon_source} not found — using placeholder"
120
+ icon_source = nil
121
+ end
122
+ UI.step("app icon: #{icon_source ? icon_source.sub("#{config.root}/", "") : "placeholder"}")
123
+
124
+ # Tauri bakes icons/icon.png into the shell binary and sets it as the
125
+ # Dock icon at runtime (stomping the bundle's icns after launch), so the
126
+ # shell must be COMPILED with the app's icon. Swap it in for the build,
127
+ # restore the placeholder after; touch build.rs so tauri-build re-embeds.
128
+ shell_icon = File.join(shell_dir, "icons", "icon.png")
129
+ placeholder_backup = "#{shell_icon}.placeholder"
130
+ if icon_source
131
+ FileUtils.cp(shell_icon, placeholder_backup) unless File.exist?(placeholder_backup)
132
+ unless Everywhere::PNG.ensure_rgba(icon_source, shell_icon)
133
+ UI.warn "couldn't convert #{File.basename(icon_source)} to RGBA (unusual PNG flavor) — Dock icon will use the placeholder after launch"
134
+ FileUtils.cp(placeholder_backup, shell_icon)
135
+ end
136
+ FileUtils.touch(File.join(shell_dir, "build.rs"))
137
+ end
138
+
139
+ UI.step("building shell #{UI.dim("(cargo release)")}")
140
+ begin
141
+ Shellout.run!({}, "cargo", "build", "--release", chdir: shell_dir)
142
+ ensure
143
+ if icon_source && File.exist?(placeholder_backup)
144
+ FileUtils.mv(placeholder_backup, shell_icon)
145
+ FileUtils.touch(File.join(shell_dir, "build.rs"))
146
+ end
147
+ end
148
+ shell_bin = Dir[File.join(shell_dir, "target/release/*")]
149
+ .find { |f| File.file?(f) && File.executable?(f) && !f.end_with?(".d") }
150
+ UI.die!("couldn't find release shell binary in #{shell_dir}/target/release") unless shell_bin
151
+
152
+ app_dir = File.join(dist_dir, "#{app_name}.app")
153
+ macos = File.join(app_dir, "Contents", "MacOS")
154
+ resources = File.join(app_dir, "Contents", "Resources")
155
+ UI.step("assembling #{File.basename(app_dir)}")
156
+ FileUtils.rm_rf(app_dir)
157
+ FileUtils.mkdir_p([macos, resources])
158
+
159
+ exe_name = app_name.gsub(/\s+/, "")
160
+ FileUtils.cp(shell_bin, File.join(macos, exe_name))
161
+ FileUtils.cp(server_binary, File.join(macos, "app-server")) if server_binary
162
+
163
+ File.write(File.join(resources, "everywhere.json"), config.to_shell_json)
164
+ if (splash = config.splash)
165
+ if File.exist?(splash)
166
+ FileUtils.cp(splash, File.join(resources, "splash.html"))
167
+ UI.step("custom splash: #{splash.sub("#{config.root}/", "")}")
168
+ else
169
+ UI.warn "splash #{splash} not found — using the default splash"
170
+ end
171
+ end
172
+ icns = make_icns(icon_source || File.join(shell_dir, "icons", "icon.png"), resources)
173
+ File.write(File.join(app_dir, "Contents", "Info.plist"),
174
+ info_plist(app_name, exe_name, icns, config.bundle_id, config.version))
175
+
176
+ UI.step("signing #{File.basename(app_dir)}")
177
+ Shellout.run!({}, "codesign", "-s", "-", "-f", File.join(macos, "app-server")) if server_binary
178
+ Shellout.run!({}, "codesign", "-s", "-", "-f", File.join(macos, exe_name))
179
+ Shellout.run!({}, "codesign", "-s", "-", "-f", app_dir)
180
+
181
+ # macOS caches app icons by bundle path; re-register so icon changes
182
+ # show up without the user hunting down cache-flush incantations.
183
+ Shellout.run?("/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister -f #{app_dir.shellescape} 2>/dev/null")
184
+
185
+ UI.success("app bundle: #{app_dir.sub("#{Dir.pwd}/", "")} #{UI.dim("(open it!)")}")
186
+ end
187
+
188
+ def find_shell_dir
189
+ candidates = ["shell/src-tauri", "../shell/src-tauri", "src-tauri"]
190
+ found = candidates.find { |c| File.exist?(File.join(c, "tauri.conf.json")) }
191
+ found ? File.expand_path(found) : UI.die!("couldn't find the shell (looked in #{candidates.join(", ")}); pass --shell-dir")
192
+ end
193
+
194
+ # macOS ships everything needed to make an .icns from a png.
195
+ def make_icns(png, resources_dir)
196
+ return nil unless File.exist?(png)
197
+
198
+ require "shellwords"
199
+ Dir.mktmpdir do |tmp|
200
+ iconset = File.join(tmp, "App.iconset")
201
+ FileUtils.mkdir_p(iconset)
202
+ [16, 32, 128, 256, 512].each do |size|
203
+ [[size, "icon_#{size}x#{size}.png"], [size * 2, "icon_#{size}x#{size}@2x.png"]].each do |px, name|
204
+ Shellout.run?("sips -z #{px} #{px} #{png.shellescape} --out #{File.join(iconset, name).shellescape} >/dev/null 2>&1")
205
+ end
206
+ end
207
+ out = File.join(resources_dir, "AppIcon.icns")
208
+ return "AppIcon" if Shellout.run?("iconutil -c icns #{iconset.shellescape} -o #{out.shellescape} >/dev/null 2>&1")
209
+ end
210
+ nil
211
+ end
212
+
213
+ # CFBundleShortVersionString is the user-facing marketing version; both it
214
+ # and CFBundleVersion come from `app.version` in everywhere.yml (via
215
+ # config.version), so the bundle and the release.json receipt agree.
216
+ def info_plist(app_name, exe_name, icon, identifier, version)
217
+ icon_keys = icon ? " <key>CFBundleIconFile</key><string>#{icon}</string>\n" : ""
218
+ <<~PLIST
219
+ <?xml version="1.0" encoding="UTF-8"?>
220
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
221
+ <plist version="1.0">
222
+ <dict>
223
+ <key>CFBundleName</key><string>#{app_name}</string>
224
+ <key>CFBundleDisplayName</key><string>#{app_name}</string>
225
+ <key>CFBundleExecutable</key><string>#{exe_name}</string>
226
+ <key>CFBundleIdentifier</key><string>#{identifier}</string>
227
+ <key>CFBundlePackageType</key><string>APPL</string>
228
+ <key>CFBundleShortVersionString</key><string>#{version}</string>
229
+ <key>CFBundleVersion</key><string>#{version}</string>
230
+ <key>LSMinimumSystemVersion</key><string>13.0</string>
231
+ <key>NSHighResolutionCapable</key><true/>
232
+ #{icon_keys}</dict>
233
+ </plist>
234
+ PLIST
235
+ end
236
+
237
+ private
238
+
239
+ # Tebako's output is thousands of lines; re-surface the warnings that
240
+ # actually matter to the app developer after the build finishes.
241
+ def surface_warnings(log)
242
+ return unless File.exist?(log)
243
+
244
+ text = File.read(log)
245
+
246
+ if text.match?(/could not strip|string table not at the end/)
247
+ UI.warn "some binaries couldn't be stripped of debug symbols (harmless; " \
248
+ "binary is a bit larger than it could be)"
249
+ end
250
+ if text.match?(/incbin is incompatible with bitcode/)
251
+ # Bitcode was removed by Apple in Xcode 14 — irrelevant, don't alarm anyone.
252
+ nil
253
+ end
254
+
255
+ press_warnings = text.scan(/^.*(?:WARNING|Warning):(?!.*bitcode).*$/).size
256
+ UI.warn "tebako emitted #{press_warnings} warning(s) — details in the build log" if press_warnings.positive?
257
+ end
258
+
259
+ def press_env
260
+ env = {
261
+ "BUNDLE_FORCE_RUBY_PLATFORM" => "true",
262
+ "LG_VADDR" => "39"
263
+ }
264
+ env["CXX"] = SHIM if File.exist?(SHIM)
265
+ bison = `brew --prefix bison 2>/dev/null`.strip
266
+ env["PATH"] = "#{bison}/bin:#{ENV.fetch("PATH")}" unless bison.empty?
267
+ env
268
+ end
269
+
270
+ end
271
+ end
272
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/cli"
4
+ require "socket"
5
+ require_relative "../shellout"
6
+
7
+ module Everywhere
8
+ module Commands
9
+ # Run the app's normal dev server and open the desktop shell pointed at it.
10
+ # No packaging: the shell skips the sidecar when NATIVE_DEV_URL is set,
11
+ # so the edit-refresh loop is untouched Rails/Hanami.
12
+ class Dev < Dry::CLI::Command
13
+ desc "Run the dev server inside the desktop shell (live reload, no packaging)"
14
+
15
+ option :port, default: "3000", desc: "Dev server port"
16
+ option :shell_dir, default: nil, desc: "Path to the shell's src-tauri directory"
17
+
18
+ def call(port: "3000", shell_dir: nil, **)
19
+ framework = Framework.detect
20
+ config = Everywhere::Config.load(framework.root)
21
+ shell_dir ||= find_shell_dir
22
+
23
+ server_pid = nil
24
+ if port_open?(port)
25
+ UI.warn "something is ALREADY RUNNING on port #{port} — attaching to it instead of starting"
26
+ UI.warn "#{UI.dim("your Procfile.dev/watchers are NOT running; stop that server or use --port if this isn't yours")}"
27
+ else
28
+ UI.step("starting #{UI.bold(framework.name)} dev server #{UI.dim("(" + framework.dev_command + ")")}")
29
+ server_pid = Shellout.spawn({ "PORT" => port.to_s }, framework.dev_command, chdir: framework.root)
30
+ wait_for_port(port)
31
+ end
32
+
33
+ dev_url = "http://127.0.0.1:#{port}#{config.entry_path}"
34
+ UI.step("opening desktop shell → #{UI.cyan(dev_url)}")
35
+ Shellout.run!({ "NATIVE_DEV_URL" => dev_url, "NATIVE_CONFIG" => config.to_shell_json },
36
+ "cargo", "run", chdir: shell_dir)
37
+ ensure
38
+ if server_pid
39
+ Process.kill("TERM", -Process.getpgid(server_pid)) rescue Process.kill("TERM", server_pid) rescue nil
40
+ Process.wait(server_pid) rescue nil
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ def find_shell_dir
47
+ candidates = ["shell/src-tauri", "../shell/src-tauri", "src-tauri"]
48
+ found = candidates.find { |c| File.exist?(File.join(c, "tauri.conf.json")) }
49
+ found or UI.die!("couldn't find the shell (looked in #{candidates.join(", ")}); pass --shell-dir")
50
+ end
51
+
52
+ def port_open?(port)
53
+ TCPSocket.new("127.0.0.1", Integer(port)).close
54
+ true
55
+ rescue Errno::ECONNREFUSED, Errno::ETIMEDOUT
56
+ false
57
+ end
58
+
59
+ def wait_for_port(port, timeout: 60)
60
+ deadline = Time.now + timeout
61
+ sleep 0.5 until port_open?(port) || Time.now > deadline
62
+ UI.die!("dev server never came up on port #{port}") unless port_open?(port)
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/cli"
4
+ require_relative "../shellout"
5
+
6
+ module Everywhere
7
+ module Commands
8
+ # Check that this machine can build desktop apps.
9
+ class Doctor < Dry::CLI::Command
10
+ desc "Check the toolchain (tebako, rust, brew deps, Xcode 26 shims)"
11
+
12
+ def call(**)
13
+ ok = true
14
+ ok &= check("rbe-tebako installed") { Shellout.run?("rbe-tebako help > /dev/null 2>&1") }
15
+ ok &= check("cargo installed") { Shellout.run?("cargo --version > /dev/null 2>&1") }
16
+ ok &= check("homebrew bison") { !`brew --prefix bison 2>/dev/null`.strip.empty? }
17
+
18
+ if RUBY_PLATFORM.include?("darwin") && clang_major >= 21
19
+ ok &= check("Xcode #{clang_major} CXX shim (~/.tebako-shims/clang++)") do
20
+ File.executable?(File.expand_path("~/.tebako-shims/clang++"))
21
+ end
22
+ ok &= check("tebako force_ruby_platform patch") do
23
+ helper = `gem contents rbe-tebako 2>/dev/null`.lines.map(&:strip).find { |l| l.end_with?("deploy_helper.rb") }
24
+ helper.nil? || File.read(helper).include?(%(@force_ruby_platform = "true"
25
+ @nokogiri_option = "--no-use-system-libraries"))
26
+ end
27
+ ok &= check("tebako rbconfig warnflags patch") do
28
+ stash = Dir[File.expand_path("~/.tebako/deps/stash_*/lib/ruby/*/**/rbconfig.rb")]
29
+ stash.empty? || stash.all? { |f| File.read(f).include?("default-const-init") }
30
+ end
31
+ end
32
+
33
+ puts
34
+ ok ? UI.success("ready to build") : UI.bad("fix the items above (see project docs / memory for recipes)")
35
+ exit(1) unless ok
36
+ end
37
+
38
+ private
39
+
40
+ def check(label)
41
+ passed = yield
42
+ passed ? UI.ok(label) : UI.bad(label)
43
+ passed
44
+ rescue StandardError
45
+ UI.bad(label)
46
+ false
47
+ end
48
+
49
+ def clang_major
50
+ @clang_major ||= `clang --version 2>/dev/null`[/version (\d+)/, 1].to_i
51
+ end
52
+ end
53
+ end
54
+ end