ruflet_cli 0.0.3 → 0.0.4

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: 504fcfaa08d919daea87df9c873cb5b6e890ad477456ffd9268730f1649bae68
4
- data.tar.gz: e59b5b1067f1604230353731a5297906c33d1333ae876a9644215161428f1a5e
3
+ metadata.gz: 36d08da2d1377a858cd6703bd694b66d937604a21544af1de95d592aec4cb4df
4
+ data.tar.gz: 3feccf443f7ecae27bf835a90f6fbb81c04bdbc86bf1a71350bda6c97a0d555d
5
5
  SHA512:
6
- metadata.gz: 5c448739e52ec85fd83d5cb219e9f249ebe2046ba67f9038d9a7c95dac50f4f6d6f6f48d6331f7dec8be21b107dffa5116fecc50c6800f3ad81c2656ad684f3a
7
- data.tar.gz: 388a23d1752044cedc4dc4b0586a240c04a03b59279aa5a0195d4aa41b3ae4e5c0b0c11a076961da78a011c92b1254397f84248121318f350b6bd986d40d1e73
6
+ metadata.gz: e8677cc79bdb4e546f88ea3140b95e0177a0f24f884fa679f1a5636098017186caa14859a189c755b42455bdd7bbf612a8ac78f608766cd19f36b1f756c5ad69
7
+ data.tar.gz: b07035a35c315f1b42cc5dda629d080a4956b1854dd96297420d6ad24986c3095101f86ff9889c13d0bc133e18967c2666fbadad3228f00b39eabd9442af88ac
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "fileutils"
4
+ require "yaml"
5
+
3
6
  module Ruflet
4
7
  module CLI
5
8
  module BuildCommand
@@ -23,7 +26,10 @@ module Ruflet
23
26
  return 1
24
27
  end
25
28
 
26
- ok = system(*flutter_cmd, chdir: client_dir)
29
+ ok = prepare_flutter_client(client_dir)
30
+ return 1 unless ok
31
+
32
+ ok = system(*flutter_cmd, *args, chdir: client_dir)
27
33
  ok ? 0 : 1
28
34
  end
29
35
 
@@ -36,9 +42,174 @@ module Ruflet
36
42
  local = File.expand_path("ruflet_client", Dir.pwd)
37
43
  return local if Dir.exist?(local)
38
44
 
45
+ template = File.expand_path("templates/ruflet_flutter_template", Dir.pwd)
46
+ return template if Dir.exist?(template)
47
+
39
48
  nil
40
49
  end
41
50
 
