ruflet 0.0.7 → 0.0.9
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/bin/ruflet +6 -0
- data/lib/ruflet/cli/build_command.rb +372 -0
- data/lib/ruflet/cli/extra_command.rb +146 -0
- data/lib/ruflet/cli/flutter_sdk.rb +359 -0
- data/lib/ruflet/cli/new_command.rb +221 -0
- data/lib/ruflet/cli/run_command.rb +699 -0
- data/lib/ruflet/cli/templates.rb +68 -0
- data/lib/ruflet/cli/update_command.rb +111 -0
- data/lib/ruflet/cli.rb +85 -0
- data/lib/ruflet/version.rb +1 -1
- data/lib/ruflet_cli.rb +3 -0
- metadata +59 -75
- data/lib/ruflet/manifest_compiler.rb +0 -62
- data/lib/ruflet.rb +0 -40
- data/lib/ruflet_protocol/ruflet/protocol.rb +0 -62
- data/lib/ruflet_protocol.rb +0 -4
- data/lib/ruflet_ui/ruflet/app.rb +0 -31
- data/lib/ruflet_ui/ruflet/colors.rb +0 -234
- data/lib/ruflet_ui/ruflet/control.rb +0 -168
- data/lib/ruflet_ui/ruflet/dsl.rb +0 -227
- data/lib/ruflet_ui/ruflet/event.rb +0 -28
- data/lib/ruflet_ui/ruflet/icon_data.rb +0 -62
- data/lib/ruflet_ui/ruflet/icons/cupertino/cupertino_icons.rb +0 -54
- data/lib/ruflet_ui/ruflet/icons/cupertino_icon_lookup.rb +0 -112
- data/lib/ruflet_ui/ruflet/icons/material_icon_lookup.rb +0 -112
- data/lib/ruflet_ui/ruflet/icons/material_icons.rb +0 -55
- data/lib/ruflet_ui/ruflet/page.rb +0 -741
- data/lib/ruflet_ui/ruflet/ui/control_factory.rb +0 -23
- data/lib/ruflet_ui/ruflet/ui/control_methods.rb +0 -16
- data/lib/ruflet_ui/ruflet/ui/control_registry.rb +0 -13
- data/lib/ruflet_ui/ruflet/ui/controls/cupertino/cupertino_action_sheet_control.rb +0 -13
- data/lib/ruflet_ui/ruflet/ui/controls/cupertino/cupertino_alert_dialog_control.rb +0 -13
- data/lib/ruflet_ui/ruflet/ui/controls/cupertino/cupertino_button_control.rb +0 -13
- data/lib/ruflet_ui/ruflet/ui/controls/cupertino/cupertino_dialog_action_control.rb +0 -24
- data/lib/ruflet_ui/ruflet/ui/controls/cupertino/cupertino_filled_button_control.rb +0 -13
- data/lib/ruflet_ui/ruflet/ui/controls/cupertino/cupertino_navigation_bar_control.rb +0 -13
- data/lib/ruflet_ui/ruflet/ui/controls/cupertino/cupertino_slider_control.rb +0 -13
- data/lib/ruflet_ui/ruflet/ui/controls/cupertino/cupertino_switch_control.rb +0 -13
- data/lib/ruflet_ui/ruflet/ui/controls/cupertino/cupertino_text_field_control.rb +0 -13
- data/lib/ruflet_ui/ruflet/ui/controls/material/alert_dialog_control.rb +0 -13
- data/lib/ruflet_ui/ruflet/ui/controls/material/app_bar_control.rb +0 -13
- data/lib/ruflet_ui/ruflet/ui/controls/material/bottom_sheet_control.rb +0 -13
- data/lib/ruflet_ui/ruflet/ui/controls/material/button_control.rb +0 -24
- data/lib/ruflet_ui/ruflet/ui/controls/material/checkbox_control.rb +0 -13
- data/lib/ruflet_ui/ruflet/ui/controls/material/column_control.rb +0 -13
- data/lib/ruflet_ui/ruflet/ui/controls/material/container_control.rb +0 -13
- data/lib/ruflet_ui/ruflet/ui/controls/material/drag_target_control.rb +0 -13
- data/lib/ruflet_ui/ruflet/ui/controls/material/draggable_control.rb +0 -13
- data/lib/ruflet_ui/ruflet/ui/controls/material/elevated_button_control.rb +0 -13
- data/lib/ruflet_ui/ruflet/ui/controls/material/filled_button_control.rb +0 -13
- data/lib/ruflet_ui/ruflet/ui/controls/material/floating_action_button_control.rb +0 -28
- data/lib/ruflet_ui/ruflet/ui/controls/material/gesture_detector_control.rb +0 -13
- data/lib/ruflet_ui/ruflet/ui/controls/material/grid_view_control.rb +0 -13
- data/lib/ruflet_ui/ruflet/ui/controls/material/icon_button_control.rb +0 -13
- data/lib/ruflet_ui/ruflet/ui/controls/material/icon_control.rb +0 -24
- data/lib/ruflet_ui/ruflet/ui/controls/material/image_control.rb +0 -13
- data/lib/ruflet_ui/ruflet/ui/controls/material/markdown_control.rb +0 -13
- data/lib/ruflet_ui/ruflet/ui/controls/material/navigation_bar_control.rb +0 -13
- data/lib/ruflet_ui/ruflet/ui/controls/material/navigation_bar_destination_control.rb +0 -13
- data/lib/ruflet_ui/ruflet/ui/controls/material/radio_control.rb +0 -13
- data/lib/ruflet_ui/ruflet/ui/controls/material/radio_group_control.rb +0 -13
- data/lib/ruflet_ui/ruflet/ui/controls/material/row_control.rb +0 -13
- data/lib/ruflet_ui/ruflet/ui/controls/material/snack_bar_control.rb +0 -68
- data/lib/ruflet_ui/ruflet/ui/controls/material/stack_control.rb +0 -13
- data/lib/ruflet_ui/ruflet/ui/controls/material/tab_bar_control.rb +0 -13
- data/lib/ruflet_ui/ruflet/ui/controls/material/tab_bar_view_control.rb +0 -13
- data/lib/ruflet_ui/ruflet/ui/controls/material/tab_control.rb +0 -13
- data/lib/ruflet_ui/ruflet/ui/controls/material/tabs_control.rb +0 -63
- data/lib/ruflet_ui/ruflet/ui/controls/material/text_button_control.rb +0 -13
- data/lib/ruflet_ui/ruflet/ui/controls/material/text_control.rb +0 -13
- data/lib/ruflet_ui/ruflet/ui/controls/material/text_field_control.rb +0 -13
- data/lib/ruflet_ui/ruflet/ui/controls/material/view_control.rb +0 -13
- data/lib/ruflet_ui/ruflet/ui/cupertino_control_factory.rb +0 -40
- data/lib/ruflet_ui/ruflet/ui/cupertino_control_methods.rb +0 -26
- data/lib/ruflet_ui/ruflet/ui/cupertino_control_registry.rb +0 -49
- data/lib/ruflet_ui/ruflet/ui/material_control_factory.rb +0 -97
- data/lib/ruflet_ui/ruflet/ui/material_control_methods.rb +0 -128
- data/lib/ruflet_ui/ruflet/ui/material_control_registry.rb +0 -154
- data/lib/ruflet_ui/ruflet/ui/shared_control_forwarders.rb +0 -89
- data/lib/ruflet_ui/ruflet/ui/widget_builder.rb +0 -55
- data/lib/ruflet_ui.rb +0 -111
|
@@ -0,0 +1,699 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require "rbconfig"
|
|
5
|
+
require "socket"
|
|
6
|
+
require "timeout"
|
|
7
|
+
require "tmpdir"
|
|
8
|
+
require "fileutils"
|
|
9
|
+
require "json"
|
|
10
|
+
require "net/http"
|
|
11
|
+
require "uri"
|
|
12
|
+
require "thread"
|
|
13
|
+
require "io/console"
|
|
14
|
+
require "time"
|
|
15
|
+
|
|
16
|
+
module Ruflet
|
|
17
|
+
module CLI
|
|
18
|
+
module RunCommand
|
|
19
|
+
def command_run(args)
|
|
20
|
+
options = { target: "mobile", requested_port: 8550 }
|
|
21
|
+
parser = OptionParser.new do |o|
|
|
22
|
+
o.on("--web") { options[:target] = "web" }
|
|
23
|
+
o.on("--desktop") { options[:target] = "desktop" }
|
|
24
|
+
o.on("--port PORT", Integer) { |v| options[:requested_port] = v }
|
|
25
|
+
end
|
|
26
|
+
parser.parse!(args)
|
|
27
|
+
|
|
28
|
+
script_token = args.shift || "main"
|
|
29
|
+
script_path = resolve_script(script_token)
|
|
30
|
+
unless script_path
|
|
31
|
+
warn "Script not found: #{script_token}"
|
|
32
|
+
warn "Expected: ./#{script_token}.rb, ./#{script_token}, or explicit file path."
|
|
33
|
+
return 1
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
selected_port = resolve_backend_port(options[:target], requested_port: options[:requested_port])
|
|
37
|
+
return 1 unless selected_port
|
|
38
|
+
env = {
|
|
39
|
+
"RUFLET_TARGET" => options[:target],
|
|
40
|
+
"RUFLET_SUPPRESS_SERVER_BANNER" => "1",
|
|
41
|
+
"RUFLET_PORT" => selected_port.to_s
|
|
42
|
+
}
|
|
43
|
+
assets_dir = File.join(File.dirname(script_path), "assets")
|
|
44
|
+
env["RUFLET_ASSETS_DIR"] = assets_dir if File.directory?(assets_dir)
|
|
45
|
+
|
|
46
|
+
print_run_banner(target: options[:target], requested_port: options[:requested_port], port: selected_port)
|
|
47
|
+
print_mobile_qr_hint(port: selected_port) if options[:target] == "mobile"
|
|
48
|
+
|
|
49
|
+
gemfile_path = find_nearest_gemfile(Dir.pwd)
|
|
50
|
+
cmd = build_runtime_command(script_path, gemfile_path: gemfile_path, env: env)
|
|
51
|
+
return 1 unless cmd
|
|
52
|
+
|
|
53
|
+
child_pid = Process.spawn(env, *cmd, pgroup: true)
|
|
54
|
+
launched_client_pids = launch_target_client(options[:target], selected_port)
|
|
55
|
+
forward_signal = lambda do |signal|
|
|
56
|
+
begin
|
|
57
|
+
Process.kill(signal, -child_pid)
|
|
58
|
+
rescue Errno::ESRCH
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
previous_int = Signal.trap("INT") { forward_signal.call("INT") }
|
|
64
|
+
previous_term = Signal.trap("TERM") { forward_signal.call("TERM") }
|
|
65
|
+
|
|
66
|
+
_pid, status = Process.wait2(child_pid)
|
|
67
|
+
status.success? ? 0 : (status.exitstatus || 1)
|
|
68
|
+
ensure
|
|
69
|
+
Signal.trap("INT", previous_int) if defined?(previous_int) && previous_int
|
|
70
|
+
Signal.trap("TERM", previous_term) if defined?(previous_term) && previous_term
|
|
71
|
+
|
|
72
|
+
if defined?(child_pid) && child_pid
|
|
73
|
+
begin
|
|
74
|
+
Process.kill("TERM", -child_pid)
|
|
75
|
+
rescue Errno::ESRCH
|
|
76
|
+
nil
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
Array(defined?(launched_client_pids) ? launched_client_pids : nil).compact.each do |pid|
|
|
81
|
+
begin
|
|
82
|
+
Process.kill("TERM", -pid)
|
|
83
|
+
rescue Errno::ESRCH
|
|
84
|
+
begin
|
|
85
|
+
Process.kill("TERM", pid)
|
|
86
|
+
rescue Errno::ESRCH
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def build_runtime_command(script_path, gemfile_path:, env:)
|
|
97
|
+
if gemfile_path
|
|
98
|
+
env["BUNDLE_GEMFILE"] = gemfile_path
|
|
99
|
+
bundle_ready = system(env, RbConfig.ruby, "-S", "bundle", "check", out: File::NULL, err: File::NULL)
|
|
100
|
+
return nil unless bundle_ready || system(env, RbConfig.ruby, "-S", "bundle", "install")
|
|
101
|
+
|
|
102
|
+
return [RbConfig.ruby, "-rbundler/setup", script_path]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
[RbConfig.ruby, script_path]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def resolve_script(token)
|
|
109
|
+
path = File.expand_path(token, Dir.pwd)
|
|
110
|
+
return path if File.file?(path)
|
|
111
|
+
|
|
112
|
+
candidate = File.expand_path("#{token}.rb", Dir.pwd)
|
|
113
|
+
return candidate if File.file?(candidate)
|
|
114
|
+
|
|
115
|
+
nil
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def find_nearest_gemfile(start_dir)
|
|
119
|
+
current = File.expand_path(start_dir)
|
|
120
|
+
loop do
|
|
121
|
+
candidate = File.join(current, "Gemfile")
|
|
122
|
+
return candidate if File.file?(candidate)
|
|
123
|
+
|
|
124
|
+
parent = File.expand_path("..", current)
|
|
125
|
+
return nil if parent == current
|
|
126
|
+
|
|
127
|
+
current = parent
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def print_run_banner(target:, requested_port:, port:)
|
|
132
|
+
if port != requested_port.to_i
|
|
133
|
+
puts "Requested port #{requested_port} is busy; bound to #{port}"
|
|
134
|
+
end
|
|
135
|
+
if target == "desktop"
|
|
136
|
+
puts "Ruflet desktop URL: http://localhost:#{port}"
|
|
137
|
+
elsif target == "mobile"
|
|
138
|
+
puts "Ruflet target: #{target}"
|
|
139
|
+
else
|
|
140
|
+
puts "Ruflet target: #{target}"
|
|
141
|
+
puts "Ruflet URL: http://localhost:#{port}"
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def launch_target_client(target, port)
|
|
146
|
+
wait_for_server_boot(port)
|
|
147
|
+
|
|
148
|
+
case target
|
|
149
|
+
when "web"
|
|
150
|
+
launch_web_client(port)
|
|
151
|
+
when "desktop"
|
|
152
|
+
launch_desktop_client("http://localhost:#{port}")
|
|
153
|
+
else
|
|
154
|
+
[]
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def launch_web_client(port)
|
|
159
|
+
web_dir = detect_web_client_dir
|
|
160
|
+
unless web_dir
|
|
161
|
+
warn "Web client build not found and prebuilt download failed."
|
|
162
|
+
return []
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
web_port = find_available_port(port + 1)
|
|
166
|
+
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)
|
|
167
|
+
Process.detach(web_pid)
|
|
168
|
+
wait_for_server_boot(web_port)
|
|
169
|
+
backend_url = "http://localhost:#{port}"
|
|
170
|
+
web_url = "http://localhost:#{web_port}/?#{URI.encode_www_form(url: backend_url)}"
|
|
171
|
+
browser_pid = open_in_browser_app_mode(web_url)
|
|
172
|
+
open_in_browser(web_url) if browser_pid.nil?
|
|
173
|
+
puts "Ruflet web client: #{web_url}"
|
|
174
|
+
puts "Ruflet backend ws: ws://localhost:#{port}/ws"
|
|
175
|
+
[web_pid, browser_pid].compact
|
|
176
|
+
rescue Errno::ENOENT
|
|
177
|
+
warn "python3 is required to host web client locally."
|
|
178
|
+
warn "Install Python 3 and rerun."
|
|
179
|
+
[]
|
|
180
|
+
rescue StandardError => e
|
|
181
|
+
warn "Failed to launch web client: #{e.class}: #{e.message}"
|
|
182
|
+
[]
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def wait_for_server_boot(port, timeout_seconds: 10)
|
|
186
|
+
Timeout.timeout(timeout_seconds) do
|
|
187
|
+
loop do
|
|
188
|
+
begin
|
|
189
|
+
sock = TCPSocket.new("127.0.0.1", port)
|
|
190
|
+
sock.write("GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
|
|
191
|
+
sock.close
|
|
192
|
+
break
|
|
193
|
+
rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH
|
|
194
|
+
sleep 0.15
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
rescue Timeout::Error
|
|
199
|
+
warn "Server did not become reachable at http://localhost:#{port} yet."
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def open_in_browser(url)
|
|
203
|
+
cmd =
|
|
204
|
+
case RbConfig::CONFIG["host_os"]
|
|
205
|
+
when /darwin/i
|
|
206
|
+
["open", url]
|
|
207
|
+
when /mswin|mingw|cygwin/i
|
|
208
|
+
["cmd", "/c", "start", "", url]
|
|
209
|
+
else
|
|
210
|
+
["xdg-open", url]
|
|
211
|
+
end
|
|
212
|
+
if system(*cmd, out: File::NULL, err: File::NULL)
|
|
213
|
+
puts "Opened browser at #{url}"
|
|
214
|
+
else
|
|
215
|
+
warn "Could not auto-open browser. Open manually: #{url}"
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def open_in_browser_app_mode(url)
|
|
220
|
+
host_os = RbConfig::CONFIG["host_os"]
|
|
221
|
+
if host_os.match?(/darwin/i)
|
|
222
|
+
chrome = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
|
|
223
|
+
chromium = "/Applications/Chromium.app/Contents/MacOS/Chromium"
|
|
224
|
+
browser = [chrome, chromium].find { |p| File.file?(p) && File.executable?(p) }
|
|
225
|
+
return nil unless browser
|
|
226
|
+
|
|
227
|
+
profile_dir = Dir.mktmpdir("ruflet-webapp-")
|
|
228
|
+
pid = Process.spawn(
|
|
229
|
+
browser,
|
|
230
|
+
"--new-window",
|
|
231
|
+
"--no-first-run",
|
|
232
|
+
"--no-default-browser-check",
|
|
233
|
+
"--user-data-dir=#{profile_dir}",
|
|
234
|
+
"--app=#{url}",
|
|
235
|
+
pgroup: true,
|
|
236
|
+
out: File::NULL,
|
|
237
|
+
err: File::NULL
|
|
238
|
+
)
|
|
239
|
+
Process.detach(pid)
|
|
240
|
+
return pid
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
if host_os.match?(/linux/i)
|
|
244
|
+
browser = %w[google-chrome chromium chromium-browser].find { |cmd| system("which", cmd, out: File::NULL, err: File::NULL) }
|
|
245
|
+
return nil unless browser
|
|
246
|
+
|
|
247
|
+
profile_dir = Dir.mktmpdir("ruflet-webapp-")
|
|
248
|
+
pid = Process.spawn(
|
|
249
|
+
browser,
|
|
250
|
+
"--new-window",
|
|
251
|
+
"--no-first-run",
|
|
252
|
+
"--no-default-browser-check",
|
|
253
|
+
"--user-data-dir=#{profile_dir}",
|
|
254
|
+
"--app=#{url}",
|
|
255
|
+
pgroup: true,
|
|
256
|
+
out: File::NULL,
|
|
257
|
+
err: File::NULL
|
|
258
|
+
)
|
|
259
|
+
Process.detach(pid)
|
|
260
|
+
return pid
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
nil
|
|
264
|
+
rescue StandardError
|
|
265
|
+
nil
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def launch_desktop_client(url)
|
|
269
|
+
cmd = detect_desktop_client_command(url)
|
|
270
|
+
unless cmd
|
|
271
|
+
warn "Desktop client executable not found."
|
|
272
|
+
warn "Set RUFLET_CLIENT_DIR to your client path."
|
|
273
|
+
warn "Example: export RUFLET_CLIENT_DIR=/path/to/ruflet_client"
|
|
274
|
+
return
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
pid = Process.spawn(*cmd, out: File::NULL, err: File::NULL)
|
|
278
|
+
Process.detach(pid)
|
|
279
|
+
if !pid
|
|
280
|
+
warn "Failed to launch desktop client: #{cmd.first}"
|
|
281
|
+
warn "Start it manually with URL: #{url}"
|
|
282
|
+
end
|
|
283
|
+
[pid]
|
|
284
|
+
rescue StandardError => e
|
|
285
|
+
warn "Failed to launch desktop client: #{e.class}: #{e.message}"
|
|
286
|
+
warn "Start it manually with URL: #{url}"
|
|
287
|
+
[]
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def detect_desktop_client_command(url)
|
|
291
|
+
root = ENV["RUFLET_CLIENT_DIR"]
|
|
292
|
+
root = File.expand_path("ruflet_client", Dir.pwd) if root.to_s.strip.empty?
|
|
293
|
+
root = nil unless Dir.exist?(root)
|
|
294
|
+
root ||= ensure_prebuilt_client(desktop: true)
|
|
295
|
+
return nil unless root && Dir.exist?(root)
|
|
296
|
+
|
|
297
|
+
host_os = RbConfig::CONFIG["host_os"]
|
|
298
|
+
if host_os.match?(/darwin/i)
|
|
299
|
+
release_bin = File.join(root, "build", "macos", "Build", "Products", "Release", "ruflet_client.app", "Contents", "MacOS", "ruflet_client")
|
|
300
|
+
debug_bin = File.join(root, "build", "macos", "Build", "Products", "Debug", "ruflet_client.app", "Contents", "MacOS", "ruflet_client")
|
|
301
|
+
prebuilt_bin = File.join(root, "desktop", "ruflet_client.app", "Contents", "MacOS", "ruflet_client")
|
|
302
|
+
executable = [release_bin, debug_bin].find { |p| File.file?(p) && File.executable?(p) }
|
|
303
|
+
executable ||= prebuilt_bin if File.file?(prebuilt_bin) && File.executable?(prebuilt_bin)
|
|
304
|
+
return [executable, url] if executable
|
|
305
|
+
elsif host_os.match?(/mswin|mingw|cygwin/i)
|
|
306
|
+
exe = File.join(root, "build", "windows", "x64", "runner", "Release", "ruflet_client.exe")
|
|
307
|
+
prebuilt = File.join(root, "desktop", "ruflet_client.exe")
|
|
308
|
+
exe = prebuilt if !File.file?(exe) && File.file?(prebuilt)
|
|
309
|
+
return [exe, url] if File.file?(exe)
|
|
310
|
+
else
|
|
311
|
+
direct = File.join(root, "build", "linux", "x64", "release", "bundle", "ruflet_client")
|
|
312
|
+
prebuilt_direct = File.join(root, "desktop", "ruflet_client")
|
|
313
|
+
direct = prebuilt_direct if !File.file?(direct) && File.file?(prebuilt_direct)
|
|
314
|
+
return [direct, url] if File.file?(direct)
|
|
315
|
+
bundle_dir = File.join(root, "build", "linux", "x64", "release", "bundle")
|
|
316
|
+
if Dir.exist?(bundle_dir)
|
|
317
|
+
candidate = Dir.children(bundle_dir).map { |f| File.join(bundle_dir, f) }
|
|
318
|
+
.find { |path| File.file?(path) && File.executable?(path) }
|
|
319
|
+
return [candidate, url] if candidate
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
nil
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def detect_web_client_dir
|
|
327
|
+
root = ENV["RUFLET_CLIENT_DIR"]
|
|
328
|
+
root = File.expand_path("ruflet_client", Dir.pwd) if root.to_s.strip.empty?
|
|
329
|
+
root = nil unless Dir.exist?(root)
|
|
330
|
+
root ||= ensure_prebuilt_client(web: true)
|
|
331
|
+
return nil unless root && Dir.exist?(root)
|
|
332
|
+
|
|
333
|
+
built = File.join(root, "build", "web")
|
|
334
|
+
return built if Dir.exist?(built) && File.file?(File.join(built, "index.html"))
|
|
335
|
+
prebuilt = File.join(root, "web")
|
|
336
|
+
return prebuilt if Dir.exist?(prebuilt) && File.file?(File.join(prebuilt, "index.html"))
|
|
337
|
+
|
|
338
|
+
nil
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def ensure_prebuilt_client(web: false, desktop: false, platform: nil, force: false)
|
|
342
|
+
platform ||= host_platform_name
|
|
343
|
+
return nil if platform.nil?
|
|
344
|
+
|
|
345
|
+
cache_root = client_cache_root_for(platform)
|
|
346
|
+
FileUtils.mkdir_p(cache_root)
|
|
347
|
+
|
|
348
|
+
wanted_assets = []
|
|
349
|
+
wanted_assets << { kind: :web, name: "ruflet_client-web.tar.gz" } if web
|
|
350
|
+
if desktop
|
|
351
|
+
desktop_asset = desktop_asset_name_for(platform)
|
|
352
|
+
return nil if desktop_asset.nil?
|
|
353
|
+
wanted_assets << { kind: :desktop, name: desktop_asset, platform: platform }
|
|
354
|
+
end
|
|
355
|
+
if !force && (wanted_assets.empty? || prebuilt_assets_present?(cache_root, web: web, desktop: desktop, platform: platform))
|
|
356
|
+
ensure_client_manifest(cache_root, platform: platform)
|
|
357
|
+
return cache_root
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
release = fetch_release_for_version
|
|
361
|
+
return nil unless release
|
|
362
|
+
|
|
363
|
+
assets = release.fetch("assets", [])
|
|
364
|
+
asset_names = assets.map { |a| a["name"].to_s }
|
|
365
|
+
installed_assets = []
|
|
366
|
+
Dir.mktmpdir("ruflet-prebuilt-") do |tmpdir|
|
|
367
|
+
wanted_assets.each do |wanted|
|
|
368
|
+
asset_name = wanted.fetch(:name)
|
|
369
|
+
asset = assets.find { |a| a["name"] == asset_name }
|
|
370
|
+
asset ||= fallback_release_asset(assets, wanted)
|
|
371
|
+
unless asset
|
|
372
|
+
warn "Missing release asset: #{asset_name}"
|
|
373
|
+
warn "Available assets: #{asset_names.join(', ')}" unless asset_names.empty?
|
|
374
|
+
return nil
|
|
375
|
+
end
|
|
376
|
+
resolved_name = asset.fetch("name")
|
|
377
|
+
puts "Downloading prebuilt client asset: #{resolved_name}"
|
|
378
|
+
archive_path = File.join(tmpdir, resolved_name)
|
|
379
|
+
download_file(asset.fetch("browser_download_url"), archive_path)
|
|
380
|
+
subdir = wanted[:kind] == :web ? "web" : "desktop"
|
|
381
|
+
target = File.join(cache_root, subdir)
|
|
382
|
+
FileUtils.rm_rf(target) if force && Dir.exist?(target)
|
|
383
|
+
FileUtils.mkdir_p(target)
|
|
384
|
+
unless extract_archive(archive_path, target)
|
|
385
|
+
warn "Failed to extract asset: #{resolved_name}"
|
|
386
|
+
return nil
|
|
387
|
+
end
|
|
388
|
+
installed_assets << {
|
|
389
|
+
"kind" => wanted[:kind].to_s,
|
|
390
|
+
"platform" => wanted[:platform] || platform,
|
|
391
|
+
"asset_name" => resolved_name,
|
|
392
|
+
"download_url" => asset.fetch("browser_download_url")
|
|
393
|
+
}
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
if prebuilt_assets_present?(cache_root, web: web, desktop: desktop, platform: platform)
|
|
398
|
+
write_client_manifest(cache_root, platform: platform, release: release, assets: installed_assets)
|
|
399
|
+
return cache_root
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
nil
|
|
403
|
+
rescue StandardError => e
|
|
404
|
+
warn "Prebuilt client bootstrap failed: #{e.class}: #{e.message}"
|
|
405
|
+
nil
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
def prebuilt_assets_present?(root, web:, desktop:, platform: nil)
|
|
409
|
+
ok_web = !web || File.file?(File.join(root, "web", "index.html"))
|
|
410
|
+
ok_desktop = !desktop || prebuilt_desktop_present?(root, platform: platform)
|
|
411
|
+
ok_web && ok_desktop
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def prebuilt_desktop_present?(root, platform: nil)
|
|
415
|
+
platform ||= host_platform_name
|
|
416
|
+
return false if platform.nil?
|
|
417
|
+
|
|
418
|
+
case platform
|
|
419
|
+
when "macos"
|
|
420
|
+
File.file?(File.join(root, "desktop", "ruflet_client.app", "Contents", "MacOS", "ruflet_client"))
|
|
421
|
+
when "linux"
|
|
422
|
+
File.file?(File.join(root, "desktop", "ruflet_client"))
|
|
423
|
+
when "windows"
|
|
424
|
+
File.file?(File.join(root, "desktop", "ruflet_client.exe"))
|
|
425
|
+
else
|
|
426
|
+
false
|
|
427
|
+
end
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
def host_platform_name
|
|
431
|
+
host_os = RbConfig::CONFIG["host_os"]
|
|
432
|
+
return "macos" if host_os.match?(/darwin/i)
|
|
433
|
+
return "linux" if host_os.match?(/linux/i)
|
|
434
|
+
return "windows" if host_os.match?(/mswin|mingw|cygwin/i)
|
|
435
|
+
|
|
436
|
+
nil
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
def desktop_asset_name_for(platform)
|
|
440
|
+
case platform
|
|
441
|
+
when "macos" then "ruflet_client-macos-universal.zip"
|
|
442
|
+
when "linux" then "ruflet_client-linux-x64.tar.gz"
|
|
443
|
+
when "windows" then "ruflet_client-windows-x64.zip"
|
|
444
|
+
end
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
def client_cache_root_for(platform)
|
|
448
|
+
File.join(Dir.home, ".ruflet", "client", ruflet_version, platform.to_s)
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
def fetch_release_for_version
|
|
452
|
+
release_by_tag("v#{ruflet_version}") ||
|
|
453
|
+
release_by_tag(ruflet_version) ||
|
|
454
|
+
release_by_tag("prebuild") ||
|
|
455
|
+
release_by_tag("prebuild-main") ||
|
|
456
|
+
release_latest
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
def ruflet_version
|
|
460
|
+
return Ruflet::VERSION if Ruflet.const_defined?(:VERSION)
|
|
461
|
+
|
|
462
|
+
require_relative "../version"
|
|
463
|
+
Ruflet::VERSION
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
def release_latest
|
|
467
|
+
github_get_json("https://api.github.com/repos/AdamMusa/Ruflet/releases/latest")
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
def release_by_tag(tag)
|
|
471
|
+
github_get_json("https://api.github.com/repos/AdamMusa/Ruflet/releases/tags/#{tag}")
|
|
472
|
+
rescue StandardError
|
|
473
|
+
nil
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
def fallback_release_asset(assets, wanted)
|
|
477
|
+
kind = wanted[:kind]
|
|
478
|
+
platform = wanted[:platform]
|
|
479
|
+
candidates = assets.select { |asset| release_asset_matches?(asset.fetch("name", ""), kind, platform) }
|
|
480
|
+
candidates.first
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
def release_asset_matches?(name, kind, platform)
|
|
484
|
+
n = name.to_s.downcase
|
|
485
|
+
return false unless n.include?("ruflet_client")
|
|
486
|
+
|
|
487
|
+
if kind == :web
|
|
488
|
+
return n.include?("web") && (n.end_with?(".tar.gz") || n.end_with?(".zip"))
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
case platform
|
|
492
|
+
when "macos"
|
|
493
|
+
n.include?("macos") && n.end_with?(".zip")
|
|
494
|
+
when "linux"
|
|
495
|
+
n.include?("linux") && (n.end_with?(".tar.gz") || n.end_with?(".tgz"))
|
|
496
|
+
when "windows"
|
|
497
|
+
n.include?("windows") && n.end_with?(".zip")
|
|
498
|
+
else
|
|
499
|
+
false
|
|
500
|
+
end
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
def github_get_json(url)
|
|
504
|
+
uri = URI(url)
|
|
505
|
+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
|
|
506
|
+
req = Net::HTTP::Get.new(uri)
|
|
507
|
+
req["Accept"] = "application/vnd.github+json"
|
|
508
|
+
req["User-Agent"] = "ruflet-cli"
|
|
509
|
+
http.request(req)
|
|
510
|
+
end
|
|
511
|
+
return JSON.parse(response.body) if response.is_a?(Net::HTTPSuccess)
|
|
512
|
+
|
|
513
|
+
raise "GitHub API failed (#{response.code})"
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
def download_file(url, destination, limit: 5)
|
|
517
|
+
raise "Too many redirects while downloading #{url}" if limit <= 0
|
|
518
|
+
|
|
519
|
+
uri = URI(url)
|
|
520
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
|
|
521
|
+
req = Net::HTTP::Get.new(uri)
|
|
522
|
+
req["User-Agent"] = "ruflet-cli"
|
|
523
|
+
http.request(req) do |res|
|
|
524
|
+
case res
|
|
525
|
+
when Net::HTTPSuccess
|
|
526
|
+
File.open(destination, "wb") { |f| res.read_body { |chunk| f.write(chunk) } }
|
|
527
|
+
return destination
|
|
528
|
+
when Net::HTTPRedirection
|
|
529
|
+
return download_file(res["location"], destination, limit: limit - 1)
|
|
530
|
+
else
|
|
531
|
+
raise "Download failed (#{res.code})"
|
|
532
|
+
end
|
|
533
|
+
end
|
|
534
|
+
end
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
def extract_archive(archive, destination)
|
|
538
|
+
if archive.end_with?(".tar.gz")
|
|
539
|
+
return system("tar", "-xzf", archive, "-C", destination, out: File::NULL, err: File::NULL)
|
|
540
|
+
end
|
|
541
|
+
if archive.end_with?(".zip")
|
|
542
|
+
host_os = RbConfig::CONFIG["host_os"]
|
|
543
|
+
if host_os.match?(/darwin/i)
|
|
544
|
+
return system("ditto", "-x", "-k", archive, destination, out: File::NULL, err: File::NULL)
|
|
545
|
+
end
|
|
546
|
+
return system("unzip", "-oq", archive, "-d", destination, out: File::NULL, err: File::NULL)
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
false
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
def client_manifest_path(root)
|
|
553
|
+
File.join(root, "manifest.json")
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
def read_client_manifest(root)
|
|
557
|
+
path = client_manifest_path(root)
|
|
558
|
+
return nil unless File.file?(path)
|
|
559
|
+
|
|
560
|
+
JSON.parse(File.read(path))
|
|
561
|
+
rescue StandardError
|
|
562
|
+
nil
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
def ensure_client_manifest(root, platform:)
|
|
566
|
+
return if read_client_manifest(root)
|
|
567
|
+
|
|
568
|
+
assets = []
|
|
569
|
+
assets << { "kind" => "web", "platform" => platform, "asset_name" => nil } if File.file?(File.join(root, "web", "index.html"))
|
|
570
|
+
if prebuilt_desktop_present?(root, platform: platform)
|
|
571
|
+
assets << { "kind" => "desktop", "platform" => platform, "asset_name" => nil }
|
|
572
|
+
end
|
|
573
|
+
return if assets.empty?
|
|
574
|
+
|
|
575
|
+
write_client_manifest(root, platform: platform, release: nil, assets: assets)
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
def write_client_manifest(root, platform:, release:, assets:)
|
|
579
|
+
FileUtils.mkdir_p(root)
|
|
580
|
+
payload = {
|
|
581
|
+
"schema" => 1,
|
|
582
|
+
"ruflet_version" => ruflet_version,
|
|
583
|
+
"platform" => platform,
|
|
584
|
+
"release_tag" => release && release["tag_name"],
|
|
585
|
+
"released_at" => release && release["published_at"],
|
|
586
|
+
"installed_at" => Time.now.utc.iso8601,
|
|
587
|
+
"targets" => assets
|
|
588
|
+
}
|
|
589
|
+
File.write(client_manifest_path(root), JSON.pretty_generate(payload))
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
def print_mobile_qr_hint(port: 8550)
|
|
593
|
+
host = best_lan_host
|
|
594
|
+
payload = "http://#{host}:#{port}"
|
|
595
|
+
|
|
596
|
+
puts
|
|
597
|
+
puts "Ruflet mobile connect URL:"
|
|
598
|
+
puts " #{payload}"
|
|
599
|
+
puts "Scan this QR from ruflet_client (Connect -> Scan QR):"
|
|
600
|
+
print_ascii_qr(payload)
|
|
601
|
+
puts
|
|
602
|
+
rescue StandardError => e
|
|
603
|
+
warn "QR setup failed: #{e.class}: #{e.message}"
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
def find_available_port(start_port, max_attempts: 100)
|
|
607
|
+
port = start_port.to_i
|
|
608
|
+
|
|
609
|
+
max_attempts.times do
|
|
610
|
+
begin
|
|
611
|
+
begin
|
|
612
|
+
probe = TCPServer.new("0.0.0.0", port)
|
|
613
|
+
rescue Errno::EACCES, Errno::EPERM
|
|
614
|
+
probe = TCPServer.new("127.0.0.1", port)
|
|
615
|
+
end
|
|
616
|
+
probe.close
|
|
617
|
+
return port
|
|
618
|
+
rescue Errno::EADDRINUSE, Errno::EACCES, Errno::EPERM
|
|
619
|
+
port += 1
|
|
620
|
+
end
|
|
621
|
+
end
|
|
622
|
+
|
|
623
|
+
start_port
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
def resolve_backend_port(_target, requested_port: 8550)
|
|
627
|
+
base = requested_port.to_i
|
|
628
|
+
base = 8550 if base <= 0
|
|
629
|
+
find_available_port(base)
|
|
630
|
+
end
|
|
631
|
+
|
|
632
|
+
def port_available?(port)
|
|
633
|
+
probe = nil
|
|
634
|
+
begin
|
|
635
|
+
begin
|
|
636
|
+
probe = TCPServer.new("0.0.0.0", port)
|
|
637
|
+
rescue Errno::EACCES, Errno::EPERM
|
|
638
|
+
probe = TCPServer.new("127.0.0.1", port)
|
|
639
|
+
end
|
|
640
|
+
true
|
|
641
|
+
rescue Errno::EADDRINUSE
|
|
642
|
+
false
|
|
643
|
+
ensure
|
|
644
|
+
probe&.close
|
|
645
|
+
end
|
|
646
|
+
end
|
|
647
|
+
|
|
648
|
+
def best_lan_host
|
|
649
|
+
ips = Socket.ip_address_list
|
|
650
|
+
addr = ips.find { |ip| ip.ipv4_private? && !ip.ipv4_loopback? }
|
|
651
|
+
return addr.ip_address if addr
|
|
652
|
+
|
|
653
|
+
"127.0.0.1"
|
|
654
|
+
end
|
|
655
|
+
|
|
656
|
+
def print_ascii_qr(payload)
|
|
657
|
+
begin
|
|
658
|
+
require "rqrcode"
|
|
659
|
+
rescue LoadError
|
|
660
|
+
puts "(Install 'rqrcode' gem in CLI package for terminal QR rendering.)"
|
|
661
|
+
return
|
|
662
|
+
end
|
|
663
|
+
|
|
664
|
+
q = RQRCode::QRCode.new(payload)
|
|
665
|
+
border = 1
|
|
666
|
+
core = q.modules
|
|
667
|
+
size = core.length + (2 * border)
|
|
668
|
+
|
|
669
|
+
matrix = Array.new(size) do |y|
|
|
670
|
+
Array.new(size) do |x|
|
|
671
|
+
cy = y - border
|
|
672
|
+
cx = x - border
|
|
673
|
+
cy >= 0 && cx >= 0 && cy < core.length && cx < core.length && core[cy][cx]
|
|
674
|
+
end
|
|
675
|
+
end
|
|
676
|
+
|
|
677
|
+
y = 0
|
|
678
|
+
while y < size
|
|
679
|
+
line = +""
|
|
680
|
+
(0...size).each do |x|
|
|
681
|
+
top = matrix[y][x]
|
|
682
|
+
bottom = (y + 1 < size) ? matrix[y + 1][x] : false
|
|
683
|
+
line << if top && bottom
|
|
684
|
+
"\u2588"
|
|
685
|
+
elsif top
|
|
686
|
+
"\u2580"
|
|
687
|
+
elsif bottom
|
|
688
|
+
"\u2584"
|
|
689
|
+
else
|
|
690
|
+
" "
|
|
691
|
+
end
|
|
692
|
+
end
|
|
693
|
+
puts line
|
|
694
|
+
y += 2
|
|
695
|
+
end
|
|
696
|
+
end
|
|
697
|
+
end
|
|
698
|
+
end
|
|
699
|
+
end
|