ruflet 0.0.19 → 0.0.20
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/README.md +2 -3
- data/lib/ruflet/cli/build_command.rb +655 -107
- data/lib/ruflet/cli/flutter_sdk.rb +17 -3
- data/lib/ruflet/cli/new_command.rb +28 -21
- data/lib/ruflet/cli/run_command.rb +219 -56
- data/lib/ruflet/cli/templates.rb +8 -5
- data/lib/ruflet/cli.rb +1 -1
- data/lib/ruflet/hot_reload/harness.rb +32 -0
- data/lib/ruflet/hot_reload.rb +226 -0
- data/lib/ruflet/version.rb +1 -1
- metadata +3 -1
|
@@ -131,7 +131,21 @@ module Ruflet
|
|
|
131
131
|
end
|
|
132
132
|
|
|
133
133
|
def fvm_env
|
|
134
|
-
|
|
134
|
+
utf8_locale_env.merge("PATH" => "#{pub_cache_bin_dir}#{File::PATH_SEPARATOR}#{ENV.fetch('PATH', '')}")
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# CocoaPods aborts with "Unicode Normalization not appropriate for
|
|
138
|
+
# ASCII-8BIT" when it inherits a non-UTF-8 locale, which takes the whole
|
|
139
|
+
# iOS and macOS build with it. Hand the toolchain a UTF-8 locale unless
|
|
140
|
+
# the environment already has one.
|
|
141
|
+
def utf8_locale_env
|
|
142
|
+
return {} if utf8_locale?(ENV["LC_ALL"]) || utf8_locale?(ENV["LANG"])
|
|
143
|
+
|
|
144
|
+
{ "LANG" => "en_US.UTF-8", "LC_ALL" => "en_US.UTF-8" }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def utf8_locale?(value)
|
|
148
|
+
value.to_s.match?(/utf-?8/i)
|
|
135
149
|
end
|
|
136
150
|
|
|
137
151
|
def tools_from_flutter_bin(flutter_bin)
|
|
@@ -143,11 +157,11 @@ module Ruflet
|
|
|
143
157
|
{
|
|
144
158
|
flutter: flutter_bin,
|
|
145
159
|
dart: (File.executable?(dart) ? dart : "dart"),
|
|
146
|
-
env:
|
|
160
|
+
env: utf8_locale_env.merge(
|
|
147
161
|
"PATH" => "#{bin_dir}#{File::PATH_SEPARATOR}#{pub_cache_bin_dir}#{File::PATH_SEPARATOR}#{ENV.fetch('PATH', '')}",
|
|
148
162
|
"FLUTTER_ROOT" => sdk_root,
|
|
149
163
|
"FVM_FLUTTER_SDK" => sdk_root
|
|
150
|
-
|
|
164
|
+
)
|
|
151
165
|
}
|
|
152
166
|
end
|
|
153
167
|
|
|
@@ -25,6 +25,7 @@ module Ruflet
|
|
|
25
25
|
"lottie" => { package: "flet_lottie", alias: "ruflet_lottie" },
|
|
26
26
|
"map" => { package: "flet_map", alias: "ruflet_map" },
|
|
27
27
|
"permission_handler" => { package: "flet_permission_handler", alias: "ruflet_permission_handler" },
|
|
28
|
+
"qrcode_scanner" => { package: "ruflet_qrcode_scanner", alias: "ruflet_qrcode_scanner" },
|
|
28
29
|
"rive" => { package: "flet_rive", alias: "ruflet_rive" },
|
|
29
30
|
"secure_storage" => { package: "flet_secure_storage", alias: "ruflet_secure_storage" },
|
|
30
31
|
"video" => { package: "flet_video", alias: "ruflet_video" },
|
|
@@ -54,11 +55,11 @@ module Ruflet
|
|
|
54
55
|
puts "Run:"
|
|
55
56
|
puts " cd #{project_name}"
|
|
56
57
|
puts " bundle install"
|
|
57
|
-
puts "
|
|
58
|
+
puts " ruflet run main.rb"
|
|
58
59
|
puts
|
|
59
60
|
puts "Build:"
|
|
60
|
-
puts "
|
|
61
|
-
puts "
|
|
61
|
+
puts " ruflet build android --self"
|
|
62
|
+
puts " ruflet build ios --self"
|
|
62
63
|
0
|
|
63
64
|
end
|
|
64
65
|
|
|
@@ -81,21 +82,23 @@ module Ruflet
|
|
|
81
82
|
end
|
|
82
83
|
|
|
83
84
|
def resolve_ruflet_client_template_root
|
|
84
|
-
[
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
85
|
+
explicit = ENV["RUFLET_TEMPLATE_ROOT"].to_s.strip
|
|
86
|
+
unless explicit.empty?
|
|
87
|
+
explicit_template = [
|
|
88
|
+
explicit,
|
|
89
|
+
File.join(explicit, "templates", "ruflet_flutter_template")
|
|
90
|
+
].find { |path| valid_ruflet_template?(path) }
|
|
91
|
+
return explicit_template if explicit_template
|
|
88
92
|
end
|
|
89
93
|
|
|
90
|
-
|
|
91
|
-
|
|
94
|
+
sibling_template = File.expand_path(
|
|
95
|
+
"../../../../../../ruflet-template/templates/ruflet_flutter_template",
|
|
96
|
+
__dir__
|
|
97
|
+
)
|
|
98
|
+
return sibling_template if valid_ruflet_template?(sibling_template)
|
|
92
99
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
File.expand_path("../../../../../ruflet_client", __dir__)
|
|
96
|
-
].each do |fallback|
|
|
97
|
-
return fallback if Dir.exist?(fallback)
|
|
98
|
-
end
|
|
100
|
+
cached_template = cached_ruflet_client_template_root
|
|
101
|
+
return cached_template if valid_ruflet_template?(cached_template)
|
|
99
102
|
|
|
100
103
|
nil
|
|
101
104
|
end
|
|
@@ -255,12 +258,6 @@ module Ruflet
|
|
|
255
258
|
def write_default_ruflet_config(root, app_name)
|
|
256
259
|
File.write(File.join(root, "ruflet.yaml"), <<~YAML)
|
|
257
260
|
app:
|
|
258
|
-
name: #{app_name}
|
|
259
|
-
display_name: #{humanize_name(app_name)}
|
|
260
|
-
package_name: #{app_name.gsub(/[^a-zA-Z0-9_]+/, "_").downcase}
|
|
261
|
-
organization: com.example
|
|
262
|
-
version: 1.0.0+1
|
|
263
|
-
description: A new Ruflet app.
|
|
264
261
|
# Required for server-driven builds: `ruflet build ios`, `apk`, `web`, etc. without `--self`.
|
|
265
262
|
# Example: https://api.example.com
|
|
266
263
|
backend_url: ""
|
|
@@ -284,6 +281,16 @@ module Ruflet
|
|
|
284
281
|
YAML
|
|
285
282
|
|
|
286
283
|
File.write(File.join(root, "services.yaml"), <<~YAML)
|
|
284
|
+
# Application identity used by the Flutter build pipeline. Ruflet derives
|
|
285
|
+
# the mobile identifier as organization.package_name and passes it to
|
|
286
|
+
# change_app_package_name for Android and iOS builds.
|
|
287
|
+
app:
|
|
288
|
+
app_name: #{humanize_name(app_name)}
|
|
289
|
+
package_name: #{app_name.gsub(/[^a-zA-Z0-9_]+/, "_").downcase}
|
|
290
|
+
organization: com.example
|
|
291
|
+
version: 1.0.0+1
|
|
292
|
+
description: A new Ruflet app.
|
|
293
|
+
|
|
287
294
|
# Native capabilities that require platform permissions. Ruflet activates
|
|
288
295
|
# the matching client extensions and writes Android/iOS permissions.
|
|
289
296
|
# Supported services: camera, microphone, location, motion
|
|
@@ -20,11 +20,12 @@ module Ruflet
|
|
|
20
20
|
DEFAULT_CLIENT_UPDATE_INTERVAL = 6 * 60 * 60
|
|
21
21
|
|
|
22
22
|
def command_run(args)
|
|
23
|
-
options = { target: "mobile", requested_port: 8550 }
|
|
23
|
+
options = { target: "mobile", requested_port: 8550, reload: true }
|
|
24
24
|
parser = OptionParser.new do |o|
|
|
25
25
|
o.on("--web") { options[:target] = "web" }
|
|
26
26
|
o.on("--desktop") { options[:target] = "desktop" }
|
|
27
27
|
o.on("--port PORT", Integer) { |v| options[:requested_port] = v }
|
|
28
|
+
o.on("--no-reload") { options[:reload] = false }
|
|
28
29
|
end
|
|
29
30
|
parser.parse!(args)
|
|
30
31
|
|
|
@@ -47,18 +48,32 @@ module Ruflet
|
|
|
47
48
|
assets_dir = File.join(File.dirname(script_path), "assets")
|
|
48
49
|
env["RUFLET_ASSETS_DIR"] = assets_dir if File.directory?(assets_dir)
|
|
49
50
|
|
|
51
|
+
# The backend serves the web client itself so both share one origin.
|
|
52
|
+
if options[:target] == "web"
|
|
53
|
+
web_client_dir = detect_web_client_dir
|
|
54
|
+
if web_client_dir
|
|
55
|
+
env["RUFLET_WEB_CLIENT_DIR"] = web_client_dir
|
|
56
|
+
else
|
|
57
|
+
warn "Web client build not found and prebuilt download failed."
|
|
58
|
+
warn "Build one with `ruflet build web`, or set RUFLET_CLIENT_DIR."
|
|
59
|
+
return 1
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
50
63
|
print_run_banner(target: options[:target], requested_port: options[:requested_port], port: selected_port)
|
|
51
64
|
print_mobile_qr_hint(port: selected_port) if options[:target] == "mobile"
|
|
65
|
+
print_hot_reload_banner if options[:reload]
|
|
52
66
|
|
|
53
67
|
gemfile_path = find_nearest_gemfile(Dir.pwd)
|
|
54
|
-
cmd = build_runtime_command(script_path, gemfile_path: gemfile_path, env: env)
|
|
68
|
+
cmd = build_runtime_command(script_path, gemfile_path: gemfile_path, env: env, reload: options[:reload])
|
|
55
69
|
return 1 unless cmd
|
|
56
70
|
|
|
57
|
-
|
|
71
|
+
run_state = { child_pid: Process.spawn(env, *cmd, pgroup: true), restart: false }
|
|
72
|
+
reload_input_thread = options[:reload] ? start_reload_input_thread(run_state) : nil
|
|
58
73
|
launched_client_pids = launch_target_client(options[:target], selected_port)
|
|
59
74
|
forward_signal = lambda do |signal|
|
|
60
75
|
begin
|
|
61
|
-
Process.kill(signal, -child_pid)
|
|
76
|
+
Process.kill(signal, -run_state[:child_pid])
|
|
62
77
|
rescue Errno::ESRCH
|
|
63
78
|
nil
|
|
64
79
|
end
|
|
@@ -67,15 +82,28 @@ module Ruflet
|
|
|
67
82
|
previous_int = Signal.trap("INT") { forward_signal.call("INT") }
|
|
68
83
|
previous_term = Signal.trap("TERM") { forward_signal.call("TERM") }
|
|
69
84
|
|
|
70
|
-
|
|
71
|
-
|
|
85
|
+
loop do
|
|
86
|
+
_pid, status = Process.wait2(run_state[:child_pid])
|
|
87
|
+
return status.success? ? 0 : (status.exitstatus || 1) unless run_state[:restart]
|
|
88
|
+
|
|
89
|
+
# Full restart requested ("R"): respawn the backend; connected
|
|
90
|
+
# clients reconnect and re-register on their own (Flet-style).
|
|
91
|
+
run_state[:restart] = false
|
|
92
|
+
puts "Restarting app..."
|
|
93
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
94
|
+
run_state[:child_pid] = Process.spawn(env, *cmd, pgroup: true)
|
|
95
|
+
wait_for_server_boot(selected_port)
|
|
96
|
+
elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round
|
|
97
|
+
puts "Restarted in #{elapsed_ms}ms"
|
|
98
|
+
end
|
|
72
99
|
ensure
|
|
100
|
+
reload_input_thread&.kill if defined?(reload_input_thread)
|
|
73
101
|
Signal.trap("INT", previous_int) if defined?(previous_int) && previous_int
|
|
74
102
|
Signal.trap("TERM", previous_term) if defined?(previous_term) && previous_term
|
|
75
103
|
|
|
76
|
-
if defined?(
|
|
104
|
+
if defined?(run_state) && run_state && run_state[:child_pid]
|
|
77
105
|
begin
|
|
78
|
-
Process.kill("TERM", -child_pid)
|
|
106
|
+
Process.kill("TERM", -run_state[:child_pid])
|
|
79
107
|
rescue Errno::ESRCH
|
|
80
108
|
nil
|
|
81
109
|
end
|
|
@@ -97,16 +125,111 @@ module Ruflet
|
|
|
97
125
|
|
|
98
126
|
private
|
|
99
127
|
|
|
100
|
-
def build_runtime_command(script_path, gemfile_path:, env:)
|
|
128
|
+
def build_runtime_command(script_path, gemfile_path:, env:, reload: false)
|
|
129
|
+
entry_script = script_path
|
|
130
|
+
if reload
|
|
131
|
+
env["RUFLET_APP_SCRIPT"] = script_path
|
|
132
|
+
env["RUFLET_WATCH_ROOT"] = File.dirname(script_path)
|
|
133
|
+
env["RUFLET_BOOTSNAP_DIR"] = bootsnap_cache_dir(script_path)
|
|
134
|
+
entry_script = hot_reload_harness_path
|
|
135
|
+
end
|
|
136
|
+
|
|
101
137
|
if gemfile_path
|
|
102
138
|
env["BUNDLE_GEMFILE"] = gemfile_path
|
|
103
139
|
bundle_ready = system(env, RbConfig.ruby, "-S", "bundle", "check", out: File::NULL, err: File::NULL)
|
|
104
140
|
return nil unless bundle_ready || system(env, RbConfig.ruby, "-S", "bundle", "install")
|
|
105
141
|
|
|
106
|
-
return [RbConfig.ruby, "-rbundler/setup",
|
|
142
|
+
return [RbConfig.ruby, "-rbundler/setup", entry_script]
|
|
107
143
|
end
|
|
108
144
|
|
|
109
|
-
[RbConfig.ruby,
|
|
145
|
+
[RbConfig.ruby, entry_script]
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def hot_reload_harness_path
|
|
149
|
+
File.expand_path("../hot_reload/harness.rb", __dir__)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Persistent, per-app bootsnap cache so it stays warm across restarts.
|
|
153
|
+
# Kept under ~/.ruflet (not the project, so nothing to gitignore) and
|
|
154
|
+
# keyed by app path + Ruby version so bytecode never crosses apps or
|
|
155
|
+
# incompatible VMs.
|
|
156
|
+
def bootsnap_cache_dir(script_path)
|
|
157
|
+
app_key = File.dirname(File.expand_path(script_path)).gsub(/[^a-zA-Z0-9]+/, "-").delete_prefix("-")
|
|
158
|
+
File.join(Dir.home, ".ruflet", "bootsnap", RUBY_VERSION, app_key)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def manual_reload_supported?
|
|
162
|
+
$stdin.tty? && Signal.list.key?("USR1")
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def print_hot_reload_banner
|
|
166
|
+
hint = manual_reload_supported? ? "; press \"r\" to reload, \"R\" to restart" : ""
|
|
167
|
+
puts "Hot reload: watching *.rb#{hint} (disable with --no-reload)"
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def start_reload_input_thread(run_state)
|
|
171
|
+
return nil unless manual_reload_supported?
|
|
172
|
+
|
|
173
|
+
Thread.new do
|
|
174
|
+
loop do
|
|
175
|
+
key = read_reload_key
|
|
176
|
+
break if key.nil?
|
|
177
|
+
break unless handle_reload_command(key, run_state)
|
|
178
|
+
end
|
|
179
|
+
rescue StandardError
|
|
180
|
+
nil
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Single keypress, no Enter required. getch puts the terminal in raw
|
|
185
|
+
# mode only for the duration of the read; intr keeps Ctrl-C working.
|
|
186
|
+
def read_reload_key
|
|
187
|
+
$stdin.getch(intr: true)
|
|
188
|
+
rescue ArgumentError
|
|
189
|
+
# Older Rubies without the intr keyword.
|
|
190
|
+
$stdin.getch
|
|
191
|
+
rescue StandardError
|
|
192
|
+
nil
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Returns false when the child process is gone and the thread should end.
|
|
196
|
+
def handle_reload_command(command, run_state)
|
|
197
|
+
case command
|
|
198
|
+
when "r"
|
|
199
|
+
Process.kill("USR1", run_state[:child_pid])
|
|
200
|
+
when "R"
|
|
201
|
+
run_state[:restart] = true
|
|
202
|
+
Process.kill("TERM", -run_state[:child_pid])
|
|
203
|
+
escalate_child_shutdown(run_state[:child_pid])
|
|
204
|
+
end
|
|
205
|
+
true
|
|
206
|
+
rescue Errno::ESRCH
|
|
207
|
+
run_state[:restart] = false
|
|
208
|
+
false
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# An app can block TERM (bad traps, stuck threads); force the restart
|
|
212
|
+
# through with KILL if the child is still running after the grace period.
|
|
213
|
+
def escalate_child_shutdown(child_pid, grace_seconds: 3)
|
|
214
|
+
Thread.new do
|
|
215
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + grace_seconds
|
|
216
|
+
loop do
|
|
217
|
+
sleep 0.1
|
|
218
|
+
begin
|
|
219
|
+
Process.kill(0, child_pid)
|
|
220
|
+
rescue Errno::ESRCH
|
|
221
|
+
break
|
|
222
|
+
end
|
|
223
|
+
next unless Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
|
|
224
|
+
|
|
225
|
+
begin
|
|
226
|
+
Process.kill("KILL", -child_pid)
|
|
227
|
+
rescue Errno::ESRCH
|
|
228
|
+
nil
|
|
229
|
+
end
|
|
230
|
+
break
|
|
231
|
+
end
|
|
232
|
+
end
|
|
110
233
|
end
|
|
111
234
|
|
|
112
235
|
def apply_local_ruflet_dev_overrides(env)
|
|
@@ -177,34 +300,24 @@ module Ruflet
|
|
|
177
300
|
end
|
|
178
301
|
end
|
|
179
302
|
|
|
303
|
+
# The backend serves the web client on its own port, so the client loads
|
|
304
|
+
# and opens its websocket on the same origin. The explicit url parameter
|
|
305
|
+
# is kept because prebuilt production clients have no default URL and use
|
|
306
|
+
# it to select the websocket transport.
|
|
180
307
|
def launch_web_client(port)
|
|
181
|
-
web_dir = detect_web_client_dir
|
|
182
|
-
unless web_dir
|
|
183
|
-
warn "Web client build not found and prebuilt download failed."
|
|
184
|
-
return []
|
|
185
|
-
end
|
|
186
|
-
|
|
187
|
-
web_port = find_available_port(port + 1)
|
|
188
|
-
web_pid = Process.spawn("python3", "-m", "http.server", web_port.to_s, "--bind", "127.0.0.1", chdir: web_dir, out: File::NULL, err: File::NULL)
|
|
189
|
-
Process.detach(web_pid)
|
|
190
|
-
wait_for_server_boot(web_port)
|
|
191
308
|
backend_url = "http://localhost:#{port}"
|
|
192
|
-
web_url = "
|
|
309
|
+
web_url = "#{backend_url}/?#{URI.encode_www_form(url: backend_url)}"
|
|
193
310
|
browser_pid = open_in_browser_app_mode(web_url)
|
|
194
311
|
open_in_browser(web_url) if browser_pid.nil?
|
|
195
312
|
puts "Ruflet web client: #{web_url}"
|
|
196
313
|
puts "Ruflet backend ws: ws://localhost:#{port}/ws"
|
|
197
|
-
[
|
|
198
|
-
rescue Errno::ENOENT
|
|
199
|
-
warn "python3 is required to host web client locally."
|
|
200
|
-
warn "Install Python 3 and rerun."
|
|
201
|
-
[]
|
|
314
|
+
[browser_pid].compact
|
|
202
315
|
rescue StandardError => e
|
|
203
316
|
warn "Failed to launch web client: #{e.class}: #{e.message}"
|
|
204
317
|
[]
|
|
205
318
|
end
|
|
206
319
|
|
|
207
|
-
def wait_for_server_boot(port, timeout_seconds: 10)
|
|
320
|
+
def wait_for_server_boot(port, timeout_seconds: 10, poll_interval: 0.01)
|
|
208
321
|
Timeout.timeout(timeout_seconds) do
|
|
209
322
|
loop do
|
|
210
323
|
begin
|
|
@@ -213,7 +326,7 @@ module Ruflet
|
|
|
213
326
|
sock.close
|
|
214
327
|
break
|
|
215
328
|
rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH
|
|
216
|
-
sleep
|
|
329
|
+
sleep poll_interval
|
|
217
330
|
end
|
|
218
331
|
end
|
|
219
332
|
end
|
|
@@ -296,7 +409,10 @@ module Ruflet
|
|
|
296
409
|
return
|
|
297
410
|
end
|
|
298
411
|
|
|
299
|
-
|
|
412
|
+
# The Flutter client took the URL as argv; a Ruflet app used as the
|
|
413
|
+
# client reads RUFLET_URL, so it connects instead of showing its own
|
|
414
|
+
# launcher. Pass both so either client auto-connects.
|
|
415
|
+
pid = Process.spawn({ "RUFLET_URL" => url }, *cmd, out: File::NULL, err: File::NULL)
|
|
300
416
|
Process.detach(pid)
|
|
301
417
|
if !pid
|
|
302
418
|
warn "Failed to launch desktop client: #{cmd.first}"
|
|
@@ -309,6 +425,38 @@ module Ruflet
|
|
|
309
425
|
[]
|
|
310
426
|
end
|
|
311
427
|
|
|
428
|
+
# A Ruflet app used as the preview client builds its native project into
|
|
429
|
+
# build/client, so its Flutter output is one level deeper than a bare
|
|
430
|
+
# Flutter client's. Search both, and never assume the app is named
|
|
431
|
+
# ruflet_client: the bundle takes the app's own display name.
|
|
432
|
+
def client_build_roots(root)
|
|
433
|
+
[File.join(root, "build", "client"), root].select { |dir| Dir.exist?(dir) }
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
def executable_file?(path)
|
|
437
|
+
File.file?(path) && File.executable?(path)
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
# A Flutter project's own web/ folder holds a source index.html, so
|
|
441
|
+
# index.html alone would match an unbuilt project. Require a compiled
|
|
442
|
+
# entrypoint as well.
|
|
443
|
+
WEB_BUILD_MARKERS = %w[flutter_bootstrap.js main.dart.js flutter.js].freeze
|
|
444
|
+
|
|
445
|
+
def built_web_client_dir?(dir)
|
|
446
|
+
return false unless Dir.exist?(dir) && File.file?(File.join(dir, "index.html"))
|
|
447
|
+
|
|
448
|
+
WEB_BUILD_MARKERS.any? { |marker| File.file?(File.join(dir, marker)) }
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
def macos_app_executable(app_bundle)
|
|
452
|
+
macos_dir = File.join(app_bundle, "Contents", "MacOS")
|
|
453
|
+
return nil unless Dir.exist?(macos_dir)
|
|
454
|
+
|
|
455
|
+
Dir.children(macos_dir)
|
|
456
|
+
.map { |entry| File.join(macos_dir, entry) }
|
|
457
|
+
.find { |path| executable_file?(path) }
|
|
458
|
+
end
|
|
459
|
+
|
|
312
460
|
def detect_desktop_client_command(url)
|
|
313
461
|
root = ENV["RUFLET_CLIENT_DIR"]
|
|
314
462
|
root = File.expand_path("ruflet_client", Dir.pwd) if root.to_s.strip.empty?
|
|
@@ -317,28 +465,42 @@ module Ruflet
|
|
|
317
465
|
return nil unless root && Dir.exist?(root)
|
|
318
466
|
|
|
319
467
|
host_os = RbConfig::CONFIG["host_os"]
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
.find { |path| File.file?(path)
|
|
341
|
-
|
|
468
|
+
client_build_roots(root).each do |base|
|
|
469
|
+
if host_os.match?(/darwin/i)
|
|
470
|
+
search = %w[Release Debug].map { |config| File.join(base, "build", "macos", "Build", "Products", config) }
|
|
471
|
+
search << File.join(base, "desktop")
|
|
472
|
+
search.each do |dir|
|
|
473
|
+
next unless Dir.exist?(dir)
|
|
474
|
+
|
|
475
|
+
Dir.glob(File.join(dir, "*.app")).sort.each do |app_bundle|
|
|
476
|
+
executable = macos_app_executable(app_bundle)
|
|
477
|
+
return [executable, url] if executable
|
|
478
|
+
end
|
|
479
|
+
end
|
|
480
|
+
elsif host_os.match?(/mswin|mingw|cygwin/i)
|
|
481
|
+
search = [
|
|
482
|
+
File.join(base, "build", "windows", "x64", "runner", "Release"),
|
|
483
|
+
File.join(base, "desktop")
|
|
484
|
+
]
|
|
485
|
+
search.each do |dir|
|
|
486
|
+
next unless Dir.exist?(dir)
|
|
487
|
+
|
|
488
|
+
exe = Dir.glob(File.join(dir, "*.exe")).sort.find { |path| File.file?(path) }
|
|
489
|
+
return [exe, url] if exe
|
|
490
|
+
end
|
|
491
|
+
else
|
|
492
|
+
search = [
|
|
493
|
+
File.join(base, "build", "linux", "x64", "release", "bundle"),
|
|
494
|
+
File.join(base, "desktop")
|
|
495
|
+
]
|
|
496
|
+
search.each do |dir|
|
|
497
|
+
next unless Dir.exist?(dir)
|
|
498
|
+
|
|
499
|
+
candidate = Dir.children(dir).sort
|
|
500
|
+
.map { |entry| File.join(dir, entry) }
|
|
501
|
+
.find { |path| executable_file?(path) }
|
|
502
|
+
return [candidate, url] if candidate
|
|
503
|
+
end
|
|
342
504
|
end
|
|
343
505
|
end
|
|
344
506
|
|
|
@@ -352,10 +514,11 @@ module Ruflet
|
|
|
352
514
|
root ||= ensure_prebuilt_client(web: true)
|
|
353
515
|
return nil unless root && Dir.exist?(root)
|
|
354
516
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
517
|
+
client_build_roots(root).each do |base|
|
|
518
|
+
[File.join(base, "build", "web"), File.join(base, "web")].each do |dir|
|
|
519
|
+
return dir if built_web_client_dir?(dir)
|
|
520
|
+
end
|
|
521
|
+
end
|
|
359
522
|
|
|
360
523
|
nil
|
|
361
524
|
end
|
data/lib/ruflet/cli/templates.rb
CHANGED
|
@@ -7,7 +7,10 @@ module Ruflet
|
|
|
7
7
|
Ruflet.run do |page|
|
|
8
8
|
page.title = "Counter Demo"
|
|
9
9
|
count = 0
|
|
10
|
-
count_text = text(
|
|
10
|
+
count_text = text(
|
|
11
|
+
value: count.to_s,
|
|
12
|
+
style: { size: 40, weight: "w700" }
|
|
13
|
+
)
|
|
11
14
|
page.floating_action_button = fab(
|
|
12
15
|
icon: "add",
|
|
13
16
|
on_click: ->(_e) do
|
|
@@ -23,7 +26,7 @@ module Ruflet
|
|
|
23
26
|
alignment: Ruflet::MainAxisAlignment::CENTER,
|
|
24
27
|
horizontal_alignment: Ruflet::CrossAxisAlignment::CENTER,
|
|
25
28
|
children: [
|
|
26
|
-
text("You have pushed the button this many times:"),
|
|
29
|
+
text(value: "You have pushed the button this many times:"),
|
|
27
30
|
count_text
|
|
28
31
|
]
|
|
29
32
|
)
|
|
@@ -54,14 +57,14 @@ module Ruflet
|
|
|
54
57
|
## Run
|
|
55
58
|
|
|
56
59
|
```bash
|
|
57
|
-
|
|
60
|
+
ruflet run main
|
|
58
61
|
```
|
|
59
62
|
|
|
60
63
|
## Build
|
|
61
64
|
|
|
62
65
|
```bash
|
|
63
|
-
|
|
64
|
-
|
|
66
|
+
ruflet build apk
|
|
67
|
+
ruflet build ios
|
|
65
68
|
```
|
|
66
69
|
MD
|
|
67
70
|
end
|
data/lib/ruflet/cli.rb
CHANGED
|
@@ -66,7 +66,7 @@ module Ruflet
|
|
|
66
66
|
ruflet --version
|
|
67
67
|
ruflet create <appname>
|
|
68
68
|
ruflet new <appname>
|
|
69
|
-
ruflet run [scriptname|path] [--web|--desktop] [--port PORT]
|
|
69
|
+
ruflet run [scriptname|path] [--web|--desktop] [--port PORT] [--no-reload]
|
|
70
70
|
ruflet update [web|desktop|all] [--check] [--force] [--platform PLATFORM]
|
|
71
71
|
ruflet debug [scriptname|path]
|
|
72
72
|
ruflet build <apk|android|ios|aab|web|macos|windows|linux> [--self] [--verbose]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Child-process entrypoint used by `ruflet run` when hot reload is enabled.
|
|
4
|
+
# The CLI spawns `ruby [-rbundler/setup] harness.rb` with:
|
|
5
|
+
# RUFLET_APP_SCRIPT absolute path to the application script (required)
|
|
6
|
+
# RUFLET_WATCH_ROOT directory to watch for *.rb changes (optional)
|
|
7
|
+
# RUFLET_BOOTSNAP_DIR bootsnap cache directory (optional)
|
|
8
|
+
|
|
9
|
+
# Bootsnap caches compiled bytecode and $LOAD_PATH resolution, which cuts the
|
|
10
|
+
# cold-boot cost paid on every full restart ("R"). It is optional: used only
|
|
11
|
+
# when the app bundles it (add `gem "bootsnap"` to speed restarts up). Set up
|
|
12
|
+
# before requiring the framework so those requires hit the cache.
|
|
13
|
+
if (cache_dir = ENV["RUFLET_BOOTSNAP_DIR"].to_s) && !cache_dir.empty?
|
|
14
|
+
begin
|
|
15
|
+
require "bootsnap"
|
|
16
|
+
Bootsnap.setup(
|
|
17
|
+
cache_dir: cache_dir,
|
|
18
|
+
load_path_cache: true,
|
|
19
|
+
compile_cache_iseq: true,
|
|
20
|
+
compile_cache_yaml: false
|
|
21
|
+
)
|
|
22
|
+
rescue LoadError
|
|
23
|
+
# bootsnap not in the bundle; boot without it.
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
require_relative "../hot_reload"
|
|
28
|
+
|
|
29
|
+
script = ENV["RUFLET_APP_SCRIPT"].to_s
|
|
30
|
+
abort "ruflet hot reload: RUFLET_APP_SCRIPT is not set" if script.empty?
|
|
31
|
+
|
|
32
|
+
Ruflet::HotReload.run(script: script, watch_root: ENV["RUFLET_WATCH_ROOT"])
|