51
+ def prepare_flutter_client(client_dir)
52
+ apply_build_config(client_dir)
53
+ unless system("flutter", "pub", "get", chdir: client_dir)
54
+ warn "flutter pub get failed"
55
+ return false
56
+ end
57
+
58
+ unless system("dart", "run", "flutter_native_splash:create", chdir: client_dir)
59
+ warn "flutter_native_splash failed"
60
+ return false
61
+ end
62
+
63
+ unless system("dart", "run", "flutter_launcher_icons", chdir: client_dir)
64
+ warn "flutter_launcher_icons failed"
65
+ return false
66
+ end
67
+
68
+ true
69
+ end
70
+
71
+ def apply_build_config(client_dir)
72
+ config_path = ENV["RUFLET_CONFIG"] || "ruflet.yaml"
73
+ unless File.file?(config_path)
74
+ alt = "ruflet.yml"
75
+ config_path = alt if File.file?(alt)
76
+ end
77
+
78
+ config_present = File.file?(config_path)
79
+ config = config_present ? (YAML.load_file(config_path) || {}) : {}
80
+ build = config["build"] || {}
81
+ assets = config["assets"] || {}
82
+ config_dir = config_present ? File.dirname(File.expand_path(config_path)) : Dir.pwd
83
+
84
+ assets_root = build["assets_dir"] || assets["dir"] || config["assets_dir"] || "assets"
85
+ assets_root = File.expand_path(assets_root, config_dir)
86
+
87
+ unless config_present || Dir.exist?(assets_root) || ENV["RUFLET_SPLASH"] || ENV["RUFLET_ICON"]
88
+ return
89
+ end
90
+
91
+ resolve_asset = lambda do |path|
92
+ return nil if path.nil? || path.to_s.strip.empty?
93
+ full = File.expand_path(path.to_s, config_dir)
94
+ File.file?(full) ? full : nil
95
+ end
96
+
97
+ find_first = lambda do |dir, names|
98
+ names.each do |name|
99
+ candidate = File.join(dir, name)
100
+ return candidate if File.file?(candidate)
101
+ end
102
+ nil
103
+ end
104
+
105
+ splash = resolve_asset.call(build["splash"] || assets["splash"] || ENV["RUFLET_SPLASH"])
106
+ splash_dark = resolve_asset.call(build["splash_dark"] || build["splash_dark_image"] || assets["splash_dark"])
107
+ icon = resolve_asset.call(build["icon"] || assets["icon"] || ENV["RUFLET_ICON"])
108
+ icon_android = resolve_asset.call(build["icon_android"] || assets["icon_android"])
109
+ icon_ios = resolve_asset.call(build["icon_ios"] || assets["icon_ios"])
110
+ icon_web = resolve_asset.call(build["icon_web"] || assets["icon_web"])
111
+ icon_windows = resolve_asset.call(build["icon_windows"] || assets["icon_windows"])
112
+ icon_macos = resolve_asset.call(build["icon_macos"] || assets["icon_macos"])
113
+
114
+ if Dir.exist?(assets_root)
115
+ splash ||= find_first.call(assets_root, ["splash.png", "splash.jpg", "splash.webp", "splash.bmp"])
116
+ splash_dark ||= find_first.call(assets_root, ["splash_dark.png", "splash_dark.jpg", "splash_dark.webp", "splash_dark.bmp"])
117
+ icon ||= find_first.call(assets_root, ["icon.png", "icon.jpg", "icon.webp", "icon.bmp"])
118
+ icon_android ||= find_first.call(assets_root, ["icon_android.png", "icon_android.jpg", "icon_android.webp"])
119
+ icon_ios ||= find_first.call(assets_root, ["icon_ios.png", "icon_ios.jpg", "icon_ios.webp"])
120
+ icon_web ||= find_first.call(assets_root, ["icon_web.png", "icon_web.jpg", "icon_web.webp"])
121
+ icon_windows ||= find_first.call(assets_root, ["icon_windows.ico", "icon_windows.png"])
122
+ icon_macos ||= find_first.call(assets_root, ["icon_macos.png", "icon_macos.jpg", "icon_macos.webp"])
123
+ end
124
+
125
+ splash_color = build["splash_color"]
126
+ splash_dark_color = build["splash_dark_color"] || build["splash_color_dark"]
127
+ icon_background = build["icon_background"]
128
+ theme_color = build["theme_color"]
129
+
130
+ assets_dir = File.join(client_dir, "assets")
131
+ FileUtils.mkdir_p(assets_dir)
132
+
133
+ copy_asset = lambda do |src, dest|
134
+ return unless src
135
+ FileUtils.cp(src, File.join(assets_dir, dest))
136
+ end
137
+
138
+ copy_asset.call(splash, "splash.png")
139
+ copy_asset.call(splash_dark, "splash_dark.png")
140
+ copy_asset.call(icon, "icon.png")
141
+ copy_asset.call(icon_android, "icon_android.png")
142
+ copy_asset.call(icon_ios, "icon_ios.png")
143
+ copy_asset.call(icon_web, "icon_web.png")
144
+ if icon_windows
145
+ ext = File.extname(icon_windows).downcase
146
+ copy_asset.call(icon_windows, ext == ".ico" ? "icon_windows.ico" : "icon_windows.png")
147
+ end
148
+ copy_asset.call(icon_macos, "icon_macos.png")
149
+
150
+ pubspec_path = File.join(client_dir, "pubspec.yaml")
151
+ return unless File.file?(pubspec_path)
152
+
153
+ if icon
154
+ update_pubspec_value(pubspec_path, "flutter_launcher_icons", "image_path", "\"assets/icon.png\"", multiple: true)
155
+ end
156
+ update_pubspec_value(pubspec_path, "flutter_launcher_icons", "image_path_android", "\"assets/icon_android.png\"", multiple: true) if icon_android
157
+ update_pubspec_value(pubspec_path, "flutter_launcher_icons", "image_path_ios", "\"assets/icon_ios.png\"", multiple: true) if icon_ios
158
+ update_pubspec_value(pubspec_path, "flutter_launcher_icons", "image_path_web", "\"assets/icon_web.png\"", multiple: true) if icon_web
159
+ if icon_windows
160
+ ext = File.extname(icon_windows).downcase
161
+ value = ext == ".ico" ? "\"assets/icon_windows.ico\"" : "\"assets/icon_windows.png\""
162
+ update_pubspec_value(pubspec_path, "flutter_launcher_icons", "image_path_windows", value, multiple: true)
163
+ end
164
+ update_pubspec_value(pubspec_path, "flutter_launcher_icons", "image_path_macos", "\"assets/icon_macos.png\"", multiple: true) if icon_macos
165
+ update_pubspec_value(pubspec_path, "flutter_launcher_icons", "background_color", "\"#{icon_background}\"") if icon_background
166
+ update_pubspec_value(pubspec_path, "flutter_launcher_icons", "theme_color", "\"#{theme_color}\"") if theme_color
167
+
168
+ update_pubspec_value(pubspec_path, "flutter_native_splash", "image", "\"assets/splash.png\"") if splash
169
+ update_pubspec_value(pubspec_path, "flutter_native_splash", "image_dark", "\"assets/splash_dark.png\"") if splash_dark
170
+ update_pubspec_value(pubspec_path, "flutter_native_splash", "color", "\"#{splash_color}\"") if splash_color
171
+ update_pubspec_value(pubspec_path, "flutter_native_splash", "color_dark", "\"#{splash_dark_color}\"") if splash_dark_color
172
+ end
173
+
174
+ def update_pubspec_value(path, block, key, value, multiple: false)
175
+ lines = File.read(path).split("\n", -1)
176
+ out = []
177
+ in_block = false
178
+ replaced = false
179
+ block_indent = nil
180
+ lines.each do |line|
181
+ if line.start_with?("#{block}:")
182
+ in_block = true
183
+ block_indent = line[/^\s*/] + " "
184
+ out << line
185
+ next
186
+ end
187
+
188
+ if in_block
189
+ if line =~ /^\S/ && !line.start_with?("#{block}:")
190
+ unless replaced
191
+ out << "#{block_indent}#{key}: #{value}"
192
+ replaced = true
193
+ end
194
+ in_block = false
195
+ else
196
+ if line.strip.start_with?("#{key}:")
197
+ indent = line[/^\s*/]
198
+ out << "#{indent}#{key}: #{value}"
199
+ replaced = true
200
+ next
201
+ end
202
+ end
203
+ end
204
+
205
+ out << line
206
+ end
207
+ if in_block && !replaced
208
+ out << "#{block_indent}#{key}: #{value}"
209
+ end
210
+ File.write(path, out.join("\n"))
211
+ end
212
+
42
213
  def flutter_build_command(platform)
