ruflet 0.0.19 → 0.0.21
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 +867 -112
- data/lib/ruflet/cli/flutter_sdk.rb +17 -3
- data/lib/ruflet/cli/new_command.rb +36 -23
- data/lib/ruflet/cli/run_command.rb +235 -61
- data/lib/ruflet/cli/templates.rb +8 -5
- data/lib/ruflet/cli/update_command.rb +6 -6
- 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
|
|
@@ -202,10 +205,16 @@ module Ruflet
|
|
|
202
205
|
git_revision(File.expand_path("../..", template_root))
|
|
203
206
|
end
|
|
204
207
|
|
|
208
|
+
# Always record where the client came from. A template resolved from a
|
|
209
|
+
# local checkout may have no readable revision, and skipping the marker
|
|
210
|
+
# entirely left the copied client indistinguishable from one that was
|
|
211
|
+
# never stamped. "local" never matches an expected revision, so the client
|
|
212
|
+
# is refreshed rather than treated as current.
|
|
205
213
|
def write_client_template_revision(target, revision)
|
|
206
|
-
|
|
214
|
+
value = revision.to_s.strip
|
|
215
|
+
value = "local" if value.empty?
|
|
207
216
|
|
|
208
|
-
File.write(File.join(target, ".ruflet-template-revision"), "#{
|
|
217
|
+
File.write(File.join(target, ".ruflet-template-revision"), "#{value}\n")
|
|
209
218
|
end
|
|
210
219
|
|
|
211
220
|
def client_template_current?(target, template_root)
|
|
@@ -255,12 +264,6 @@ module Ruflet
|
|
|
255
264
|
def write_default_ruflet_config(root, app_name)
|
|
256
265
|
File.write(File.join(root, "ruflet.yaml"), <<~YAML)
|
|
257
266
|
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
267
|
# Required for server-driven builds: `ruflet build ios`, `apk`, `web`, etc. without `--self`.
|
|
265
268
|
# Example: https://api.example.com
|
|
266
269
|
backend_url: ""
|
|
@@ -284,6 +287,16 @@ module Ruflet
|
|
|
284
287
|
YAML
|
|
285
288
|
|
|
286
289
|
File.write(File.join(root, "services.yaml"), <<~YAML)
|
|
290
|
+
# Application identity used by the Flutter build pipeline. Ruflet derives
|
|
291
|
+
# the mobile identifier as organization.package_name and passes it to
|
|
292
|
+
# change_app_package_name for Android and iOS builds.
|
|
293
|
+
app:
|
|
294
|
+
app_name: #{humanize_name(app_name)}
|
|
295
|
+
package_name: #{app_name.gsub(/[^a-zA-Z0-9_]+/, "_").downcase}
|
|
296
|
+
organization: com.example
|
|
297
|
+
version: 1.0.0+1
|
|
298
|
+
description: A new Ruflet app.
|
|
299
|
+
|
|
287
300
|
# Native capabilities that require platform permissions. Ruflet activates
|
|
288
301
|
# the matching client extensions and writes Android/iOS permissions.
|
|
289
302
|
# 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,23 @@ module Ruflet
|
|
|
177
300
|
end
|
|
178
301
|
end
|
|
179
302
|
|
|
303
|
+
# The backend serves the web client on its own port, so the client reads
|
|
304
|
+
# the origin it was loaded from and opens its websocket there. No URL is
|
|
305
|
+
# passed in the query string: nothing in the client parses one.
|
|
180
306
|
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
307
|
backend_url = "http://localhost:#{port}"
|
|
192
|
-
web_url = "
|
|
308
|
+
web_url = "#{backend_url}/"
|
|
193
309
|
browser_pid = open_in_browser_app_mode(web_url)
|
|
194
310
|
open_in_browser(web_url) if browser_pid.nil?
|
|
195
311
|
puts "Ruflet web client: #{web_url}"
|
|
196
312
|
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
|
-
[]
|
|
313
|
+
[browser_pid].compact
|
|
202
314
|
rescue StandardError => e
|
|
203
315
|
warn "Failed to launch web client: #{e.class}: #{e.message}"
|
|
204
316
|
[]
|
|
205
317
|
end
|
|
206
318
|
|
|
207
|
-
def wait_for_server_boot(port, timeout_seconds: 10)
|
|
319
|
+
def wait_for_server_boot(port, timeout_seconds: 10, poll_interval: 0.01)
|
|
208
320
|
Timeout.timeout(timeout_seconds) do
|
|
209
321
|
loop do
|
|
210
322
|
begin
|
|
@@ -213,7 +325,7 @@ module Ruflet
|
|
|
213
325
|
sock.close
|
|
214
326
|
break
|
|
215
327
|
rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH
|
|
216
|
-
sleep
|
|
328
|
+
sleep poll_interval
|
|
217
329
|
end
|
|
218
330
|
end
|
|
219
331
|
end
|
|
@@ -296,7 +408,10 @@ module Ruflet
|
|
|
296
408
|
return
|
|
297
409
|
end
|
|
298
410
|
|
|
299
|
-
|
|
411
|
+
# The Flutter client took the URL as argv; a Ruflet app used as the
|
|
412
|
+
# client reads RUFLET_URL, so it connects instead of showing its own
|
|
413
|
+
# launcher. Pass both so either client auto-connects.
|
|
414
|
+
pid = Process.spawn({ "RUFLET_URL" => url }, *cmd, out: File::NULL, err: File::NULL)
|
|
300
415
|
Process.detach(pid)
|
|
301
416
|
if !pid
|
|
302
417
|
warn "Failed to launch desktop client: #{cmd.first}"
|
|
@@ -309,6 +424,38 @@ module Ruflet
|
|
|
309
424
|
[]
|
|
310
425
|
end
|
|
311
426
|
|
|
427
|
+
# A Ruflet app used as the preview client builds its native project into
|
|
428
|
+
# build/client, so its Flutter output is one level deeper than a bare
|
|
429
|
+
# Flutter client's. Search both, and never assume the app is named
|
|
430
|
+
# ruflet_client: the bundle takes the app's own display name.
|
|
431
|
+
def client_build_roots(root)
|
|
432
|
+
[File.join(root, "build", "client"), root].select { |dir| Dir.exist?(dir) }
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
def executable_file?(path)
|
|
436
|
+
File.file?(path) && File.executable?(path)
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
# A Flutter project's own web/ folder holds a source index.html, so
|
|
440
|
+
# index.html alone would match an unbuilt project. Require a compiled
|
|
441
|
+
# entrypoint as well.
|
|
442
|
+
WEB_BUILD_MARKERS = %w[flutter_bootstrap.js main.dart.js flutter.js].freeze
|
|
443
|
+
|
|
444
|
+
def built_web_client_dir?(dir)
|
|
445
|
+
return false unless Dir.exist?(dir) && File.file?(File.join(dir, "index.html"))
|
|
446
|
+
|
|
447
|
+
WEB_BUILD_MARKERS.any? { |marker| File.file?(File.join(dir, marker)) }
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
def macos_app_executable(app_bundle)
|
|
451
|
+
macos_dir = File.join(app_bundle, "Contents", "MacOS")
|
|
452
|
+
return nil unless Dir.exist?(macos_dir)
|
|
453
|
+
|
|
454
|
+
Dir.children(macos_dir)
|
|
455
|
+
.map { |entry| File.join(macos_dir, entry) }
|
|
456
|
+
.find { |path| executable_file?(path) }
|
|
457
|
+
end
|
|
458
|
+
|
|
312
459
|
def detect_desktop_client_command(url)
|
|
313
460
|
root = ENV["RUFLET_CLIENT_DIR"]
|
|
314
461
|
root = File.expand_path("ruflet_client", Dir.pwd) if root.to_s.strip.empty?
|
|
@@ -317,28 +464,42 @@ module Ruflet
|
|
|
317
464
|
return nil unless root && Dir.exist?(root)
|
|
318
465
|
|
|
319
466
|
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
|
-
|
|
467
|
+
client_build_roots(root).each do |base|
|
|
468
|
+
if host_os.match?(/darwin/i)
|
|
469
|
+
search = %w[Release Debug].map { |config| File.join(base, "build", "macos", "Build", "Products", config) }
|
|
470
|
+
search << File.join(base, "desktop")
|
|
471
|
+
search.each do |dir|
|
|
472
|
+
next unless Dir.exist?(dir)
|
|
473
|
+
|
|
474
|
+
Dir.glob(File.join(dir, "*.app")).sort.each do |app_bundle|
|
|
475
|
+
executable = macos_app_executable(app_bundle)
|
|
476
|
+
return [executable, url] if executable
|
|
477
|
+
end
|
|
478
|
+
end
|
|
479
|
+
elsif host_os.match?(/mswin|mingw|cygwin/i)
|
|
480
|
+
search = [
|
|
481
|
+
File.join(base, "build", "windows", "x64", "runner", "Release"),
|
|
482
|
+
File.join(base, "desktop")
|
|
483
|
+
]
|
|
484
|
+
search.each do |dir|
|
|
485
|
+
next unless Dir.exist?(dir)
|
|
486
|
+
|
|
487
|
+
exe = Dir.glob(File.join(dir, "*.exe")).sort.find { |path| File.file?(path) }
|
|
488
|
+
return [exe, url] if exe
|
|
489
|
+
end
|
|
490
|
+
else
|
|
491
|
+
search = [
|
|
492
|
+
File.join(base, "build", "linux", "x64", "release", "bundle"),
|
|
493
|
+
File.join(base, "desktop")
|
|
494
|
+
]
|
|
495
|
+
search.each do |dir|
|
|
496
|
+
next unless Dir.exist?(dir)
|
|
497
|
+
|
|
498
|
+
candidate = Dir.children(dir).sort
|
|
499
|
+
.map { |entry| File.join(dir, entry) }
|
|
500
|
+
.find { |path| executable_file?(path) }
|
|
501
|
+
return [candidate, url] if candidate
|
|
502
|
+
end
|
|
342
503
|
end
|
|
343
504
|
end
|
|
344
505
|
|
|
@@ -352,10 +513,11 @@ module Ruflet
|
|
|
352
513
|
root ||= ensure_prebuilt_client(web: true)
|
|
353
514
|
return nil unless root && Dir.exist?(root)
|
|
354
515
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
516
|
+
client_build_roots(root).each do |base|
|
|
517
|
+
[File.join(base, "build", "web"), File.join(base, "web")].each do |dir|
|
|
518
|
+
return dir if built_web_client_dir?(dir)
|
|
519
|
+
end
|
|
520
|
+
end
|
|
359
521
|
|
|
360
522
|
nil
|
|
361
523
|
end
|
|
@@ -440,23 +602,35 @@ module Ruflet
|
|
|
440
602
|
nil
|
|
441
603
|
end
|
|
442
604
|
|
|
605
|
+
# This decides both "the download installed correctly" and "the cache is
|
|
606
|
+
# still usable, skip the network". It has to agree with what `ruflet run`
|
|
607
|
+
# will actually accept later, or a half-extracted cache reports success
|
|
608
|
+
# here and then fails to launch -- and, because a present cache short
|
|
609
|
+
# circuits the download, never repairs itself without --force.
|
|
443
610
|
def prebuilt_assets_present?(root, web:, desktop:, platform: nil)
|
|
444
|
-
ok_web = !web ||
|
|
611
|
+
ok_web = !web || built_web_client_dir?(File.join(root, "web"))
|
|
445
612
|
ok_desktop = !desktop || prebuilt_desktop_present?(root, platform: platform)
|
|
446
613
|
ok_web && ok_desktop
|
|
447
614
|
end
|
|
448
615
|
|
|
616
|
+
# The prebuilt client is whatever `ruflet build` produced from Ruflet
|
|
617
|
+
# Explorer, so its bundle and binary carry that project's app name --
|
|
618
|
+
# "Ruflet Explorer.app", not the old "ruflet_client.app". Match on shape
|
|
619
|
+
# the way detect_desktop_client_command does rather than on a fixed name.
|
|
449
620
|
def prebuilt_desktop_present?(root, platform: nil)
|
|
450
621
|
platform ||= host_platform_name
|
|
451
622
|
return false if platform.nil?
|
|
452
623
|
|
|
624
|
+
desktop = File.join(root, "desktop")
|
|
625
|
+
return false unless Dir.exist?(desktop)
|
|
626
|
+
|
|
453
627
|
case platform
|
|
454
628
|
when "macos"
|
|
455
|
-
|
|
629
|
+
Dir.glob(File.join(desktop, "*.app")).any? { |app_bundle| macos_app_executable(app_bundle) }
|
|
456
630
|
when "linux"
|
|
457
|
-
|
|
631
|
+
Dir.children(desktop).any? { |entry| executable_file?(File.join(desktop, entry)) }
|
|
458
632
|
when "windows"
|
|
459
|
-
|
|
633
|
+
Dir.glob(File.join(desktop, "*.exe")).any? { |path| File.file?(path) }
|
|
460
634
|
else
|
|
461
635
|
false
|
|
462
636
|
end
|
|
@@ -660,7 +834,7 @@ module Ruflet
|
|
|
660
834
|
return if read_client_manifest(root)
|
|
661
835
|
|
|
662
836
|
assets = []
|
|
663
|
-
assets << { "kind" => "web", "platform" => platform, "asset_name" => nil } if
|
|
837
|
+
assets << { "kind" => "web", "platform" => platform, "asset_name" => nil } if built_web_client_dir?(File.join(root, "web"))
|
|
664
838
|
if prebuilt_desktop_present?(root, platform: platform)
|
|
665
839
|
assets << { "kind" => "desktop", "platform" => platform, "asset_name" => nil }
|
|
666
840
|
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
|
|
@@ -108,12 +108,12 @@ module Ruflet
|
|
|
108
108
|
|
|
109
109
|
installed = true
|
|
110
110
|
targets.each do |target|
|
|
111
|
-
present =
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
111
|
+
present = prebuilt_assets_present?(
|
|
112
|
+
root,
|
|
113
|
+
web: target == :web,
|
|
114
|
+
desktop: target == :desktop,
|
|
115
|
+
platform: platform
|
|
116
|
+
)
|
|
117
117
|
installed &&= present
|
|
118
118
|
puts "#{target}: #{present ? 'installed' : 'missing'}"
|
|
119
119
|
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]
|