ruflet_rails 0.0.9 → 0.0.11
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/generators/ruflet/install/install_generator.rb +9 -39
- data/lib/generators/ruflet/scaffold/scaffold_generator.rb +10 -16
- data/lib/ruflet/rails/assets.rb +87 -0
- data/lib/ruflet/rails/configuration.rb +129 -0
- data/lib/ruflet/rails/install_support.rb +80 -236
- data/lib/ruflet/rails/native_app.rb +253 -0
- data/lib/ruflet/rails/protocol/endpoint.rb +3 -11
- data/lib/ruflet/rails/protocol/local_server.rb +27 -194
- data/lib/ruflet/rails/protocol/middleware.rb +74 -1
- data/lib/ruflet/rails/protocol/static_index_guard.rb +6 -0
- data/lib/ruflet/rails/protocol/web_app.rb +194 -0
- data/lib/ruflet/rails/protocol/web_app_endpoint.rb +44 -0
- data/lib/ruflet/rails/protocol/websocket_detection.rb +19 -0
- data/lib/ruflet/rails/protocol.rb +3 -0
- data/lib/ruflet/rails/railtie.rb +49 -32
- data/lib/ruflet/rails/resource_component.rb +188 -8
- data/lib/ruflet/rails/route_stack.rb +90 -0
- data/lib/ruflet/rails/view_helpers.rb +58 -0
- data/lib/ruflet/rails/webview_app.rb +54 -0
- data/lib/ruflet/rails.rb +133 -5
- data/lib/ruflet/version.rb +1 -1
- data/lib/ruflet_rails.rb +6 -1
- metadata +33 -10
- data/lib/ruflet/rails/resource_view.rb +0 -124
|
@@ -113,129 +113,25 @@ module Ruflet
|
|
|
113
113
|
File.join("app", "views", "ruflet", "components", names[:plural], "#{names[:singular]}_form.rb")
|
|
114
114
|
end
|
|
115
115
|
|
|
116
|
-
def scaffold_view_path(model_name)
|
|
117
|
-
names = model_names(model_name)
|
|
118
|
-
|
|
119
|
-
File.join("app", "views", "ruflet", "#{names[:plural]}_view.rb")
|
|
120
|
-
end
|
|
121
|
-
|
|
122
116
|
def scaffold_component_path(model_name)
|
|
123
117
|
names = model_names(model_name)
|
|
124
118
|
|
|
125
119
|
File.join("app", "views", "ruflet", "components", names[:plural], "#{names[:singular]}_component.rb")
|
|
126
120
|
end
|
|
127
121
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
component_class = "#{model_class}Component"
|
|
133
|
-
title = names[:title]
|
|
134
|
-
attrs = normalized_form_attributes(attributes)
|
|
135
|
-
resource_fields = scaffold_resource_fields(attrs)
|
|
136
|
-
display_fields = scaffold_display_fields(attrs)
|
|
137
|
-
display_value_cases = scaffold_display_value_cases(attrs)
|
|
138
|
-
|
|
139
|
-
template = <<~RUBY
|
|
140
|
-
# frozen_string_literal: true
|
|
141
|
-
|
|
142
|
-
require "ruflet_rails"
|
|
143
|
-
require_relative "components/#{names[:plural]}/#{names[:singular]}_component"
|
|
144
|
-
|
|
145
|
-
class #{view_class} < Ruflet::Rails::ResourceView
|
|
146
|
-
route #{("/" + names[:plural]).inspect}
|
|
147
|
-
|
|
148
|
-
def render
|
|
149
|
-
page.title = resource_title
|
|
150
|
-
render_index
|
|
151
|
-
end
|
|
152
|
-
|
|
153
|
-
private
|
|
154
|
-
|
|
155
|
-
def model_class
|
|
156
|
-
#{model_class}
|
|
157
|
-
end
|
|
158
|
-
|
|
159
|
-
def resource_title
|
|
160
|
-
#{title.inspect}
|
|
161
|
-
end
|
|
162
|
-
|
|
163
|
-
def singular_title
|
|
164
|
-
model_class.model_name.human.titleize
|
|
165
|
-
end
|
|
166
|
-
|
|
167
|
-
def records
|
|
168
|
-
scope = model_class.respond_to?(:limit) ? model_class.limit(50) : model_class.all
|
|
169
|
-
scope.respond_to?(:limit) ? scope.limit(50) : scope.to_a.first(50)
|
|
170
|
-
end
|
|
171
|
-
|
|
172
|
-
def render_index
|
|
173
|
-
page.views = []
|
|
174
|
-
page.add(component.render)
|
|
175
|
-
end
|
|
176
|
-
|
|
177
|
-
def render_show(record)
|
|
178
|
-
page.views = []
|
|
179
|
-
page.add(component.show(record))
|
|
180
|
-
page.update
|
|
181
|
-
end
|
|
182
|
-
|
|
183
|
-
def component
|
|
184
|
-
@component ||= #{component_class}.new(page, controller: self)
|
|
185
|
-
end
|
|
186
|
-
|
|
187
|
-
def show_record(record)
|
|
188
|
-
render_show(record)
|
|
189
|
-
end
|
|
190
|
-
|
|
191
|
-
def save_record(record, attributes, dialog)
|
|
192
|
-
if record.update(attributes)
|
|
193
|
-
close_dialog(dialog)
|
|
194
|
-
render_index
|
|
195
|
-
show_snackbar("\#{singular_title} saved")
|
|
196
|
-
else
|
|
197
|
-
show_errors(record)
|
|
198
|
-
end
|
|
199
|
-
end
|
|
200
|
-
|
|
201
|
-
def destroy_record(record, dialog)
|
|
202
|
-
record.destroy!
|
|
203
|
-
close_dialog(dialog)
|
|
204
|
-
render_index
|
|
205
|
-
show_snackbar("\#{singular_title} deleted")
|
|
206
|
-
rescue StandardError => e
|
|
207
|
-
show_snackbar(e.message)
|
|
208
|
-
end
|
|
209
|
-
|
|
210
|
-
def resource_fields
|
|
211
|
-
#{resource_fields}
|
|
212
|
-
end
|
|
213
|
-
|
|
214
|
-
def display_fields
|
|
215
|
-
#{display_fields}
|
|
216
|
-
end
|
|
217
|
-
|
|
218
|
-
def display_value(record, field)
|
|
219
|
-
case field
|
|
220
|
-
__DISPLAY_VALUE_CASES__
|
|
221
|
-
else
|
|
222
|
-
record.public_send(field).to_s
|
|
223
|
-
end
|
|
224
|
-
end
|
|
225
|
-
|
|
226
|
-
def primary_label(record)
|
|
227
|
-
field = display_fields.first
|
|
228
|
-
field ? display_value(record, field) : "##\#{record_id(record)}"
|
|
229
|
-
end
|
|
230
|
-
|
|
231
|
-
def secondary_label(record)
|
|
232
|
-
field = display_fields[1]
|
|
233
|
-
field ? display_value(record, field) : nil
|
|
234
|
-
end
|
|
122
|
+
# A `case` with no `when` clause is a syntax error, so models without
|
|
123
|
+
# date/time attributes get the plain fallback body instead.
|
|
124
|
+
def scaffold_display_value_body(display_value_cases, indent)
|
|
125
|
+
return "#{indent}record.public_send(field).to_s" if display_value_cases.to_s.strip.empty?
|
|
235
126
|
|
|
127
|
+
reindented_cases = display_value_cases.gsub(/^ /, indent)
|
|
128
|
+
<<~RUBY.chomp.gsub(/^/, indent).gsub(/^#{Regexp.escape(indent)}__CASES__$/, reindented_cases)
|
|
129
|
+
case field
|
|
130
|
+
__CASES__
|
|
131
|
+
else
|
|
132
|
+
record.public_send(field).to_s
|
|
236
133
|
end
|
|
237
134
|
RUBY
|
|
238
|
-
template.gsub(/^[ \t]*__DISPLAY_VALUE_CASES__$/, display_value_cases)
|
|
239
135
|
end
|
|
240
136
|
|
|
241
137
|
def scaffold_component_template(model_name:, attributes: [])
|
|
@@ -253,6 +149,15 @@ module Ruflet
|
|
|
253
149
|
require "date"
|
|
254
150
|
require "ruflet_rails"
|
|
255
151
|
|
|
152
|
+
# The model (#{model_class}) is inferred from the class name and the route
|
|
153
|
+
# ("/#{names[:plural]}") is declared in config/routes.rb. This file is YOURS:
|
|
154
|
+
# the whole CRUD UI (index table, detail screen, create/edit form) and the
|
|
155
|
+
# database calls (#{model_class}#update, #destroy!, .new) are explicit below
|
|
156
|
+
# so you can change the UI or logic however you like. The base class only
|
|
157
|
+
# provides reusable helpers: record loading, field inference, dialog
|
|
158
|
+
# open/close, the date/time picker value helpers, and refresh.
|
|
159
|
+
#
|
|
160
|
+
# The same component renders on web and on mobile/desktop.
|
|
256
161
|
class #{component_class} < Ruflet::Rails::ResourceComponent
|
|
257
162
|
def render
|
|
258
163
|
safe_area(
|
|
@@ -401,6 +306,7 @@ module Ruflet
|
|
|
401
306
|
width: dialog_width,
|
|
402
307
|
content: column(
|
|
403
308
|
spacing: 8,
|
|
309
|
+
horizontal_alignment: "stretch",
|
|
404
310
|
children: [
|
|
405
311
|
#{control_list}
|
|
406
312
|
]
|
|
@@ -409,7 +315,14 @@ module Ruflet
|
|
|
409
315
|
actions: [
|
|
410
316
|
text_button(content: text("Cancel"), on_click: ->(_event) { close_dialog(dialog) }),
|
|
411
317
|
filled_button(content: text("Save"), on_click: ->(_event) {
|
|
412
|
-
|
|
318
|
+
# Persist with the model (this is your code — change it freely).
|
|
319
|
+
if record.update(attributes.call)
|
|
320
|
+
close_dialog(dialog)
|
|
321
|
+
refresh
|
|
322
|
+
show_snackbar("\#{singular_title} saved")
|
|
323
|
+
else
|
|
324
|
+
show_errors(record)
|
|
325
|
+
end
|
|
413
326
|
})
|
|
414
327
|
],
|
|
415
328
|
actions_alignment: "end"
|
|
@@ -426,7 +339,13 @@ module Ruflet
|
|
|
426
339
|
content: text("Permanently remove \#{singular_title} #\#{record_id(record)}?", no_wrap: false),
|
|
427
340
|
actions: [
|
|
428
341
|
text_button(content: text("Cancel"), on_click: ->(_event) { close_dialog(dialog) }),
|
|
429
|
-
filled_button(content: text("Delete"), on_click: ->(_event) {
|
|
342
|
+
filled_button(content: text("Delete"), on_click: ->(_event) {
|
|
343
|
+
# Destroy with the model (this is your code — change it freely).
|
|
344
|
+
record.destroy!
|
|
345
|
+
close_dialog(dialog)
|
|
346
|
+
refresh
|
|
347
|
+
show_snackbar("\#{singular_title} deleted")
|
|
348
|
+
})
|
|
430
349
|
],
|
|
431
350
|
actions_alignment: "end"
|
|
432
351
|
)
|
|
@@ -512,7 +431,7 @@ module Ruflet
|
|
|
512
431
|
help_text: #{label.inspect},
|
|
513
432
|
on_change: ->(_event) do
|
|
514
433
|
close_dialogs(#{control})
|
|
515
|
-
page.update(#{display_control}, value: date_display_value(#{control}.
|
|
434
|
+
page.update(#{display_control}, value: date_display_value(#{control}.value))
|
|
516
435
|
end
|
|
517
436
|
)
|
|
518
437
|
#{control}_field = column(
|
|
@@ -539,7 +458,7 @@ module Ruflet
|
|
|
539
458
|
help_text: #{label.inspect},
|
|
540
459
|
on_change: ->(_event) do
|
|
541
460
|
close_dialogs(#{control})
|
|
542
|
-
page.update(#{display_control}, value: time_display_value(#{control}.
|
|
461
|
+
page.update(#{display_control}, value: time_display_value(#{control}.value))
|
|
543
462
|
end
|
|
544
463
|
)
|
|
545
464
|
#{control}_field = column(
|
|
@@ -569,7 +488,7 @@ module Ruflet
|
|
|
569
488
|
close_dialogs(#{control})
|
|
570
489
|
page.update(
|
|
571
490
|
#{display_control},
|
|
572
|
-
value: date_range_display_value(#{control}.
|
|
491
|
+
value: date_range_display_value(#{control}.start_value, #{control}.end_value)
|
|
573
492
|
)
|
|
574
493
|
end
|
|
575
494
|
)
|
|
@@ -603,13 +522,13 @@ module Ruflet
|
|
|
603
522
|
value =
|
|
604
523
|
case type
|
|
605
524
|
when "boolean"
|
|
606
|
-
"!!#{control}.
|
|
525
|
+
"!!#{control}.value"
|
|
607
526
|
when "date"
|
|
608
|
-
"#{control}.
|
|
527
|
+
"#{control}.value.to_s.split(\"T\", 2).first"
|
|
609
528
|
when "date_range", "daterange"
|
|
610
|
-
"Range.new(Date.parse(#{control}.
|
|
529
|
+
"Range.new(Date.parse(#{control}.start_value.to_s), Date.parse(#{control}.end_value.to_s))"
|
|
611
530
|
else
|
|
612
|
-
"#{control}.
|
|
531
|
+
"#{control}.value.to_s"
|
|
613
532
|
end
|
|
614
533
|
|
|
615
534
|
"#{name.inspect} => #{value}"
|
|
@@ -648,7 +567,7 @@ module Ruflet
|
|
|
648
567
|
spacing: 12,
|
|
649
568
|
children: [
|
|
650
569
|
text(title, size: 24, weight: "bold"),
|
|
651
|
-
column(spacing: 8, children: ruflet_form_controls(fields)),
|
|
570
|
+
column(spacing: 8, horizontal_alignment: "stretch", children: ruflet_form_controls(fields)),
|
|
652
571
|
row(
|
|
653
572
|
spacing: 8,
|
|
654
573
|
children: [
|
|
@@ -827,12 +746,12 @@ module Ruflet
|
|
|
827
746
|
value
|
|
828
747
|
end
|
|
829
748
|
|
|
830
|
-
def build_args_for_platform(platform)
|
|
749
|
+
def build_args_for_platform(platform, ruflet_url: nil)
|
|
831
750
|
normalized = normalize_build_platform(platform)
|
|
832
751
|
return [] if normalized.to_s.empty?
|
|
833
752
|
|
|
834
753
|
args = [normalized]
|
|
835
|
-
args += ["--
|
|
754
|
+
args += ["--dart-define", "RUFLET_URL=#{ruflet_url}"] if normalized == "web" && ruflet_url.to_s.strip != ""
|
|
836
755
|
args
|
|
837
756
|
end
|
|
838
757
|
|
|
@@ -840,132 +759,57 @@ module Ruflet
|
|
|
840
759
|
File.join("app", "views", "ruflet", "main.rb")
|
|
841
760
|
end
|
|
842
761
|
|
|
843
|
-
def
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
def default_web_public_path
|
|
848
|
-
"app"
|
|
849
|
-
end
|
|
850
|
-
|
|
851
|
-
def web_base_href(public_path = default_web_public_path)
|
|
852
|
-
normalized = public_path.to_s.strip.gsub(%r{\A/+|/+\z}, "")
|
|
853
|
-
normalized.empty? ? "/" : "/#{normalized}/"
|
|
854
|
-
end
|
|
855
|
-
|
|
856
|
-
def publish_web_build(root, public_path: default_web_public_path)
|
|
857
|
-
publish_web_client(root, source: File.join(root, "build", "web"), public_path: public_path)
|
|
858
|
-
end
|
|
859
|
-
|
|
860
|
-
def publish_prebuilt_web_client(root, platform: host_desktop_platform, public_path: default_web_public_path)
|
|
861
|
-
source = prebuilt_web_client_path(platform: platform)
|
|
862
|
-
return false unless source
|
|
863
|
-
|
|
864
|
-
publish_web_client(root, source: source, public_path: public_path)
|
|
865
|
-
end
|
|
866
|
-
|
|
867
|
-
def prebuilt_web_client_path(platform: host_desktop_platform)
|
|
868
|
-
return nil if platform.to_s.empty?
|
|
869
|
-
|
|
870
|
-
source = File.join(prebuilt_client_cache_root(platform: platform), "web")
|
|
871
|
-
return nil unless Dir.exist?(source)
|
|
872
|
-
return nil unless File.file?(File.join(source, "index.html"))
|
|
873
|
-
|
|
874
|
-
source
|
|
875
|
-
end
|
|
762
|
+
def initializer_template(entrypoint: default_entrypoint_path, ws_path: "/ws")
|
|
763
|
+
<<~RUBY
|
|
764
|
+
# frozen_string_literal: true
|
|
876
765
|
|
|
877
|
-
|
|
878
|
-
|
|
766
|
+
Ruflet::Rails.configure do |config|
|
|
767
|
+
# Ruflet app entry-point. Auto-mounts a WebSocket endpoint at ws_path —
|
|
768
|
+
# no explicit route needed in config/routes.rb.
|
|
769
|
+
config.app_file = Rails.root.join(#{entrypoint.inspect})
|
|
770
|
+
|
|
771
|
+
# URL path the WebSocket endpoint listens on (default: "/ws").
|
|
772
|
+
config.ws_path = #{ws_path.inspect}
|
|
773
|
+
|
|
774
|
+
# Base URL the Flutter client uses to reach this Rails app. Always
|
|
775
|
+
# required: it backs asset URLs (Ruflet::Rails.asset_url), the
|
|
776
|
+
# build-time RUFLET_URL define, and the desktop launcher. At runtime
|
|
777
|
+
# it can fall back to the connecting host, but a build has no request,
|
|
778
|
+
# so set it here. Point it at a LAN IP (not localhost) to test on a
|
|
779
|
+
# real device.
|
|
780
|
+
config.backend_url = ENV.fetch("RUFLET_BACKEND_URL") do
|
|
781
|
+
Rails.env.production? ? "https://example.com" : "http://localhost:3000"
|
|
782
|
+
end
|
|
879
783
|
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
784
|
+
# Directory the Flutter web build is served from. Defaults to
|
|
785
|
+
# Rails.root/build/web (where `rake ruflet:build[web]` outputs).
|
|
786
|
+
# Must stay OUTSIDE public/, or Rails would serve it statically and
|
|
787
|
+
# expose the app at a path no route declares.
|
|
788
|
+
# config.web_build_dir = Rails.root.join("build", "web")
|
|
789
|
+
end
|
|
790
|
+
RUBY
|
|
885
791
|
end
|
|
886
792
|
|
|
887
|
-
def
|
|
888
|
-
|
|
889
|
-
return false unless File.file?(File.join(source, "index.html"))
|
|
890
|
-
|
|
891
|
-
target = File.join(root, "public", public_path.to_s.gsub(%r{\A/+|/+\z}, ""))
|
|
892
|
-
FileUtils.rm_rf(target)
|
|
893
|
-
FileUtils.mkdir_p(File.dirname(target))
|
|
894
|
-
FileUtils.cp_r(source, target)
|
|
895
|
-
rewrite_web_base_href(target, public_path: public_path)
|
|
896
|
-
inject_web_client_bootstrap(target)
|
|
897
|
-
true
|
|
793
|
+
def initializer_path
|
|
794
|
+
File.join("config", "initializers", "ruflet.rb")
|
|
898
795
|
end
|
|
899
796
|
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
content = File.read(index_path)
|
|
905
|
-
base_href = web_base_href(public_path)
|
|
906
|
-
updated =
|
|
907
|
-
if content.match?(%r{<base\s+href=["'][^"']*["']\s*/?>}i)
|
|
908
|
-
content.sub(%r{<base\s+href=["'][^"']*["']\s*/?>}i, %(<base href="#{base_href}">))
|
|
909
|
-
else
|
|
910
|
-
content.sub(%r{<head([^>]*)>}i, %(<head\\1>\n <base href="#{base_href}">))
|
|
911
|
-
end
|
|
912
|
-
File.write(index_path, updated)
|
|
913
|
-
end
|
|
914
|
-
|
|
915
|
-
def inject_web_client_bootstrap(target)
|
|
916
|
-
index_path = File.join(target, "index.html")
|
|
917
|
-
return unless File.file?(index_path)
|
|
918
|
-
|
|
919
|
-
content = File.read(index_path)
|
|
920
|
-
return if content.include?('id="ruflet-rails-bootstrap"')
|
|
921
|
-
|
|
922
|
-
script = <<~HTML
|
|
923
|
-
<script id="ruflet-rails-bootstrap">
|
|
924
|
-
if (window.location.search === "" && window.location.hash === "") {
|
|
925
|
-
const rufletServerUrl = window.location.origin + "/";
|
|
926
|
-
window.history.replaceState(
|
|
927
|
-
null,
|
|
928
|
-
document.title,
|
|
929
|
-
window.location.pathname + "?url=" + encodeURIComponent(rufletServerUrl)
|
|
930
|
-
);
|
|
931
|
-
}
|
|
932
|
-
</script>
|
|
933
|
-
HTML
|
|
934
|
-
|
|
935
|
-
updated =
|
|
936
|
-
if content.include?('<script src="flutter_bootstrap.js"')
|
|
937
|
-
content.sub(%r{<script src="flutter_bootstrap\.js"[^>]*></script>}i) { |match| "#{script} #{match}" }
|
|
938
|
-
else
|
|
939
|
-
content.sub(%r{</body>}i, "#{script}</body>")
|
|
940
|
-
end
|
|
941
|
-
File.write(index_path, updated)
|
|
797
|
+
# Kept for backward compatibility with apps that use manual mount.
|
|
798
|
+
def route_snippet(entrypoint: default_entrypoint_path, mount_path: "/ws", helper: "app")
|
|
799
|
+
%(match "#{mount_path}", to: Ruflet::Rails.#{helper}(Rails.root.join("#{entrypoint}")), via: :all)
|
|
942
800
|
end
|
|
943
801
|
|
|
944
|
-
def install_next_steps(target:, entrypoint:, client:,
|
|
945
|
-
web_path = default_web_public_path
|
|
802
|
+
def install_next_steps(target:, entrypoint:, client:, mount_path: "/ws")
|
|
946
803
|
lines = [
|
|
947
804
|
"Ruflet Rails installed.",
|
|
948
805
|
"Generated entrypoint: #{entrypoint}",
|
|
949
806
|
"Mounted websocket: #{mount_path}",
|
|
950
807
|
"Next steps:",
|
|
951
808
|
" 1. Start Rails: bin/rails server",
|
|
952
|
-
" 2.
|
|
809
|
+
" 2. Connect your Ruflet app to ws://localhost:3000#{mount_path}"
|
|
953
810
|
]
|
|
954
811
|
|
|
955
|
-
if
|
|
956
|
-
lines << "Web client copied to public/#{web_path}."
|
|
957
|
-
elsif target.to_s == "ruflet" || %w[web all].include?(client.to_s)
|
|
958
|
-
lines += [
|
|
959
|
-
"Web client was not copied because no built/prebuilt web index.html was found.",
|
|
960
|
-
"To download the prebuilt client from GitHub: bin/rails ruflet:update[web]",
|
|
961
|
-
"To build the WASM web client yourself, install the ruflet CLI globally first:",
|
|
962
|
-
" gem install ruflet",
|
|
963
|
-
"Then build and copy build/web into public/#{web_path}:",
|
|
964
|
-
" bin/rails ruflet:build[web]"
|
|
965
|
-
]
|
|
966
|
-
end
|
|
967
|
-
|
|
968
|
-
if %w[desktop all].include?(client.to_s)
|
|
812
|
+
if client.to_s == "desktop"
|
|
969
813
|
lines += [
|
|
970
814
|
"Desktop clients are server-driven and connect to this Rails app.",
|
|
971
815
|
"Plain bin/dev, bin/rails server, and bin/rails s do not launch desktop.",
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Ruflet
|
|
6
|
+
module Rails
|
|
7
|
+
# Hotwire Native-style driver for ruflet_rails.
|
|
8
|
+
#
|
|
9
|
+
# Your existing web app is the body, rendered in a WebView. Navigation works
|
|
10
|
+
# out of the box: a tiny JS bridge injected into each page intercepts link
|
|
11
|
+
# clicks and proposes them to native, so every visit becomes a NATIVE screen
|
|
12
|
+
# pushed onto the stack (with an automatic back button) — no per-link code.
|
|
13
|
+
# The native AppBar title tracks each page's <title>, and you declare special
|
|
14
|
+
# paths as data.
|
|
15
|
+
#
|
|
16
|
+
# Ruflet.run do |page|
|
|
17
|
+
# Ruflet::Rails.native_app(
|
|
18
|
+
# page,
|
|
19
|
+
# start_url: "https://myapp.com",
|
|
20
|
+
# title: "My App", # auto-updates from <title>
|
|
21
|
+
# actions: -> { [icon_button("search", on_click: ->(_e) { ... })] },
|
|
22
|
+
# navigation_bar: navigation_bar(destinations: [...]),
|
|
23
|
+
#
|
|
24
|
+
# # web content shown in a bottom sheet (auth, quick forms):
|
|
25
|
+
# modal: ["/sign_in", "/sign_up", %r{/new\z}],
|
|
26
|
+
#
|
|
27
|
+
# # optional: override a path with a fully native screen:
|
|
28
|
+
# native: { %r{\A/products/(\d+)\z} => ->(ctx) { product_screen(ctx.match[1]) } }
|
|
29
|
+
# )
|
|
30
|
+
# end
|
|
31
|
+
#
|
|
32
|
+
# Normal links just push a native webview screen (back returns). Paths listed
|
|
33
|
+
# in `modal:` open as a bottom sheet. Paths in `native:` render your own UI.
|
|
34
|
+
#
|
|
35
|
+
# The bridge talks to native over the webview's console channel
|
|
36
|
+
# (on_console_message), which works on iOS/Android/macOS. On platforms
|
|
37
|
+
# without a native webview the body degrades to a plain frame.
|
|
38
|
+
class NativeApp
|
|
39
|
+
# Injected into every page: report the title, and turn same-origin link
|
|
40
|
+
# clicks into native visit proposals (so they don't load in place).
|
|
41
|
+
BRIDGE_JS = <<~JS
|
|
42
|
+
(function () {
|
|
43
|
+
function report(kind, value) { console.log("ruflet:" + kind + ":" + value); }
|
|
44
|
+
report("title", document.title || "");
|
|
45
|
+
if (window.__rufletBridgeBound) return;
|
|
46
|
+
window.__rufletBridgeBound = true;
|
|
47
|
+
document.addEventListener("click", function (e) {
|
|
48
|
+
var a = e.target && e.target.closest ? e.target.closest("a[href]") : null;
|
|
49
|
+
if (!a || a.target === "_blank") return;
|
|
50
|
+
if (a.origin && a.origin !== location.origin) return; // external: leave it
|
|
51
|
+
e.preventDefault();
|
|
52
|
+
report("visit", a.href);
|
|
53
|
+
}, true);
|
|
54
|
+
})();
|
|
55
|
+
JS
|
|
56
|
+
|
|
57
|
+
VISIT_PREFIX = "ruflet:visit:"
|
|
58
|
+
TITLE_PREFIX = "ruflet:title:"
|
|
59
|
+
|
|
60
|
+
Screen = Struct.new(:kind, :url, :view, :webview, :title_text, keyword_init: true)
|
|
61
|
+
Context = Struct.new(:url, :path, :match, keyword_init: true)
|
|
62
|
+
|
|
63
|
+
def initialize(page, start_url:, title: nil, actions: nil, navigation_bar: nil,
|
|
64
|
+
bottom_appbar: nil, modal: [], native: {})
|
|
65
|
+
@page = page
|
|
66
|
+
@start_url = start_url.to_s
|
|
67
|
+
@title = title.to_s
|
|
68
|
+
@actions = actions
|
|
69
|
+
@navigation_bar = navigation_bar
|
|
70
|
+
@bottom_appbar = bottom_appbar
|
|
71
|
+
@modal_patterns = Array(modal).map { |p| compile_pattern(p) }
|
|
72
|
+
@native_rules = native.map { |pattern, builder| [compile_pattern(pattern), builder] }
|
|
73
|
+
@screens = []
|
|
74
|
+
@modal_sheet = nil
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def self.bridge_js = BRIDGE_JS
|
|
78
|
+
|
|
79
|
+
def start
|
|
80
|
+
@page.on_view_pop = ->(_event) { pop }
|
|
81
|
+
push_webview(@start_url, root: true)
|
|
82
|
+
self
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
# --- Stack operations ---------------------------------------------------
|
|
88
|
+
|
|
89
|
+
def push_webview(url, root: false)
|
|
90
|
+
screen = Screen.new(kind: :webview, url: url.to_s)
|
|
91
|
+
screen.title_text = Ruflet::UI::ControlFactory.build(:text, value: @title)
|
|
92
|
+
screen.webview = build_webview(screen)
|
|
93
|
+
screen.view = build_view(screen, root: root)
|
|
94
|
+
@screens.push(screen)
|
|
95
|
+
flush
|
|
96
|
+
screen
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def push_native(builder, ctx)
|
|
100
|
+
view = builder.call(ctx)
|
|
101
|
+
return unless view.is_a?(Ruflet::Control)
|
|
102
|
+
|
|
103
|
+
@screens.push(Screen.new(kind: :native, url: ctx.url, view: view))
|
|
104
|
+
flush
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def pop
|
|
108
|
+
return if @screens.size <= 1
|
|
109
|
+
|
|
110
|
+
@screens.pop
|
|
111
|
+
flush
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def flush
|
|
115
|
+
@page.views = @screens.map(&:view)
|
|
116
|
+
@page.update
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# --- Webview screen + native chrome ------------------------------------
|
|
120
|
+
|
|
121
|
+
def build_webview(screen)
|
|
122
|
+
Ruflet::UI::ControlFactory.build(
|
|
123
|
+
:webview,
|
|
124
|
+
url: screen.url,
|
|
125
|
+
expand: true,
|
|
126
|
+
on_page_ended: ->(_event) { inject_bridge(screen) },
|
|
127
|
+
on_console_message: ->(event) { handle_message(screen, message_of(event)) }
|
|
128
|
+
)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def build_view(screen, root:)
|
|
132
|
+
args = { route: root ? "/" : "/screen", controls: [screen.webview], appbar: build_appbar(screen) }
|
|
133
|
+
if root
|
|
134
|
+
args[:navigation_bar] = @navigation_bar unless @navigation_bar.nil?
|
|
135
|
+
args[:bottom_appbar] = @bottom_appbar unless @bottom_appbar.nil?
|
|
136
|
+
end
|
|
137
|
+
Ruflet::UI::ControlFactory.build(:view, **args)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def build_appbar(screen)
|
|
141
|
+
args = { title: screen.title_text }
|
|
142
|
+
actions = resolve_actions
|
|
143
|
+
args[:actions] = actions if actions
|
|
144
|
+
Ruflet::UI::ControlFactory.build(:appbar, **args)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def resolve_actions
|
|
148
|
+
@actions.respond_to?(:call) ? @actions.call : @actions
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# --- Bridge ------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
def inject_bridge(screen)
|
|
154
|
+
screen.webview.run_javascript(BRIDGE_JS) if screen.webview.respond_to?(:run_javascript)
|
|
155
|
+
rescue StandardError
|
|
156
|
+
nil
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def handle_message(screen, message)
|
|
160
|
+
return if message.nil?
|
|
161
|
+
|
|
162
|
+
if message.start_with?(TITLE_PREFIX)
|
|
163
|
+
update_title(screen, message[TITLE_PREFIX.length..])
|
|
164
|
+
elsif message.start_with?(VISIT_PREFIX)
|
|
165
|
+
visit(message[VISIT_PREFIX.length..])
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def update_title(screen, value)
|
|
170
|
+
return unless screen.title_text&.wire_id
|
|
171
|
+
|
|
172
|
+
@page.update(screen.title_text, value: value.to_s)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# A proposed visit: native screen > modal sheet > push a webview screen.
|
|
176
|
+
def visit(url)
|
|
177
|
+
path = path_of(url)
|
|
178
|
+
return if path.nil?
|
|
179
|
+
|
|
180
|
+
builder, match = match_native(path)
|
|
181
|
+
if builder
|
|
182
|
+
push_native(builder, Context.new(url: url, path: path, match: match))
|
|
183
|
+
elsif modal?(path)
|
|
184
|
+
present_modal(url)
|
|
185
|
+
else
|
|
186
|
+
push_webview(url)
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# --- Modal (bottom sheet of web content) -------------------------------
|
|
191
|
+
|
|
192
|
+
def present_modal(url)
|
|
193
|
+
sheet_webview = Ruflet::UI::ControlFactory.build(:webview, url: url.to_s, expand: true)
|
|
194
|
+
@modal_sheet = Ruflet::UI::ControlFactory.build(
|
|
195
|
+
:bottomsheet,
|
|
196
|
+
open: true,
|
|
197
|
+
dismissible: true,
|
|
198
|
+
content: Ruflet::UI::ControlFactory.build(:container, height: 520, content: sheet_webview),
|
|
199
|
+
on_dismiss: ->(_event) { @modal_sheet = nil }
|
|
200
|
+
)
|
|
201
|
+
@page.bottom_sheet = @modal_sheet
|
|
202
|
+
@page.update
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# --- Matching ----------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
def match_native(path)
|
|
208
|
+
@native_rules.each do |pattern, builder|
|
|
209
|
+
if pattern.is_a?(Regexp)
|
|
210
|
+
m = pattern.match(path)
|
|
211
|
+
return [builder, m] if m
|
|
212
|
+
elsif pattern == path
|
|
213
|
+
return [builder, nil]
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
nil
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def modal?(path)
|
|
220
|
+
@modal_patterns.any? do |pattern|
|
|
221
|
+
pattern.is_a?(Regexp) ? pattern.match?(path) : pattern == path
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def compile_pattern(pattern)
|
|
226
|
+
return pattern if pattern.is_a?(Regexp)
|
|
227
|
+
|
|
228
|
+
value = pattern.to_s
|
|
229
|
+
value.start_with?("/") ? value : "/#{value}"
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def path_of(url)
|
|
233
|
+
URI.parse(url.to_s).path
|
|
234
|
+
rescue URI::InvalidURIError
|
|
235
|
+
nil
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def message_of(event)
|
|
239
|
+
data = event.respond_to?(:data) ? event.data : event
|
|
240
|
+
return data["message"] || data[:message] if data.is_a?(Hash)
|
|
241
|
+
|
|
242
|
+
data
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
module_function
|
|
247
|
+
|
|
248
|
+
# Start a Hotwire Native-style app. See NativeApp.
|
|
249
|
+
def native_app(page, **opts)
|
|
250
|
+
NativeApp.new(page, **opts).start
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|