43
214
  case platform
44
215
  when "apk", "android"
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module Ruflet
6
+ module CLI
7
+ module ExtraCommand
8
+ def command_create(args)
9
+ command_new(args)
10
+ end
11
+
12
+ def command_doctor(args)
13
+ verbose = args.delete("--verbose") || args.delete("-v")
14
+ puts "Ruflet doctor"
15
+ puts " Ruby: #{RUBY_VERSION}"
16
+ flutter = system("which flutter > /dev/null 2>&1")
17
+ puts " Flutter: #{flutter ? 'found' : 'missing'}"
18
+ if flutter
19
+ system("flutter", "doctor", *(verbose ? ["-v"] : []))
20
+ return $?.exitstatus || 0
21
+ end
22
+ 1
23
+ end
24
+
25
+ def command_devices(args)
26
+ ensure_flutter!("devices")
27
+ system("flutter", "devices", *args)
28
+ $?.exitstatus || 1
29
+ end
30
+
31
+ def command_emulators(args)
32
+ ensure_flutter!("emulators")
33
+ action = nil
34
+ emulator_id = nil
35
+ verbose = false
36
+ parser = OptionParser.new do |o|
37
+ o.on("--create") { action = "create" }
38
+ o.on("--delete") { action = "delete" }
39
+ o.on("--start") { action = "start" }
40
+ o.on("--emulator ID") { |v| emulator_id = v }
41
+ o.on("-v", "--verbose") { verbose = true }
42
+ end
43
+ parser.parse!(args)
44
+
45
+ case action
46
+ when "start"
47
+ unless emulator_id
48
+ warn "Missing --emulator for start"
49
+ return 1
50
+ end
51
+ cmd = ["flutter", "emulators", "--launch", emulator_id]
52
+ cmd << "-v" if verbose
53
+ system(*cmd)
54
+ $?.exitstatus || 1
55
+ when "create", "delete"
56
+ warn "ruflet emulators --#{action} is not implemented yet. Use your platform tools."
57
+ 1
58
+ else
59
+ cmd = ["flutter", "emulators"]
60
+ cmd << "-v" if verbose
61
+ system(*cmd)
62
+ $?.exitstatus || 1
63
+ end
64
+ end
65
+
66
+ def command_serve(args)
67
+ options = { port: 8550, root: Dir.pwd }
68
+ parser = OptionParser.new do |o|
69
+ o.on("-p", "--port PORT", Integer, "Port (default: 8550)") { |v| options[:port] = v }
70
+ o.on("-r", "--root PATH", "Root directory (default: current dir)") { |v| options[:root] = v }
71
+ end
72
+ parser.parse!(args)
73
+
74
+ require "webrick"
75
+ root = File.expand_path(options[:root])
76
+ server = WEBrick::HTTPServer.new(
77
+ Port: options[:port],
78
+ DocumentRoot: root,
79
+ AccessLog: [],
80
+ Logger: WEBrick::Log.new($stderr, WEBrick::Log::WARN)
81
+ )
82
+ trap("INT") { server.shutdown }
83
+ puts "Serving #{root} on http://127.0.0.1:#{options[:port]}"
84
+ server.start
85
+ 0
86
+ end
87
+
88
+ def command_pack(args)
89
+ platform = default_desktop_platform
90
+ unless platform
91
+ warn "pack is only supported on desktop hosts (macOS, Windows, Linux)"
92
+ return 1
93
+ end
94
+ command_build([platform] + args)
95
+ end
96
+
97
+ def command_publish(args)
98
+ command_build(["web"] + args)
99
+ end
100
+
101
+ def command_debug(args)
102
+ ensure_flutter!("debug")
103
+ options = {
104
+ platform: nil,
105
+ device_id: nil,
106
+ release: false,
107
+ verbose: false,
108
+ web_renderer: nil
109
+ }
110
+ parser = OptionParser.new do |o|
111
+ o.on("--platform NAME") { |v| options[:platform] = v }
112
+ o.on("--device-id ID") { |v| options[:device_id] = v }
113
+ o.on("--release") { options[:release] = true }
114
+ o.on("-v", "--verbose") { options[:verbose] = true }
115
+ o.on("--web-renderer NAME") { |v| options[:web_renderer] = v }
116
+ end
117
+ parser.parse!(args)
118
+
119
+ options[:platform] ||= args.shift
120
+ cmd = ["flutter", "run"]
121
+ cmd << "--release" if options[:release]
122
+ cmd << "-v" if options[:verbose]
123
+ cmd += ["--web-renderer", options[:web_renderer]] if options[:web_renderer]
124
+
125
+ if options[:device_id]
126
+ cmd += ["-d", options[:device_id]]
127
+ else
128
+ case options[:platform]
129
+ when "web"
130
+ cmd += ["-d", "chrome"]
131
+ when "macos", "windows", "linux"
132
+ cmd += ["-d", options[:platform]]
133
+ when "ios", "android"
134
+ # let flutter pick the default device
135
+ end
136
+ end
137
+
138
+ client_dir = detect_client_dir
139
+ unless client_dir
140
+ warn "Could not find Flutter client directory."
141
+ warn "Set RUFLET_CLIENT_DIR or place client at ./ruflet_client"
142
+ return 1
143
+ end
144
+
145
+ system(*cmd, chdir: client_dir)
146
+ $?.exitstatus || 1
147
+ end
148
+
149
+ private
150
+
151
+ def detect_client_dir
152
+ env_dir = ENV["RUFLET_CLIENT_DIR"]
153
+ return env_dir if env_dir && Dir.exist?(env_dir)
154
+
155
+ local = File.expand_path("ruflet_client", Dir.pwd)
156
+ return local if Dir.exist?(local)
157
+
158
+ template = File.expand_path("templates/ruflet_flutter_template", Dir.pwd)
159
+ return template if Dir.exist?(template)
160
+
161
+ nil
162
+ end
163
+
164
+ def default_desktop_platform
165
+ host = RbConfig::CONFIG["host_os"]
166
+ return "macos" if host =~ /darwin/i
167
+ return "windows" if host =~ /mswin|mingw|cygwin/i
168
+ return "linux" if host =~ /linux/i
169
+ nil
170
+ end
171
+
172
+ def ensure_flutter!(command_name)
173
+ return if system("which flutter > /dev/null 2>&1")
174
+
175
+ warn "Flutter is required for `ruflet #{command_name}`. Install Flutter and ensure it is on PATH."
176
+ exit 1
177
+ end
178
+ end
179
+ end
180
+ end
@@ -24,18 +24,53 @@ module Ruflet
24
24
  File.write(File.join(root, "Gemfile"), Ruflet::CLI::GEMFILE_TEMPLATE)
