ruflet_cli 0.0.7 → 0.0.8
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/lib/ruflet/cli/build_command.rb +171 -40
- data/lib/ruflet/cli/new_command.rb +145 -1
- data/lib/ruflet/cli/run_command.rb +22 -196
- data/lib/ruflet/cli/templates.rb +3 -3
- data/lib/ruflet/cli.rb +3 -3
- data/lib/ruflet/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bda605a60090590b4fd902f1b14a3b81c44fbe28cdd08ece037611feae2d775c
|
|
4
|
+
data.tar.gz: f52031603e160ccaeefe976e4b3edd24ea383f03d61dca9f15141411d7ee030e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 87e2ffbe8ff812a0a56c4b7d3a55f8c981d4b614efecd451709bf026687b4c60fd42778e11d5e978c4a34f8bccafb7f93b522ea241a6d6eb51aff3f20e1e9a79
|
|
7
|
+
data.tar.gz: fba28f53a351ba5edb0e1f9bc16b178132ce8f487029d263777f11092cb19df312ea3113301b201e03b39c595ef9ca7a6d87f1e539a9fc619a1822d62969890c
|
|
@@ -1,17 +1,36 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
|
+
require "uri"
|
|
4
5
|
require "yaml"
|
|
5
6
|
|
|
6
7
|
module Ruflet
|
|
7
8
|
module CLI
|
|
8
9
|
module BuildCommand
|
|
9
10
|
include FlutterSdk
|
|
11
|
+
CLIENT_EXTENSION_MAP = {
|
|
12
|
+
"ads" => { package: "flet_ads", alias: "ruflet_ads" },
|
|
13
|
+
"audio" => { package: "flet_audio", alias: "ruflet_audio" },
|
|
14
|
+
"audio_recorder" => { package: "flet_audio_recorder", alias: "ruflet_audio_recorder" },
|
|
15
|
+
"camera" => { package: "flet_camera", alias: "ruflet_camera" },
|
|
16
|
+
"charts" => { package: "flet_charts", alias: "ruflet_charts" },
|
|
17
|
+
"code_editor" => { package: "flet_code_editor", alias: "ruflet_code_editor" },
|
|
18
|
+
"color_pickers" => { package: "flet_color_pickers", alias: "ruflet_color_picker" },
|
|
19
|
+
"datatable2" => { package: "flet_datatable2", alias: "ruflet_datatable2" },
|
|
20
|
+
"flashlight" => { package: "flet_flashlight", alias: "ruflet_flashlight" },
|
|
21
|
+
"geolocator" => { package: "flet_geolocator", alias: "ruflet_geolocator" },
|
|
22
|
+
"lottie" => { package: "flet_lottie", alias: "ruflet_lottie" },
|
|
23
|
+
"map" => { package: "flet_map", alias: "ruflet_map" },
|
|
24
|
+
"permission_handler" => { package: "flet_permission_handler", alias: "ruflet_permission_handler" },
|
|
25
|
+
"secure_storage" => { package: "flet_secure_storage", alias: "ruflet_secure_storage" },
|
|
26
|
+
"video" => { package: "flet_video", alias: "ruflet_video" },
|
|
27
|
+
"webview" => { package: "flet_webview", alias: "ruflet_webview" }
|
|
28
|
+
}.freeze
|
|
10
29
|
|
|
11
30
|
def command_build(args)
|
|
12
31
|
platform = (args.shift || "").downcase
|
|
13
32
|
if platform.empty?
|
|
14
|
-
warn "Usage: ruflet build <apk|ios|aab|web|macos|windows|linux>"
|
|
33
|
+
warn "Usage: ruflet build <apk|android|ios|aab|web|macos|windows|linux>"
|
|
15
34
|
return 1
|
|
16
35
|
end
|
|
17
36
|
|
|
@@ -28,11 +47,18 @@ module Ruflet
|
|
|
28
47
|
return 1
|
|
29
48
|
end
|
|
30
49
|
|
|
50
|
+
config = load_ruflet_config
|
|
31
51
|
tools = ensure_flutter!("build", client_dir: client_dir)
|
|
32
|
-
ok = prepare_flutter_client(client_dir, tools: tools)
|
|
52
|
+
ok = prepare_flutter_client(client_dir, tools: tools, config: config)
|
|
33
53
|
return 1 unless ok
|
|
34
54
|
|
|
35
|
-
|
|
55
|
+
build_args = [*flutter_cmd, *args]
|
|
56
|
+
client_url = configured_client_url(config)
|
|
57
|
+
if client_url
|
|
58
|
+
build_args += ["--dart-define", "RUFLET_CLIENT_URL=#{client_url}"]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
ok = system(tools[:env], tools[:flutter], *build_args, chdir: client_dir)
|
|
36
62
|
ok ? 0 : 1
|
|
37
63
|
end
|
|
38
64
|
|
|
@@ -51,35 +77,72 @@ module Ruflet
|
|
|
51
77
|
nil
|
|
52
78
|
end
|
|
53
79
|
|
|
54
|
-
def prepare_flutter_client(client_dir, tools:)
|
|
55
|
-
|
|
80
|
+
def prepare_flutter_client(client_dir, tools:, config:)
|
|
81
|
+
apply_service_extension_config(client_dir, config)
|
|
82
|
+
asset_flags = apply_build_config(client_dir, config)
|
|
83
|
+
if asset_flags[:error]
|
|
84
|
+
warn asset_flags[:error]
|
|
85
|
+
return false
|
|
86
|
+
end
|
|
56
87
|
unless system(tools[:env], tools[:flutter], "pub", "get", chdir: client_dir)
|
|
57
88
|
warn "flutter pub get failed"
|
|
58
89
|
return false
|
|
59
90
|
end
|
|
60
91
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
92
|
+
if asset_flags[:has_splash]
|
|
93
|
+
unless system(tools[:env], tools[:dart], "run", "flutter_native_splash:create", chdir: client_dir)
|
|
94
|
+
warn "flutter_native_splash failed"
|
|
95
|
+
return false
|
|
96
|
+
end
|
|
64
97
|
end
|
|
65
98
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
99
|
+
if asset_flags[:has_icon]
|
|
100
|
+
unless system(tools[:env], tools[:dart], "run", "flutter_launcher_icons", chdir: client_dir)
|
|
101
|
+
warn "flutter_launcher_icons failed"
|
|
102
|
+
return false
|
|
103
|
+
end
|
|
69
104
|
end
|
|
70
105
|
|
|
71
106
|
true
|
|
72
107
|
end
|
|
73
108
|
|
|
74
|
-
def
|
|
109
|
+
def configured_client_url(config)
|
|
110
|
+
candidates = [
|
|
111
|
+
config["ruflet_client_url"],
|
|
112
|
+
(config["app"].is_a?(Hash) ? config["app"]["ruflet_client_url"] : nil)
|
|
113
|
+
]
|
|
114
|
+
raw = candidates.find { |v| !v.to_s.strip.empty? }
|
|
115
|
+
return nil if raw.nil?
|
|
116
|
+
|
|
117
|
+
value = raw.to_s.strip
|
|
118
|
+
uri = URI.parse(value)
|
|
119
|
+
return nil unless %w[http https ws wss].include?(uri.scheme)
|
|
120
|
+
return nil if uri.host.to_s.strip.empty?
|
|
121
|
+
|
|
122
|
+
value
|
|
123
|
+
rescue URI::InvalidURIError
|
|
124
|
+
nil
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def load_ruflet_config
|
|
75
128
|
config_path = ENV["RUFLET_CONFIG"] || "ruflet.yaml"
|
|
76
129
|
unless File.file?(config_path)
|
|
77
130
|
alt = "ruflet.yml"
|
|
78
131
|
config_path = alt if File.file?(alt)
|
|
79
132
|
end
|
|
133
|
+
return {} unless File.file?(config_path)
|
|
80
134
|
|
|
135
|
+
YAML.safe_load(File.read(config_path), aliases: true) || {}
|
|
136
|
+
rescue StandardError => e
|
|
137
|
+
warn "Failed to load ruflet config: #{e.class}: #{e.message}"
|
|
138
|
+
{}
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def apply_build_config(client_dir, config = {})
|
|
142
|
+
build = config["build"] || {}
|
|
143
|
+
assets = config["assets"] || {}
|
|
144
|
+
config_path = ENV["RUFLET_CONFIG"] || (File.file?("ruflet.yaml") ? "ruflet.yaml" : "ruflet.yml")
|
|
81
145
|
config_present = File.file?(config_path)
|
|
82
|
-
config = config_present ? (YAML.load_file(config_path) || {}) : {}
|
|
83
146
|
build = config["build"] || {}
|
|
84
147
|
assets = config["assets"] || {}
|
|
85
148
|
config_dir = config_present ? File.dirname(File.expand_path(config_path)) : Dir.pwd
|
|
@@ -87,44 +150,24 @@ module Ruflet
|
|
|
87
150
|
assets_root = build["assets_dir"] || assets["dir"] || config["assets_dir"] || "assets"
|
|
88
151
|
assets_root = File.expand_path(assets_root, config_dir)
|
|
89
152
|
|
|
90
|
-
unless config_present || Dir.exist?(assets_root) || ENV["RUFLET_SPLASH"] || ENV["RUFLET_ICON"]
|
|
91
|
-
return
|
|
92
|
-
end
|
|
93
|
-
|
|
94
153
|
resolve_asset = lambda do |path|
|
|
95
154
|
return nil if path.nil? || path.to_s.strip.empty?
|
|
96
155
|
full = File.expand_path(path.to_s, config_dir)
|
|
97
156
|
File.file?(full) ? full : nil
|
|
98
157
|
end
|
|
99
158
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
candidate = File.join(dir, name)
|
|
103
|
-
return candidate if File.file?(candidate)
|
|
104
|
-
end
|
|
105
|
-
nil
|
|
106
|
-
end
|
|
159
|
+
splash_defined = key_defined?(build, "splash_screen") || key_defined?(assets, "splash_screen") || key_defined?(config, "splash_screen")
|
|
160
|
+
icon_defined = key_defined?(build, "icon_launcher") || key_defined?(assets, "icon_launcher") || key_defined?(config, "icon_launcher")
|
|
107
161
|
|
|
108
|
-
splash = resolve_asset.call(build["
|
|
162
|
+
splash = resolve_asset.call(build["splash_screen"] || assets["splash_screen"] || config["splash_screen"])
|
|
109
163
|
splash_dark = resolve_asset.call(build["splash_dark"] || build["splash_dark_image"] || assets["splash_dark"])
|
|
110
|
-
icon = resolve_asset.call(build["
|
|
164
|
+
icon = resolve_asset.call(build["icon_launcher"] || assets["icon_launcher"] || config["icon_launcher"])
|
|
111
165
|
icon_android = resolve_asset.call(build["icon_android"] || assets["icon_android"])
|
|
112
166
|
icon_ios = resolve_asset.call(build["icon_ios"] || assets["icon_ios"])
|
|
113
167
|
icon_web = resolve_asset.call(build["icon_web"] || assets["icon_web"])
|
|
114
168
|
icon_windows = resolve_asset.call(build["icon_windows"] || assets["icon_windows"])
|
|
115
169
|
icon_macos = resolve_asset.call(build["icon_macos"] || assets["icon_macos"])
|
|
116
170
|
|
|
117
|
-
if Dir.exist?(assets_root)
|
|
118
|
-
splash ||= find_first.call(assets_root, ["splash.png", "splash.jpg", "splash.webp", "splash.bmp"])
|
|
119
|
-
splash_dark ||= find_first.call(assets_root, ["splash_dark.png", "splash_dark.jpg", "splash_dark.webp", "splash_dark.bmp"])
|
|
120
|
-
icon ||= find_first.call(assets_root, ["icon.png", "icon.jpg", "icon.webp", "icon.bmp"])
|
|
121
|
-
icon_android ||= find_first.call(assets_root, ["icon_android.png", "icon_android.jpg", "icon_android.webp"])
|
|
122
|
-
icon_ios ||= find_first.call(assets_root, ["icon_ios.png", "icon_ios.jpg", "icon_ios.webp"])
|
|
123
|
-
icon_web ||= find_first.call(assets_root, ["icon_web.png", "icon_web.jpg", "icon_web.webp"])
|
|
124
|
-
icon_windows ||= find_first.call(assets_root, ["icon_windows.ico", "icon_windows.png"])
|
|
125
|
-
icon_macos ||= find_first.call(assets_root, ["icon_macos.png", "icon_macos.jpg", "icon_macos.webp"])
|
|
126
|
-
end
|
|
127
|
-
|
|
128
171
|
splash_color = build["splash_color"]
|
|
129
172
|
splash_dark_color = build["splash_dark_color"] || build["splash_color_dark"]
|
|
130
173
|
icon_background = build["icon_background"]
|
|
@@ -150,10 +193,19 @@ module Ruflet
|
|
|
150
193
|
end
|
|
151
194
|
copy_asset.call(icon_macos, "icon_macos.png")
|
|
152
195
|
|
|
196
|
+
if splash_defined && splash.nil?
|
|
197
|
+
return { has_icon: false, has_splash: false, error: "build config error: splash_screen is set but file was not found" }
|
|
198
|
+
end
|
|
199
|
+
if icon_defined && icon.nil?
|
|
200
|
+
return { has_icon: false, has_splash: false, error: "build config error: icon_launcher is set but file was not found" }
|
|
201
|
+
end
|
|
202
|
+
|
|
153
203
|
pubspec_path = File.join(client_dir, "pubspec.yaml")
|
|
154
|
-
|
|
204
|
+
unless File.file?(pubspec_path)
|
|
205
|
+
return { has_icon: icon_defined && !icon.nil?, has_splash: splash_defined && !splash.nil?, error: nil }
|
|
206
|
+
end
|
|
155
207
|
|
|
156
|
-
if icon
|
|
208
|
+
if icon_defined && icon
|
|
157
209
|
update_pubspec_value(pubspec_path, "flutter_launcher_icons", "image_path", "\"assets/icon.png\"", multiple: true)
|
|
158
210
|
end
|
|
159
211
|
update_pubspec_value(pubspec_path, "flutter_launcher_icons", "image_path_android", "\"assets/icon_android.png\"", multiple: true) if icon_android
|
|
@@ -168,10 +220,89 @@ module Ruflet
|
|
|
168
220
|
update_pubspec_value(pubspec_path, "flutter_launcher_icons", "background_color", "\"#{icon_background}\"") if icon_background
|
|
169
221
|
update_pubspec_value(pubspec_path, "flutter_launcher_icons", "theme_color", "\"#{theme_color}\"") if theme_color
|
|
170
222
|
|
|
171
|
-
update_pubspec_value(pubspec_path, "flutter_native_splash", "image", "\"assets/splash.png\"") if splash
|
|
223
|
+
update_pubspec_value(pubspec_path, "flutter_native_splash", "image", "\"assets/splash.png\"") if splash_defined && splash
|
|
172
224
|
update_pubspec_value(pubspec_path, "flutter_native_splash", "image_dark", "\"assets/splash_dark.png\"") if splash_dark
|
|
173
225
|
update_pubspec_value(pubspec_path, "flutter_native_splash", "color", "\"#{splash_color}\"") if splash_color
|
|
174
226
|
update_pubspec_value(pubspec_path, "flutter_native_splash", "color_dark", "\"#{splash_dark_color}\"") if splash_dark_color
|
|
227
|
+
|
|
228
|
+
{ has_icon: icon_defined && !icon.nil?, has_splash: splash_defined && !splash.nil?, error: nil }
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def key_defined?(hash, key)
|
|
232
|
+
hash.is_a?(Hash) && (hash.key?(key) || hash.key?(key.to_sym))
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def apply_service_extension_config(client_dir, config = {})
|
|
236
|
+
services = Array(config["services"])
|
|
237
|
+
extension_keys = services.map { |v| normalize_extension_key(v) }.compact.uniq
|
|
238
|
+
extension_packages = extension_keys.filter_map { |key| CLIENT_EXTENSION_MAP[key]&.fetch(:package) }.uniq
|
|
239
|
+
extension_aliases = extension_keys.filter_map { |key| CLIENT_EXTENSION_MAP[key]&.fetch(:alias) }.uniq
|
|
240
|
+
|
|
241
|
+
pubspec_path = File.join(client_dir, "pubspec.yaml")
|
|
242
|
+
main_path = File.join(client_dir, "lib", "main.dart")
|
|
243
|
+
prune_client_pubspec(pubspec_path, extension_packages) if File.file?(pubspec_path)
|
|
244
|
+
prune_client_main(main_path, extension_aliases) if File.file?(main_path)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def normalize_extension_key(value)
|
|
248
|
+
key = value.to_s.strip.downcase
|
|
249
|
+
return nil if key.empty?
|
|
250
|
+
|
|
251
|
+
key.tr!("-", "_")
|
|
252
|
+
key.gsub!(/\A(flet_)+/, "")
|
|
253
|
+
key.gsub!(/\Aservice_/, "")
|
|
254
|
+
key
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def prune_client_pubspec(path, selected_packages)
|
|
258
|
+
data = YAML.safe_load(File.read(path), aliases: true) || {}
|
|
259
|
+
deps = (data["dependencies"] || {}).dup
|
|
260
|
+
|
|
261
|
+
deps.keys.each do |name|
|
|
262
|
+
next unless name.start_with?("flet_")
|
|
263
|
+
next if name == "flet"
|
|
264
|
+
next if selected_packages.include?(name)
|
|
265
|
+
|
|
266
|
+
deps.delete(name)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
data["dependencies"] = deps
|
|
270
|
+
File.write(path, YAML.dump(data))
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def prune_client_main(path, selected_aliases)
|
|
274
|
+
lines = File.readlines(path)
|
|
275
|
+
alias_to_package = {}
|
|
276
|
+
|
|
277
|
+
lines.each do |line|
|
|
278
|
+
match = line.match(%r{\Aimport 'package:(flet_[^/]+)/\1\.dart' as ([a-zA-Z0-9_]+);})
|
|
279
|
+
next unless match
|
|
280
|
+
|
|
281
|
+
alias_to_package[match[2]] = match[1]
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
kept = lines.select do |line|
|
|
285
|
+
import_match = line.match(%r{\Aimport 'package:(flet_[^/]+)/\1\.dart' as ([a-zA-Z0-9_]+);})
|
|
286
|
+
if import_match
|
|
287
|
+
package_name = import_match[1]
|
|
288
|
+
next true if package_name == "flet"
|
|
289
|
+
next true if selected_aliases.include?(import_match[2])
|
|
290
|
+
next false
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
extension_match = line.match(/\A\s*([a-zA-Z0-9_]+)\.Extension\(\),\s*\z/)
|
|
294
|
+
if extension_match
|
|
295
|
+
extension_alias = extension_match[1]
|
|
296
|
+
package_name = alias_to_package[extension_alias]
|
|
297
|
+
next true if package_name.nil?
|
|
298
|
+
next true if selected_aliases.include?(extension_alias)
|
|
299
|
+
next false
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
true
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
File.write(path, kept.join)
|
|
175
306
|
end
|
|
176
307
|
|
|
177
308
|
def update_pubspec_value(path, block, key, value, multiple: false)
|
|
@@ -1,10 +1,30 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
|
+
require "yaml"
|
|
4
5
|
|
|
5
6
|
module Ruflet
|
|
6
7
|
module CLI
|
|
7
8
|
module NewCommand
|
|
9
|
+
CLIENT_EXTENSION_MAP = {
|
|
10
|
+
"ads" => { package: "flet_ads", alias: "ruflet_ads" },
|
|
11
|
+
"audio" => { package: "flet_audio", alias: "ruflet_audio" },
|
|
12
|
+
"audio_recorder" => { package: "flet_audio_recorder", alias: "ruflet_audio_recorder" },
|
|
13
|
+
"camera" => { package: "flet_camera", alias: "ruflet_camera" },
|
|
14
|
+
"charts" => { package: "flet_charts", alias: "ruflet_charts" },
|
|
15
|
+
"code_editor" => { package: "flet_code_editor", alias: "ruflet_code_editor" },
|
|
16
|
+
"color_pickers" => { package: "flet_color_pickers", alias: "ruflet_color_picker" },
|
|
17
|
+
"datatable2" => { package: "flet_datatable2", alias: "ruflet_datatable2" },
|
|
18
|
+
"flashlight" => { package: "flet_flashlight", alias: "ruflet_flashlight" },
|
|
19
|
+
"geolocator" => { package: "flet_geolocator", alias: "ruflet_geolocator" },
|
|
20
|
+
"lottie" => { package: "flet_lottie", alias: "ruflet_lottie" },
|
|
21
|
+
"map" => { package: "flet_map", alias: "ruflet_map" },
|
|
22
|
+
"permission_handler" => { package: "flet_permission_handler", alias: "ruflet_permission_handler" },
|
|
23
|
+
"secure_storage" => { package: "flet_secure_storage", alias: "ruflet_secure_storage" },
|
|
24
|
+
"video" => { package: "flet_video", alias: "ruflet_video" },
|
|
25
|
+
"webview" => { package: "flet_webview", alias: "ruflet_webview" }
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
8
28
|
def command_new(args)
|
|
9
29
|
app_name = args.shift
|
|
10
30
|
if app_name.nil? || app_name.strip.empty?
|
|
@@ -22,10 +42,11 @@ module Ruflet
|
|
|
22
42
|
File.write(File.join(root, "main.rb"), format(Ruflet::CLI::MAIN_TEMPLATE, app_title: humanize_name(File.basename(root))))
|
|
23
43
|
File.write(File.join(root, "Gemfile"), Ruflet::CLI::GEMFILE_TEMPLATE)
|
|
24
44
|
File.write(File.join(root, "README.md"), format(Ruflet::CLI::README_TEMPLATE, app_name: File.basename(root)))
|
|
45
|
+
write_default_ruflet_config(root, File.basename(root))
|
|
25
46
|
copy_ruflet_client_template(root)
|
|
47
|
+
configure_ruflet_client(root)
|
|
26
48
|
|
|
27
49
|
project_name = File.basename(root)
|
|
28
|
-
puts "Ruflet app created: #{project_name}"
|
|
29
50
|
puts "Run:"
|
|
30
51
|
puts " cd #{project_name}"
|
|
31
52
|
puts " bundle install"
|
|
@@ -69,6 +90,129 @@ module Ruflet
|
|
|
69
90
|
end
|
|
70
91
|
end
|
|
71
92
|
|
|
93
|
+
def write_default_ruflet_config(root, app_name)
|
|
94
|
+
File.write(File.join(root, "ruflet.yaml"), <<~YAML)
|
|
95
|
+
app:
|
|
96
|
+
name: #{app_name}
|
|
97
|
+
# Optional production client endpoint used by `ruflet build`.
|
|
98
|
+
# Example: https://api.example.com
|
|
99
|
+
ruflet_client_url: ""
|
|
100
|
+
|
|
101
|
+
# Source of truth for Flutter client extensions/plugins.
|
|
102
|
+
# Examples: camera, video, audio, flashlight, webview, map
|
|
103
|
+
services: []
|
|
104
|
+
|
|
105
|
+
# Build assets configuration consumed by `ruflet build`.
|
|
106
|
+
# Paths are relative to this file unless absolute.
|
|
107
|
+
assets:
|
|
108
|
+
dir: assets
|
|
109
|
+
splash_screen: assets/splash.png
|
|
110
|
+
icon_launcher: assets/icon.png
|
|
111
|
+
|
|
112
|
+
build:
|
|
113
|
+
splash_color: "#FFFFFF"
|
|
114
|
+
splash_dark_color: "#0B0B0B"
|
|
115
|
+
icon_background: "#FFFFFF"
|
|
116
|
+
theme_color: "#FFFFFF"
|
|
117
|
+
YAML
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def configure_ruflet_client(root)
|
|
121
|
+
config_path = File.join(root, "ruflet.yaml")
|
|
122
|
+
return unless File.file?(config_path)
|
|
123
|
+
|
|
124
|
+
config = YAML.safe_load(File.read(config_path), aliases: true) || {}
|
|
125
|
+
extension_keys = extract_extension_keys(config)
|
|
126
|
+
extension_packages = extension_keys.filter_map { |key| CLIENT_EXTENSION_MAP[key]&.fetch(:package) }.uniq
|
|
127
|
+
extension_aliases = extension_keys.filter_map { |key| CLIENT_EXTENSION_MAP[key]&.fetch(:alias) }.uniq
|
|
128
|
+
|
|
129
|
+
client_dir = File.join(root, "ruflet_client")
|
|
130
|
+
apply_client_manifest!(client_dir, extension_packages, extension_aliases)
|
|
131
|
+
rescue StandardError => e
|
|
132
|
+
warn "Failed to configure ruflet_client from ruflet.yaml: #{e.class}: #{e.message}"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def extract_extension_keys(config)
|
|
136
|
+
from_services = Array(config["services"])
|
|
137
|
+
|
|
138
|
+
from_services
|
|
139
|
+
.map { |v| normalize_extension_key(v) }
|
|
140
|
+
.compact
|
|
141
|
+
.uniq
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def normalize_extension_key(value)
|
|
145
|
+
key = value.to_s.strip.downcase
|
|
146
|
+
return nil if key.empty?
|
|
147
|
+
|
|
148
|
+
key.tr!("-", "_")
|
|
149
|
+
key.gsub!(/\A(flet_)+/, "")
|
|
150
|
+
key.gsub!(/\Aservice_/, "")
|
|
151
|
+
key.gsub!(/\Acontrol_/, "")
|
|
152
|
+
key = "file_picker" if key == "filepicker"
|
|
153
|
+
key
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def apply_client_manifest!(client_dir, extension_packages, extension_aliases)
|
|
157
|
+
return unless Dir.exist?(client_dir)
|
|
158
|
+
|
|
159
|
+
pubspec_path = File.join(client_dir, "pubspec.yaml")
|
|
160
|
+
main_path = File.join(client_dir, "lib", "main.dart")
|
|
161
|
+
prune_client_pubspec(pubspec_path, extension_packages) if File.file?(pubspec_path)
|
|
162
|
+
prune_client_main(main_path, extension_aliases) if File.file?(main_path)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def prune_client_pubspec(path, selected_packages)
|
|
166
|
+
data = YAML.safe_load(File.read(path), aliases: true) || {}
|
|
167
|
+
deps = (data["dependencies"] || {}).dup
|
|
168
|
+
|
|
169
|
+
deps.keys.each do |name|
|
|
170
|
+
next unless name.start_with?("flet_")
|
|
171
|
+
next if name == "flet"
|
|
172
|
+
next if selected_packages.include?(name)
|
|
173
|
+
|
|
174
|
+
deps.delete(name)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
data["dependencies"] = deps
|
|
178
|
+
File.write(path, YAML.dump(data))
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def prune_client_main(path, selected_aliases)
|
|
182
|
+
lines = File.readlines(path)
|
|
183
|
+
alias_to_package = {}
|
|
184
|
+
|
|
185
|
+
lines.each do |line|
|
|
186
|
+
match = line.match(%r{\Aimport 'package:(flet_[^/]+)/\1\.dart' as ([a-zA-Z0-9_]+);})
|
|
187
|
+
next unless match
|
|
188
|
+
|
|
189
|
+
alias_to_package[match[2]] = match[1]
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
kept = lines.select do |line|
|
|
193
|
+
import_match = line.match(%r{\Aimport 'package:(flet_[^/]+)/\1\.dart' as ([a-zA-Z0-9_]+);})
|
|
194
|
+
if import_match
|
|
195
|
+
package_name = import_match[1]
|
|
196
|
+
next true if package_name == "flet"
|
|
197
|
+
next true if selected_aliases.include?(import_match[2])
|
|
198
|
+
next false
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
extension_match = line.match(/\A\s*([a-zA-Z0-9_]+)\.Extension\(\),\s*\z/)
|
|
202
|
+
if extension_match
|
|
203
|
+
extension_alias = extension_match[1]
|
|
204
|
+
package_name = alias_to_package[extension_alias]
|
|
205
|
+
next true if package_name.nil? # non-Flet extension lines
|
|
206
|
+
next true if selected_aliases.include?(extension_alias)
|
|
207
|
+
next false
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
true
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
File.write(path, kept.join)
|
|
214
|
+
end
|
|
215
|
+
|
|
72
216
|
def humanize_name(name)
|
|
73
217
|
name.to_s.gsub(/[_-]+/, " ").split.map(&:capitalize).join(" ")
|
|
74
218
|
end
|
|
@@ -16,12 +16,11 @@ module Ruflet
|
|
|
16
16
|
module CLI
|
|
17
17
|
module RunCommand
|
|
18
18
|
def command_run(args)
|
|
19
|
-
options = { target: "mobile",
|
|
19
|
+
options = { target: "mobile", requested_port: 8550 }
|
|
20
20
|
parser = OptionParser.new do |o|
|
|
21
21
|
o.on("--web") { options[:target] = "web" }
|
|
22
22
|
o.on("--desktop") { options[:target] = "desktop" }
|
|
23
|
-
o.on("--
|
|
24
|
-
o.on("--no-hot-reload") { options[:hot_reload] = false }
|
|
23
|
+
o.on("--port PORT", Integer) { |v| options[:requested_port] = v }
|
|
25
24
|
end
|
|
26
25
|
parser.parse!(args)
|
|
27
26
|
|
|
@@ -33,7 +32,7 @@ module Ruflet
|
|
|
33
32
|
return 1
|
|
34
33
|
end
|
|
35
34
|
|
|
36
|
-
selected_port = resolve_backend_port(options[:target])
|
|
35
|
+
selected_port = resolve_backend_port(options[:target], requested_port: options[:requested_port])
|
|
37
36
|
return 1 unless selected_port
|
|
38
37
|
env = {
|
|
39
38
|
"RUFLET_TARGET" => options[:target],
|
|
@@ -43,11 +42,11 @@ module Ruflet
|
|
|
43
42
|
assets_dir = File.join(File.dirname(script_path), "assets")
|
|
44
43
|
env["RUFLET_ASSETS_DIR"] = assets_dir if File.directory?(assets_dir)
|
|
45
44
|
|
|
46
|
-
print_run_banner(target: options[:target], port: selected_port)
|
|
45
|
+
print_run_banner(target: options[:target], requested_port: options[:requested_port], port: selected_port)
|
|
47
46
|
print_mobile_qr_hint(port: selected_port) if options[:target] == "mobile"
|
|
48
47
|
|
|
49
48
|
gemfile_path = find_nearest_gemfile(Dir.pwd)
|
|
50
|
-
cmd = build_runtime_command(script_path, gemfile_path: gemfile_path, env: env
|
|
49
|
+
cmd = build_runtime_command(script_path, gemfile_path: gemfile_path, env: env)
|
|
51
50
|
return 1 unless cmd
|
|
52
51
|
|
|
53
52
|
child_pid = Process.spawn(env, *cmd, pgroup: true)
|
|
@@ -63,22 +62,8 @@ module Ruflet
|
|
|
63
62
|
previous_int = Signal.trap("INT") { forward_signal.call("INT") }
|
|
64
63
|
previous_term = Signal.trap("TERM") { forward_signal.call("TERM") }
|
|
65
64
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
run_hot_reload_loop(
|
|
69
|
-
child_pid: child_pid,
|
|
70
|
-
env: env,
|
|
71
|
-
cmd: cmd,
|
|
72
|
-
script_path: script_path,
|
|
73
|
-
gemfile_path: gemfile_path,
|
|
74
|
-
target: options[:target],
|
|
75
|
-
port: selected_port,
|
|
76
|
-
launched_client_pids: launched_client_pids
|
|
77
|
-
)
|
|
78
|
-
else
|
|
79
|
-
_pid, status = Process.wait2(child_pid)
|
|
80
|
-
status.success? ? 0 : (status.exitstatus || 1)
|
|
81
|
-
end
|
|
65
|
+
_pid, status = Process.wait2(child_pid)
|
|
66
|
+
status.success? ? 0 : (status.exitstatus || 1)
|
|
82
67
|
ensure
|
|
83
68
|
Signal.trap("INT", previous_int) if defined?(previous_int) && previous_int
|
|
84
69
|
Signal.trap("TERM", previous_term) if defined?(previous_term) && previous_term
|
|
@@ -107,103 +92,7 @@ module Ruflet
|
|
|
107
92
|
|
|
108
93
|
private
|
|
109
94
|
|
|
110
|
-
def
|
|
111
|
-
hot_queue = Queue.new
|
|
112
|
-
hot_listener = start_hotkey_listener(hot_queue)
|
|
113
|
-
watch_roots = watch_roots_for_script(script_path, gemfile_path)
|
|
114
|
-
watch_snapshot = build_watch_snapshot(watch_roots)
|
|
115
|
-
puts "Watching #{watch_snapshot.size} Ruby files for hot reload."
|
|
116
|
-
|
|
117
|
-
loop do
|
|
118
|
-
exited = Process.waitpid(child_pid, Process::WNOHANG)
|
|
119
|
-
if exited
|
|
120
|
-
status = $?
|
|
121
|
-
return status&.success? ? 0 : (status&.exitstatus || 1)
|
|
122
|
-
end
|
|
123
|
-
|
|
124
|
-
latest_snapshot = build_watch_snapshot(watch_roots)
|
|
125
|
-
if latest_snapshot != watch_snapshot
|
|
126
|
-
changed = changed_paths(watch_snapshot, latest_snapshot)
|
|
127
|
-
sample = changed.first(3).map { |p| File.basename(p) }.join(", ")
|
|
128
|
-
suffix = changed.size > 3 ? ", ..." : ""
|
|
129
|
-
puts "Detected change in #{changed.size} file(s): #{sample}#{suffix}"
|
|
130
|
-
hot_queue << :reload
|
|
131
|
-
watch_snapshot = latest_snapshot
|
|
132
|
-
end
|
|
133
|
-
|
|
134
|
-
process_hot_reload_actions(
|
|
135
|
-
queue: hot_queue,
|
|
136
|
-
child_pid: child_pid,
|
|
137
|
-
launched_client_pids: launched_client_pids,
|
|
138
|
-
env: env,
|
|
139
|
-
cmd: cmd,
|
|
140
|
-
target: target,
|
|
141
|
-
port: port
|
|
142
|
-
) do |new_child_pid, new_client_pids|
|
|
143
|
-
child_pid = new_child_pid
|
|
144
|
-
launched_client_pids = new_client_pids
|
|
145
|
-
end
|
|
146
|
-
|
|
147
|
-
sleep 0.25
|
|
148
|
-
end
|
|
149
|
-
ensure
|
|
150
|
-
hot_listener&.kill
|
|
151
|
-
end
|
|
152
|
-
|
|
153
|
-
def process_hot_reload_actions(queue:, child_pid:, launched_client_pids:, env:, cmd:, target:, port:)
|
|
154
|
-
until queue.empty?
|
|
155
|
-
action = queue.pop(true)
|
|
156
|
-
case action
|
|
157
|
-
when :interrupt
|
|
158
|
-
Process.kill("INT", Process.pid)
|
|
159
|
-
when :reload
|
|
160
|
-
puts "Reloading backend..."
|
|
161
|
-
child_pid = restart_backend(child_pid, env, cmd)
|
|
162
|
-
when :restart
|
|
163
|
-
puts "Hot restarting backend and client..."
|
|
164
|
-
child_pid = restart_backend(child_pid, env, cmd)
|
|
165
|
-
launched_client_pids = restart_clients(launched_client_pids, target, port)
|
|
166
|
-
end
|
|
167
|
-
yield child_pid, launched_client_pids if block_given?
|
|
168
|
-
end
|
|
169
|
-
rescue ThreadError
|
|
170
|
-
nil
|
|
171
|
-
end
|
|
172
|
-
|
|
173
|
-
def start_hotkey_listener(queue)
|
|
174
|
-
return nil unless $stdin.tty?
|
|
175
|
-
|
|
176
|
-
Thread.new do
|
|
177
|
-
$stdin.raw do |stdin|
|
|
178
|
-
loop do
|
|
179
|
-
key = stdin.getc
|
|
180
|
-
break if key.nil?
|
|
181
|
-
|
|
182
|
-
action = hot_action_for_key(key)
|
|
183
|
-
queue << action if action
|
|
184
|
-
end
|
|
185
|
-
end
|
|
186
|
-
rescue StandardError
|
|
187
|
-
nil
|
|
188
|
-
end
|
|
189
|
-
end
|
|
190
|
-
|
|
191
|
-
def hot_action_for_key(key)
|
|
192
|
-
return :interrupt if key == "\u0003" # Ctrl+C in raw mode
|
|
193
|
-
return :reload if key == "r"
|
|
194
|
-
return :restart if key == "R"
|
|
195
|
-
|
|
196
|
-
nil
|
|
197
|
-
end
|
|
198
|
-
|
|
199
|
-
def restart_backend(child_pid, env, cmd)
|
|
200
|
-
terminate_process(child_pid)
|
|
201
|
-
new_pid = Process.spawn(env, *cmd, pgroup: true)
|
|
202
|
-
puts "Hot reload applied."
|
|
203
|
-
new_pid
|
|
204
|
-
end
|
|
205
|
-
|
|
206
|
-
def build_runtime_command(script_path, gemfile_path:, env:, hot_reload:)
|
|
95
|
+
def build_runtime_command(script_path, gemfile_path:, env:)
|
|
207
96
|
if gemfile_path
|
|
208
97
|
env["BUNDLE_GEMFILE"] = gemfile_path
|
|
209
98
|
bundle_ready = system(env, RbConfig.ruby, "-S", "bundle", "check", out: File::NULL, err: File::NULL)
|
|
@@ -215,73 +104,6 @@ module Ruflet
|
|
|
215
104
|
[RbConfig.ruby, script_path]
|
|
216
105
|
end
|
|
217
106
|
|
|
218
|
-
def restart_clients(launched_client_pids, target, port)
|
|
219
|
-
Array(launched_client_pids).compact.each { |pid| terminate_process(pid) }
|
|
220
|
-
launch_target_client(target, port)
|
|
221
|
-
end
|
|
222
|
-
|
|
223
|
-
def terminate_process(pid)
|
|
224
|
-
return unless pid
|
|
225
|
-
|
|
226
|
-
begin
|
|
227
|
-
Process.kill("TERM", -pid)
|
|
228
|
-
rescue Errno::ESRCH
|
|
229
|
-
begin
|
|
230
|
-
Process.kill("TERM", pid)
|
|
231
|
-
rescue Errno::ESRCH
|
|
232
|
-
nil
|
|
233
|
-
end
|
|
234
|
-
end
|
|
235
|
-
|
|
236
|
-
begin
|
|
237
|
-
Timeout.timeout(2) { Process.wait(pid) }
|
|
238
|
-
rescue StandardError
|
|
239
|
-
nil
|
|
240
|
-
end
|
|
241
|
-
end
|
|
242
|
-
|
|
243
|
-
def watch_roots_for_script(script_path, gemfile_path)
|
|
244
|
-
roots = [Dir.pwd, File.dirname(script_path)]
|
|
245
|
-
if gemfile_path
|
|
246
|
-
roots << File.dirname(gemfile_path)
|
|
247
|
-
roots.concat(extra_watch_roots_from_gemfile(gemfile_path))
|
|
248
|
-
end
|
|
249
|
-
roots.uniq.select { |root| File.directory?(root) }
|
|
250
|
-
end
|
|
251
|
-
|
|
252
|
-
def extra_watch_roots_from_gemfile(gemfile_path)
|
|
253
|
-
base_dir = File.dirname(gemfile_path)
|
|
254
|
-
content = File.read(gemfile_path)
|
|
255
|
-
content.scan(/path:\s*["']([^"']+)["']/).flatten.map do |path|
|
|
256
|
-
File.expand_path(path, base_dir)
|
|
257
|
-
end.select { |path| File.directory?(path) }
|
|
258
|
-
rescue StandardError
|
|
259
|
-
[]
|
|
260
|
-
end
|
|
261
|
-
|
|
262
|
-
def build_watch_snapshot(roots)
|
|
263
|
-
snapshot = {}
|
|
264
|
-
roots.each do |root|
|
|
265
|
-
Dir.glob(File.join(root, "**", "*.rb")).each do |path|
|
|
266
|
-
next if path.include?("/.git/")
|
|
267
|
-
next if path.include?("/tmp/")
|
|
268
|
-
next if path.include?("/log/")
|
|
269
|
-
next unless File.file?(path)
|
|
270
|
-
|
|
271
|
-
snapshot[path] = File.mtime(path).to_f
|
|
272
|
-
rescue Errno::ENOENT
|
|
273
|
-
nil
|
|
274
|
-
end
|
|
275
|
-
end
|
|
276
|
-
snapshot
|
|
277
|
-
end
|
|
278
|
-
|
|
279
|
-
def changed_paths(old_snapshot, new_snapshot)
|
|
280
|
-
(old_snapshot.keys | new_snapshot.keys).select do |path|
|
|
281
|
-
old_snapshot[path] != new_snapshot[path]
|
|
282
|
-
end
|
|
283
|
-
end
|
|
284
|
-
|
|
285
107
|
def resolve_script(token)
|
|
286
108
|
path = File.expand_path(token, Dir.pwd)
|
|
287
109
|
return path if File.file?(path)
|
|
@@ -305,12 +127,14 @@ module Ruflet
|
|
|
305
127
|
end
|
|
306
128
|
end
|
|
307
129
|
|
|
308
|
-
def print_run_banner(target:, port:)
|
|
309
|
-
if
|
|
310
|
-
puts "Requested port
|
|
130
|
+
def print_run_banner(target:, requested_port:, port:)
|
|
131
|
+
if port != requested_port.to_i
|
|
132
|
+
puts "Requested port #{requested_port} is busy; bound to #{port}"
|
|
311
133
|
end
|
|
312
134
|
if target == "desktop"
|
|
313
135
|
puts "Ruflet desktop URL: http://localhost:#{port}"
|
|
136
|
+
elsif target == "mobile"
|
|
137
|
+
puts "Ruflet target: #{target}"
|
|
314
138
|
else
|
|
315
139
|
puts "Ruflet target: #{target}"
|
|
316
140
|
puts "Ruflet URL: http://localhost:#{port}"
|
|
@@ -341,9 +165,11 @@ module Ruflet
|
|
|
341
165
|
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)
|
|
342
166
|
Process.detach(web_pid)
|
|
343
167
|
wait_for_server_boot(web_port)
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
168
|
+
backend_url = "http://localhost:#{port}"
|
|
169
|
+
web_url = "http://localhost:#{web_port}/?#{URI.encode_www_form(url: backend_url)}"
|
|
170
|
+
browser_pid = open_in_browser_app_mode(web_url)
|
|
171
|
+
open_in_browser(web_url) if browser_pid.nil?
|
|
172
|
+
puts "Ruflet web client: #{web_url}"
|
|
347
173
|
puts "Ruflet backend ws: ws://localhost:#{port}/ws"
|
|
348
174
|
[web_pid, browser_pid].compact
|
|
349
175
|
rescue Errno::ENOENT
|
|
@@ -711,8 +537,6 @@ module Ruflet
|
|
|
711
537
|
puts
|
|
712
538
|
puts "Ruflet mobile connect URL:"
|
|
713
539
|
puts " #{payload}"
|
|
714
|
-
puts "Ruflet server ws URL:"
|
|
715
|
-
puts " ws://0.0.0.0:#{port}/ws"
|
|
716
540
|
puts "Scan this QR from ruflet_client (Connect -> Scan QR):"
|
|
717
541
|
print_ascii_qr(payload)
|
|
718
542
|
puts
|
|
@@ -740,8 +564,10 @@ module Ruflet
|
|
|
740
564
|
start_port
|
|
741
565
|
end
|
|
742
566
|
|
|
743
|
-
def resolve_backend_port(
|
|
744
|
-
|
|
567
|
+
def resolve_backend_port(_target, requested_port: 8550)
|
|
568
|
+
base = requested_port.to_i
|
|
569
|
+
base = 8550 if base <= 0
|
|
570
|
+
find_available_port(base)
|
|
745
571
|
end
|
|
746
572
|
|
|
747
573
|
def port_available?(port)
|
data/lib/ruflet/cli/templates.rb
CHANGED
|
@@ -8,7 +8,7 @@ module Ruflet
|
|
|
8
8
|
page.title = "Counter Demo"
|
|
9
9
|
count = 0
|
|
10
10
|
count_text = nil
|
|
11
|
-
count_text ||= text(value: count.to_s, size: 40)
|
|
11
|
+
count_text ||= text(value: count.to_s, style: { size: 40 })
|
|
12
12
|
page.add(
|
|
13
13
|
container(
|
|
14
14
|
expand: true,
|
|
@@ -37,8 +37,8 @@ module Ruflet
|
|
|
37
37
|
GEMFILE_TEMPLATE = <<~GEMFILE
|
|
38
38
|
source "https://rubygems.org"
|
|
39
39
|
|
|
40
|
-
gem "ruflet", ">= 0.0.
|
|
41
|
-
gem "ruflet_server", ">= 0.0.
|
|
40
|
+
gem "ruflet", ">= 0.0.8"
|
|
41
|
+
gem "ruflet_server", ">= 0.0.8"
|
|
42
42
|
GEMFILE
|
|
43
43
|
|
|
44
44
|
README_TEMPLATE = <<~MD
|
data/lib/ruflet/cli.rb
CHANGED
|
@@ -4,8 +4,8 @@ require "optparse"
|
|
|
4
4
|
|
|
5
5
|
require_relative "cli/templates"
|
|
6
6
|
require_relative "cli/new_command"
|
|
7
|
-
require_relative "cli/run_command"
|
|
8
7
|
require_relative "cli/flutter_sdk"
|
|
8
|
+
require_relative "cli/run_command"
|
|
9
9
|
require_relative "cli/build_command"
|
|
10
10
|
require_relative "cli/extra_command"
|
|
11
11
|
|
|
@@ -54,9 +54,9 @@ module Ruflet
|
|
|
54
54
|
Commands:
|
|
55
55
|
ruflet create <appname>
|
|
56
56
|
ruflet new <appname>
|
|
57
|
-
ruflet run [scriptname|path] [--web|--desktop] [--
|
|
57
|
+
ruflet run [scriptname|path] [--web|--desktop] [--port PORT]
|
|
58
58
|
ruflet debug [scriptname|path]
|
|
59
|
-
ruflet build <apk|ios|aab|web|macos|windows|linux>
|
|
59
|
+
ruflet build <apk|android|ios|aab|web|macos|windows|linux>
|
|
60
60
|
ruflet devices
|
|
61
61
|
ruflet emulators
|
|
62
62
|
ruflet doctor
|
data/lib/ruflet/version.rb
CHANGED