25
25
  File.write(File.join(root, ".bundle", "config"), Ruflet::CLI::BUNDLE_CONFIG_TEMPLATE)
26
26
  File.write(File.join(root, "README.md"), format(Ruflet::CLI::README_TEMPLATE, app_name: File.basename(root)))
27
+ copy_ruflet_client_template(root)
27
28
 
28
29
  project_name = File.basename(root)
29
30
  puts "Ruflet app created: #{project_name}"
30
31
  puts "Run:"
31
32
  puts " cd #{project_name}"
32
33
  puts " bundle install"
33
- puts " bundle exec ruflet run main"
34
+ puts " bundle exec ruflet run main.rb"
35
+ puts
36
+ puts "Client template:"
37
+ puts " cd ruflet_client"
38
+ puts " flutter pub get"
39
+ puts " flutter run"
34
40
  0
35
41
  end
36
42
 
37
43
  private
38
44
 
45
+ def copy_ruflet_client_template(root)
46
+ template_root = File.expand_path("../../../../../ruflet_client", __dir__)
47
+ return unless Dir.exist?(template_root)
48
+
49
+ target = File.join(root, "ruflet_client")
50
+ FileUtils.cp_r(template_root, target)
51
+ prune_client_template(target)
52
+ end
53
+
54
+ def prune_client_template(target)
55
+ paths = %w[
56
+ .dart_tool
57
+ .idea
58
+ build
59
+ ios/Pods
60
+ ios/.symlinks
61
+ ios/Podfile.lock
62
+ macos/Pods
63
+ macos/Podfile.lock
64
+ android/.gradle
65
+ android/.kotlin
66
+ android/local.properties
67
+ ]
68
+ paths.each do |path|
69
+ full = File.join(target, path)
70
+ FileUtils.rm_rf(full) if File.exist?(full)
71
+ end
72
+ end
73
+
39
74
  def humanize_name(name)
40
75
  name.to_s.gsub(/[_-]+/, " ").split.map(&:capitalize).join(" ")
41
76
  end
@@ -3,6 +3,12 @@
3
3
  require "optparse"
4
4
  require "rbconfig"
5
5
  require "socket"
6
+ require "timeout"
7
+ require "tmpdir"
8
+ require "fileutils"
9
+ require "json"
10
+ require "net/http"
11
+ require "uri"
6
12
 
7
13
  module Ruflet
8
14
  module CLI
@@ -30,8 +36,10 @@ module Ruflet
30
36
  "RUFLET_SUPPRESS_SERVER_BANNER" => "1",
31
37
  "RUFLET_PORT" => selected_port.to_s
32
38
  }
39
+ assets_dir = File.join(File.dirname(script_path), "assets")
40
+ env["RUFLET_ASSETS_DIR"] = assets_dir if File.directory?(assets_dir)
33
41
 
34
- puts "Requested port 8550 is busy; bound to #{selected_port}" if selected_port != 8550
42
+ print_run_banner(target: options[:target], port: selected_port)
35
43
  print_mobile_qr_hint(port: selected_port) if options[:target] == "mobile"
36
44
 
37
45
  cmd =
@@ -47,6 +55,7 @@ module Ruflet
47
55
  end
48
56
 
49
57
  child_pid = Process.spawn(env, *cmd, pgroup: true)
58
+ launched_client_pids = launch_target_client(options[:target], selected_port)
50
59
  forward_signal = lambda do |signal|
51
60
  begin
52
61
  Process.kill(signal, -child_pid)
@@ -71,6 +80,18 @@ module Ruflet
71
80
  nil
72
81
  end
73
82
  end
83
+
84
+ Array(defined?(launched_client_pids) ? launched_client_pids : nil).compact.each do |pid|
85
+ begin
86
+ Process.kill("TERM", -pid)
87
+ rescue Errno::ESRCH
88
+ begin
89
+ Process.kill("TERM", pid)
90
+ rescue Errno::ESRCH
91
+ nil
92
+ end
93
+ end
94
+ end
74
95
  end
75
96
 
76
97
  private
@@ -85,6 +106,361 @@ module Ruflet
85
106
  nil
86
107
  end
87
108
 
109
+ def print_run_banner(target:, port:)
110
+ if port != 8550
111
+ puts "Requested port 8550 is busy; bound to #{port}"
112
+ end
113
+ if target == "desktop"
114
+ puts "Ruflet desktop URL: http://localhost:#{port}"
115
+ else
116
+ puts "Ruflet target: #{target}"
117
+ puts "Ruflet URL: http://localhost:#{port}"
118
+ end
119
+ end
120
+
121
+ def launch_target_client(target, port)
122
+ wait_for_server_boot(port)
123
+
124
+ case target
125
+ when "web"
126
+ launch_web_client(port)
127
+ when "desktop"
128
+ launch_desktop_client("http://localhost:#{port}")
129
+ else
130
+ []
131
+ end
132
+ end
133
+
134
+ def launch_web_client(port)
135
+ web_dir = detect_web_client_dir
136
+ unless web_dir
137
+ warn "Web client build not found and prebuilt download failed."
138
+ return []
139
+ end
140
+
141
+ web_port = find_available_port(port + 1)
142
+ 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)
143
+ Process.detach(web_pid)
144
+ wait_for_server_boot(web_port)
145
+ browser_pid = open_in_browser_app_mode("http://localhost:#{web_port}")
146
+ open_in_browser("http://localhost:#{web_port}") if browser_pid.nil?
147
+ puts "Ruflet web client: http://localhost:#{web_port}"
148
+ puts "Ruflet backend ws: ws://localhost:#{port}/ws"
149
+ [web_pid, browser_pid].compact
150
+ rescue Errno::ENOENT
151
+ warn "python3 is required to host web client locally."
152
+ warn "Install Python 3 and rerun."
153
+ []
154
+ rescue StandardError => e
155
+ warn "Failed to launch web client: #{e.class}: #{e.message}"
156
+ []
157
+ end
158
+
159
+ def wait_for_server_boot(port, timeout_seconds: 10)
160
+ Timeout.timeout(timeout_seconds) do
161
+ loop do
162
+ begin
163
+ sock = TCPSocket.new("127.0.0.1", port)
164
+ sock.write("GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
165
+ sock.close
166
+ break
167
+ rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH
168
+ sleep 0.15
169
+ end
170
+ end
171
+ end
172
+ rescue Timeout::Error
173
+ warn "Server did not become reachable at http://localhost:#{port} yet."
174
+ end
175
+
176
+ def open_in_browser(url)
177
+ cmd =
178
+ case RbConfig::CONFIG["host_os"]
179
+ when /darwin/i
180
+ ["open", url]
181
+ when /mswin|mingw|cygwin/i
182
+ ["cmd", "/c", "start", "", url]
183
+ else
184
+ ["xdg-open", url]
185
+ end
186
+ if system(*cmd, out: File::NULL, err: File::NULL)
187
+ puts "Opened browser at #{url}"
188
+ else
189
+ warn "Could not auto-open browser. Open manually: #{url}"
190
+ end
191
+ end
192
+
193
+ def open_in_browser_app_mode(url)
194
+ host_os = RbConfig::CONFIG["host_os"]
195
+ if host_os.match?(/darwin/i)
196
+ chrome = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
197
+ chromium = "/Applications/Chromium.app/Contents/MacOS/Chromium"
198
+ browser = [chrome, chromium].find { |p| File.file?(p) && File.executable?(p) }
199
+ return nil unless browser
200
+
201
+ profile_dir = Dir.mktmpdir("ruflet-webapp-")
202
+ pid = Process.spawn(
203
+ browser,
204
+ "--new-window",
205
+ "--no-first-run",
206
+ "--no-default-browser-check",
207
+ "--user-data-dir=#{profile_dir}",
208
+ "--app=#{url}",
209
+ pgroup: true,
210
+ out: File::NULL,
211
+ err: File::NULL
212
+ )
213
+ Process.detach(pid)
214
+ return pid
215
+ end
216
+
217
+ if host_os.match?(/linux/i)
218
+ browser = %w[google-chrome chromium chromium-browser].find { |cmd| system("which", cmd, out: File::NULL, err: File::NULL) }
219
+ return nil unless browser
220
+
221
+ profile_dir = Dir.mktmpdir("ruflet-webapp-")
222
+ pid = Process.spawn(
223
+ browser,
224
+ "--new-window",
225
+ "--no-first-run",
226
+ "--no-default-browser-check",
227
+ "--user-data-dir=#{profile_dir}",
228
+ "--app=#{url}",
229
+ pgroup: true,
230
+ out: File::NULL,
231
+ err: File::NULL
232
+ )
233
+ Process.detach(pid)
234
+ return pid
235
+ end
236
+
237
+ nil
238
+ rescue StandardError
239
+ nil
240
+ end
241
+
242
+ def launch_desktop_client(url)
243
+ cmd = detect_desktop_client_command(url)
244
+ unless cmd
245
+ warn "Desktop client executable not found."
246
+ warn "Set RUFLET_CLIENT_DIR to your client path."
247
+ warn "Example: export RUFLET_CLIENT_DIR=/path/to/ruflet_client"
248
+ return
249
+ end
250
+
251
+ pid = Process.spawn(*cmd, out: File::NULL, err: File::NULL)
252
+ Process.detach(pid)
253
+ if !pid
254
+ warn "Failed to launch desktop client: #{cmd.first}"
255
+ warn "Start it manually with URL: #{url}"
256
+ end
257
+ [pid]
258
+ rescue StandardError => e
259
+ warn "Failed to launch desktop client: #{e.class}: #{e.message}"
260
+ warn "Start it manually with URL: #{url}"
261
+ []
262
+ end
263
+
264
+ def detect_desktop_client_command(url)
265
+ root = ENV["RUFLET_CLIENT_DIR"]
266
+ root = File.expand_path("ruflet_client", Dir.pwd) if root.to_s.strip.empty?
267
+ root = nil unless Dir.exist?(root)
268
+ root ||= ensure_prebuilt_client(desktop: true)
269
+ return nil unless root && Dir.exist?(root)
270
+
271
+ host_os = RbConfig::CONFIG["host_os"]
272
+ if host_os.match?(/darwin/i)
273
+ release_bin = File.join(root, "build", "macos", "Build", "Products", "Release", "ruflet_client.app", "Contents", "MacOS", "ruflet_client")
274
+ debug_bin = File.join(root, "build", "macos", "Build", "Products", "Debug", "ruflet_client.app", "Contents", "MacOS", "ruflet_client")
275
+ prebuilt_bin = File.join(root, "desktop", "ruflet_client.app", "Contents", "MacOS", "ruflet_client")
276
+ executable = [release_bin, debug_bin].find { |p| File.file?(p) && File.executable?(p) }
277
+ executable ||= prebuilt_bin if File.file?(prebuilt_bin) && File.executable?(prebuilt_bin)
278
+ return [executable, url] if executable
279
+ elsif host_os.match?(/mswin|mingw|cygwin/i)
280
+ exe = File.join(root, "build", "windows", "x64", "runner", "Release", "ruflet_client.exe")
281
+ prebuilt = File.join(root, "desktop", "ruflet_client.exe")
282
+ exe = prebuilt if !File.file?(exe) && File.file?(prebuilt)
283
+ return [exe, url] if File.file?(exe)
284
+ else
285
+ direct = File.join(root, "build", "linux", "x64", "release", "bundle", "ruflet_client")
286
+ prebuilt_direct = File.join(root, "desktop", "ruflet_client")
287
+ direct = prebuilt_direct if !File.file?(direct) && File.file?(prebuilt_direct)
288
+ return [direct, url] if File.file?(direct)
289
+ bundle_dir = File.join(root, "build", "linux", "x64", "release", "bundle")
290
+ if Dir.exist?(bundle_dir)
291
+ candidate = Dir.children(bundle_dir).map { |f| File.join(bundle_dir, f) }
292
+ .find { |path| File.file?(path) && File.executable?(path) }
293
+ return [candidate, url] if candidate
294
+ end
295
+ end
296
+
297
+ nil
298
+ end
299
+
300
+ def detect_web_client_dir
301
+ root = ENV["RUFLET_CLIENT_DIR"]
302
+ root = File.expand_path("ruflet_client", Dir.pwd) if root.to_s.strip.empty?
303
+ root = nil unless Dir.exist?(root)
304
+ root ||= ensure_prebuilt_client(web: true)
305
+ return nil unless root && Dir.exist?(root)
306
+
307
+ built = File.join(root, "build", "web")
308
+ return built if Dir.exist?(built) && File.file?(File.join(built, "index.html"))
309
+ prebuilt = File.join(root, "web")
310
+ return prebuilt if Dir.exist?(prebuilt) && File.file?(File.join(prebuilt, "index.html"))
311
+
312
+ nil
313
+ end
314
+
315
+ def ensure_prebuilt_client(web: false, desktop: false)
316
+ platform = host_platform_name
317
+ return nil if platform.nil?
318
+
319
+ cache_root = File.join(Dir.home, ".ruflet", "client", Ruflet::VERSION, platform)
320
+ FileUtils.mkdir_p(cache_root)
321
+
322
+ wanted_assets = []
323
+ wanted_assets << "ruflet_client-web.tar.gz" if web
324
+ if desktop
325
+ desktop_asset = desktop_asset_name_for(platform)
326
+ return nil if desktop_asset.nil?
327
+ wanted_assets << desktop_asset
328
+ end
329
+ return cache_root if wanted_assets.empty? || prebuilt_assets_present?(cache_root, web: web, desktop: desktop)
330
+
331
+ release = fetch_release_for_version
332
+ return nil unless release
333
+
334
+ assets = release.fetch("assets", [])
335
+ Dir.mktmpdir("ruflet-prebuilt-") do |tmpdir|
336
+ wanted_assets.each do |asset_name|
337
+ asset = assets.find { |a| a["name"] == asset_name }
338
+ unless asset
339
+ warn "Missing release asset: #{asset_name}"
340
+ return nil
341
+ end
342
+ archive_path = File.join(tmpdir, asset_name)
343
+ download_file(asset.fetch("browser_download_url"), archive_path)
344
+ subdir = asset_name.include?("-web.") ? "web" : "desktop"
345
+ target = File.join(cache_root, subdir)
346
+ FileUtils.mkdir_p(target)
347
+ unless extract_archive(archive_path, target)
348
+ warn "Failed to extract asset: #{asset_name}"
349
+ return nil
350
+ end
351
+ end
352
+ end
353
+
354
+ return cache_root if prebuilt_assets_present?(cache_root, web: web, desktop: desktop)
355
+
356
+ nil
357
+ rescue StandardError => e
358
+ warn "Prebuilt client bootstrap failed: #{e.class}: #{e.message}"
359
+ nil
360
+ end
361
+
362
+ def prebuilt_assets_present?(root, web:, desktop:)
363
+ ok_web = !web || File.file?(File.join(root, "web", "index.html"))
364
+ ok_desktop = !desktop || prebuilt_desktop_present?(root)
365
+ ok_web && ok_desktop
366
+ end
367
+
368
+ def prebuilt_desktop_present?(root)
369
+ platform = host_platform_name
370
+ return false if platform.nil?
371
+
372
+ case platform
373
+ when "macos"
374
+ File.file?(File.join(root, "desktop", "ruflet_client.app", "Contents", "MacOS", "ruflet_client"))
375
+ when "linux"
376
+ File.file?(File.join(root, "desktop", "ruflet_client"))
377
+ when "windows"
378
+ File.file?(File.join(root, "desktop", "ruflet_client.exe"))
379
+ else
380
+ false
381
+ end
382
+ end
383
+
384
+ def host_platform_name
385
+ host_os = RbConfig::CONFIG["host_os"]
386
+ return "macos" if host_os.match?(/darwin/i)
387
+ return "linux" if host_os.match?(/linux/i)
388
+ return "windows" if host_os.match?(/mswin|mingw|cygwin/i)
389
+
390
+ nil
391
+ end
392
+
393
+ def desktop_asset_name_for(platform)
394
+ case platform
395
+ when "macos" then "ruflet_client-macos-universal.zip"
396
+ when "linux" then "ruflet_client-linux-x64.tar.gz"
397
+ when "windows" then "ruflet_client-windows-x64.zip"
398
+ end
399
+ end
400
+
401
+ def fetch_release_for_version
402
+ release_by_tag("v#{Ruflet::VERSION}") || release_latest
403
+ end
404
+
405
+ def release_latest
406
+ github_get_json("https://api.github.com/repos/AdamMusa/Ruflet/releases/latest")
407
+ end
408
+
409
+ def release_by_tag(tag)
410
+ github_get_json("https://api.github.com/repos/AdamMusa/Ruflet/releases/tags/#{tag}")
411
+ rescue StandardError
412
+ nil
413
+ end
414
+
415
+ def github_get_json(url)
416
+ uri = URI(url)
417
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
418
+ req = Net::HTTP::Get.new(uri)
419
+ req["Accept"] = "application/vnd.github+json"
420
+ req["User-Agent"] = "ruflet-cli"
421
+ http.request(req)
422
+ end
423
+ return JSON.parse(response.body) if response.is_a?(Net::HTTPSuccess)
424
+
425
+ raise "GitHub API failed (#{response.code})"
426
+ end
427
+
428
+ def download_file(url, destination, limit: 5)
429
+ raise "Too many redirects while downloading #{url}" if limit <= 0
430
+
431
+ uri = URI(url)
432
+ Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
433
+ req = Net::HTTP::Get.new(uri)
434
+ req["User-Agent"] = "ruflet-cli"
435
+ http.request(req) do |res|
436
+ case res
437
+ when Net::HTTPSuccess
438
+ File.open(destination, "wb") { |f| res.read_body { |chunk| f.write(chunk) } }
439
+ return destination
440
+ when Net::HTTPRedirection
441
+ return download_file(res["location"], destination, limit: limit - 1)
442
+ else
443
+ raise "Download failed (#{res.code})"
444
+ end
445
+ end
446
+ end
447
+ end
448
+
449
+ def extract_archive(archive, destination)
450
+ if archive.end_with?(".tar.gz")
451
+ return system("tar", "-xzf", archive, "-C", destination, out: File::NULL, err: File::NULL)
452
+ end
453
+ if archive.end_with?(".zip")
454
+ host_os = RbConfig::CONFIG["host_os"]
455
+ if host_os.match?(/darwin/i)
456
+ return system("ditto", "-x", "-k", archive, destination, out: File::NULL, err: File::NULL)
457
+ end
458
+ return system("unzip", "-oq", archive, "-d", destination, out: File::NULL, err: File::NULL)
459
+ end
460
+
461
+ false
462
+ end
463
+
88
464
  def print_mobile_qr_hint(port: 8550)
89
465
  host = best_lan_host
90
466
  payload = "http://#{host}:#{port}"
@@ -13,8 +13,6 @@ module Ruflet
13
13
 
14
14
  def view(page)
15
15
  page.title = "Counter Demo"
16
- page.vertical_alignment = Ruflet::MainAxisAlignment::CENTER
17
- page.horizontal_alignment = Ruflet::CrossAxisAlignment::CENTER
18
16
  count_text = page.text(value: @count.to_s, size: 40)
19
17
 
20
18
  page.add(
@@ -47,13 +45,13 @@ module Ruflet
47
45
  end
48
46
 
49
47
  MainApp.new.run
50
-
51
48
  RUBY
52
49
 
53
50
  GEMFILE_TEMPLATE = <<~GEMFILE
54
51
  source "https://rubygems.org"
55
52
 
56
53
  gem "ruflet", ">= 0.0.3"
54
+ gem "ruflet_protocol", ">= 0.0.3"
57
55
  gem "ruflet_server", ">= 0.0.3"
58
56
  GEMFILE
59
57
 
@@ -77,7 +75,17 @@ module Ruflet
77
75
  ## Run
78
76
 
79
77
  ```bash
80
- bundle exec ruflet run main
78
+ bundle exec ruflet run main.rb
79
+ ```
80
+
81
+ ## Client Template
82
+
83
+ `ruflet_client` template is generated inside this app.
84
+
85
+ ```bash
86
+ cd ruflet_client
87
+ flutter pub get
88
+ flutter run
81
89
  ```
82
90
 
83
91
  ## Build
data/lib/ruflet/cli.rb CHANGED
@@ -6,6 +6,7 @@ require_relative "cli/templates"
6
6
  require_relative "cli/new_command"
7
7
  require_relative "cli/run_command"
8
8
  require_relative "cli/build_command"
9
+ require_relative "cli/extra_command"
9
10
 
10
11
  module Ruflet
11
12
  module CLI
@@ -13,17 +14,34 @@ module Ruflet
13
14
  extend NewCommand
14
15
  extend RunCommand
15
16
  extend BuildCommand
17
+ extend ExtraCommand
16
18
 
17
19
  def run(argv = ARGV)
18
20
  command = (argv.shift || "help").downcase
19
21
 
20
22
  case command
23
+ when "create"
24
+ command_create(argv)
21
25
  when "new", "bootstrap", "init"
22
26
  command_new(argv)
23
27
  when "run"
24
28
  command_run(argv)
29
+ when "debug"
30
+ command_debug(argv)
25
31
  when "build"
26
32
  command_build(argv)
33
+ when "pack"
34
+ command_pack(argv)
35
+ when "publish"
36
+ command_publish(argv)
37
+ when "serve"
38
+ command_serve(argv)
39
+ when "devices"
40
+ command_devices(argv)
41
+ when "emulators"
42
+ command_emulators(argv)
43
+ when "doctor"
44
+ command_doctor(argv)
27
45
  when "help", "-h", "--help"
28
46
  print_help
29
47
  0
@@ -39,9 +57,17 @@ module Ruflet
39
57
  Ruflet CLI
40
58
 
41
59
  Commands:
60
+ ruflet create <appname>
42
61
  ruflet new <appname>
43
62
  ruflet run [scriptname|path] [--web|--mobile|--desktop]
63
+ ruflet debug [scriptname|path]
44
64
  ruflet build <apk|ios|aab|web|macos|windows|linux>
65
+ ruflet pack
66
+ ruflet publish
67
+ ruflet serve [--port N] [--root PATH]
68
+ ruflet devices
69
+ ruflet emulators
70
+ ruflet doctor
45
71
  HELP
46
72
  end
47
73
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ruflet
4
- VERSION = "0.0.3" unless const_defined?(:VERSION)
4
+ VERSION = "0.0.4" unless const_defined?(:VERSION)
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruflet_cli
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.3
4
+ version: 0.0.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - AdamMusa
@@ -35,6 +35,7 @@ files:
35
35
  - bin/ruflet
36
36
  - lib/ruflet/cli.rb
37
37
  - lib/ruflet/cli/build_command.rb
38
+ - lib/ruflet/cli/extra_command.rb
38
39
  - lib/ruflet/cli/new_command.rb
39
40
  - lib/ruflet/cli/run_command.rb
40
41
  - lib/ruflet/cli/templates.rb