ruflet_rails 0.0.16 → 0.0.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +220 -28
- data/lib/generators/ruflet/install/install_generator.rb +3 -31
- data/lib/ruflet/rails/desktop_launcher.rb +2 -2
- data/lib/ruflet/rails/html_dsl/control_diff.rb +109 -0
- data/lib/ruflet/rails/html_dsl/html_app.rb +1328 -0
- data/lib/ruflet/rails/html_dsl/parser.rb +208 -0
- data/lib/ruflet/rails/html_dsl/styles.rb +793 -0
- data/lib/ruflet/rails/html_dsl/template_source.rb +292 -0
- data/lib/ruflet/rails/html_dsl/transformer.rb +1210 -0
- data/lib/ruflet/rails/html_dsl/view_helpers.rb +329 -0
- data/lib/ruflet/rails/html_dsl.rb +46 -0
- data/lib/ruflet/rails/install_support.rb +4 -20
- data/lib/ruflet/rails/native_app.rb +4 -2
- data/lib/ruflet/rails/native_screens.rb +92 -0
- data/lib/ruflet/rails/protocol.rb +0 -1
- data/lib/ruflet/rails/railtie.rb +4 -33
- data/lib/ruflet/rails/view_helpers.rb +6 -45
- data/lib/ruflet/rails.rb +10 -51
- data/lib/ruflet/version.rb +1 -1
- data/lib/ruflet_rails.rb +2 -1
- metadata +16 -11
- data/lib/ruflet/rails/protocol/web_app.rb +0 -243
- data/lib/ruflet/rails/protocol/web_socket_connection.rb +0 -126
- data/lib/ruflet/rails/protocol/wire_codec.rb +0 -268
- data/lib/ruflet/rails/web_installer.rb +0 -137
|
@@ -0,0 +1,1328 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
require "erb"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module Ruflet
|
|
8
|
+
module Rails
|
|
9
|
+
module HtmlDsl
|
|
10
|
+
# HTML-over-the-wire native UI: a Rails template renders HTML DSL markup
|
|
11
|
+
# and this app turns each screen into real Ruflet controls — no WebView.
|
|
12
|
+
# Links push native screens, `on-click` runs a controller method and
|
|
13
|
+
# re-renders the screen in place, forms hand their field values to one.
|
|
14
|
+
class HtmlApp
|
|
15
|
+
Screen = Struct.new(:url, :view, :route, :root, :fields, :title,
|
|
16
|
+
:appbar_sig, :fab_sig, :nav_sig, :body, :markup, keyword_init: true)
|
|
17
|
+
|
|
18
|
+
# Event handlers stay bound to the screen whose markup declared them,
|
|
19
|
+
# so a control on a covered screen still targets the right state.
|
|
20
|
+
class ScreenHandlers
|
|
21
|
+
def initialize(app, screen)
|
|
22
|
+
@app = app
|
|
23
|
+
@screen = screen
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def navigate(url, mode) = @app.navigate(url, mode)
|
|
27
|
+
def action(url:) = @app.action(url: url, from: @screen)
|
|
28
|
+
def submit_form(form) = @app.submit_form(form, from: @screen)
|
|
29
|
+
def field_changed(name, value) = @screen.fields[name] = value
|
|
30
|
+
def service(spec) = @app.run_service(spec)
|
|
31
|
+
def control_event(spec, event) = @app.handle_control_event(spec, event)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def initialize(page, start_url:, title: nil, fetcher: nil)
|
|
35
|
+
@page = page
|
|
36
|
+
@start_url = start_url.to_s
|
|
37
|
+
@title = title
|
|
38
|
+
@fetcher = fetcher || default_fetcher
|
|
39
|
+
@screens = []
|
|
40
|
+
@route_seq = 0
|
|
41
|
+
@service_request_token = 0
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def start
|
|
45
|
+
@page.on_view_pop = ->(_event) { pop }
|
|
46
|
+
push_screen(@start_url, root: true)
|
|
47
|
+
self
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Screens are rendered in the session itself, with no request behind
|
|
51
|
+
# them, so the whole session stays on its single WebSocket thread.
|
|
52
|
+
def default_fetcher
|
|
53
|
+
TemplateSource.new
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# --- navigation ---------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
def navigate(url, mode)
|
|
59
|
+
url = absolute_url(url)
|
|
60
|
+
case mode.to_s
|
|
61
|
+
when "back"
|
|
62
|
+
pop
|
|
63
|
+
when "root"
|
|
64
|
+
@screens.clear
|
|
65
|
+
push_screen(url, root: true)
|
|
66
|
+
when "replace"
|
|
67
|
+
@screens.pop
|
|
68
|
+
push_screen(url, root: @screens.empty?)
|
|
69
|
+
else
|
|
70
|
+
push_screen(url, root: @screens.empty?)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# An `on-click` action: run the method the URL names, then re-render
|
|
75
|
+
# the screen it was tapped on with the markup that comes back.
|
|
76
|
+
def action(url:, from: nil)
|
|
77
|
+
screen = from || @screens.last
|
|
78
|
+
return unless screen
|
|
79
|
+
|
|
80
|
+
response = request(absolute_url(url))
|
|
81
|
+
rerender(screen, response)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def submit_form(form, from: nil)
|
|
85
|
+
screen = from || @screens.last
|
|
86
|
+
return unless screen
|
|
87
|
+
|
|
88
|
+
params = form[:fields].keys.each_with_object({}) do |name, collected|
|
|
89
|
+
value = screen.fields.key?(name) ? screen.fields[name] : form[:fields][name]
|
|
90
|
+
collected[name] = value.nil? ? "" : value
|
|
91
|
+
end
|
|
92
|
+
response = request(absolute_url(form[:action]), params: params)
|
|
93
|
+
rerender(screen, response)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def pop
|
|
97
|
+
return if @screens.size <= 1
|
|
98
|
+
|
|
99
|
+
invalidate_pending_services
|
|
100
|
+
@screens.pop
|
|
101
|
+
flush
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# A pending service result belongs to the screen that asked for it.
|
|
105
|
+
# Bumping the request token when the stack changes means a slow result
|
|
106
|
+
# that lands after the user has navigated away is ignored, instead of
|
|
107
|
+
# surfacing on whatever screen happens to be visible now.
|
|
108
|
+
def invalidate_pending_services
|
|
109
|
+
@service_request_token += 1
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Run a native platform service in response to a tap
|
|
113
|
+
# (`<button service="copy" text="…">`). Everything the device can do is
|
|
114
|
+
# reachable this way: the tap invokes the matching native method and,
|
|
115
|
+
# for anything that returns data or can fail, the result comes back in
|
|
116
|
+
# a native result dialog. Streaming services can target an inline Text
|
|
117
|
+
# control instead, so rapid readings do not cover the application UI.
|
|
118
|
+
def run_service(spec)
|
|
119
|
+
@service_request_token += 1
|
|
120
|
+
@service_result_target = spec["result-target"]
|
|
121
|
+
name = spec["service"].to_s.downcase.tr("_", "-")
|
|
122
|
+
case name
|
|
123
|
+
# --- clipboard --------------------------------------------------------
|
|
124
|
+
when "copy", "clipboard", "clipboard-set"
|
|
125
|
+
@page.set_clipboard(spec["text"].to_s)
|
|
126
|
+
show_service_result(spec["toast"] || "Copied to clipboard", title: "Clipboard",
|
|
127
|
+
target: @service_result_target)
|
|
128
|
+
when "paste", "read-clipboard", "clipboard-get"
|
|
129
|
+
@page.get_clipboard(on_result: report("Clipboard", show_result: true))
|
|
130
|
+
when "clipboard-set-files"
|
|
131
|
+
@page.set_clipboard_files(array_value(spec["files"]), on_result: report("Clipboard files saved"))
|
|
132
|
+
when "clipboard-get-files"
|
|
133
|
+
@page.get_clipboard_files(on_result: report("Clipboard files", show_result: true))
|
|
134
|
+
when "clipboard-set-image"
|
|
135
|
+
@page.set_clipboard_image(spec["value"] || spec["data"], on_result: report("Clipboard image saved"))
|
|
136
|
+
when "clipboard-get-image"
|
|
137
|
+
@page.get_clipboard_image(on_result: report("Clipboard image", show_result: true))
|
|
138
|
+
# --- share ------------------------------------------------------------
|
|
139
|
+
when "share", "share-text"
|
|
140
|
+
@page.share_text(spec["text"].to_s, title: spec["title"], subject: spec["subject"],
|
|
141
|
+
on_result: report(spec["toast"] || "Share sheet opened"))
|
|
142
|
+
when "share-link", "share-url", "share-uri"
|
|
143
|
+
@page.share_uri((spec["uri"] || spec["url"]).to_s, on_result: report("Share sheet opened"))
|
|
144
|
+
when "share-files"
|
|
145
|
+
@page.share_files(array_value(spec["files"]), text: spec["text"], title: spec["title"],
|
|
146
|
+
subject: spec["subject"],
|
|
147
|
+
on_result: report("Share sheet opened"))
|
|
148
|
+
# --- haptics ----------------------------------------------------------
|
|
149
|
+
when "haptic", "vibrate"
|
|
150
|
+
run_haptic(spec["style"])
|
|
151
|
+
show_service_result(spec["toast"] || "Haptic feedback", title: "Haptics",
|
|
152
|
+
target: @service_result_target)
|
|
153
|
+
# --- links ------------------------------------------------------------
|
|
154
|
+
when "launch", "open", "url"
|
|
155
|
+
@page.launch_url(spec["url"].to_s, mode: spec["mode"], on_result: report("Link opened"))
|
|
156
|
+
when "can-launch"
|
|
157
|
+
invoke_named_service("url_launcher", "can_launch_url", { "url" => spec["url"] }, "Can launch")
|
|
158
|
+
when "open-window"
|
|
159
|
+
@page.open_window(spec["url"].to_s, title: spec["title"], width: spec["width"],
|
|
160
|
+
height: spec["height"], on_result: report("Window opened"))
|
|
161
|
+
when "close-in-app-web-view"
|
|
162
|
+
@page.close_in_app_web_view(on_result: report("In-app browser closed"))
|
|
163
|
+
# --- hardware ---------------------------------------------------------
|
|
164
|
+
when "flashlight", "torch"
|
|
165
|
+
toggle_flashlight
|
|
166
|
+
when "flashlight-on", "torch-on"
|
|
167
|
+
@page.flashlight.on(on_result: report("Torch on"))
|
|
168
|
+
@flashlight_on = true
|
|
169
|
+
when "flashlight-off", "torch-off"
|
|
170
|
+
@page.flashlight.off(on_result: report("Torch off"))
|
|
171
|
+
@flashlight_on = false
|
|
172
|
+
when "flashlight-available"
|
|
173
|
+
@page.flashlight.is_available(on_result: report("Flashlight available", show_result: true))
|
|
174
|
+
when "wakelock", "keep-awake"
|
|
175
|
+
toggle_wakelock
|
|
176
|
+
when "wakelock-enable"
|
|
177
|
+
@page.wakelock.enable(on_result: report("Staying awake"))
|
|
178
|
+
@wakelock_on = true
|
|
179
|
+
when "wakelock-disable"
|
|
180
|
+
@page.wakelock.disable(on_result: report("Sleep allowed"))
|
|
181
|
+
@wakelock_on = false
|
|
182
|
+
when "wakelock-status"
|
|
183
|
+
@page.wakelock.is_enabled(on_result: report("Wakelock enabled", show_result: true))
|
|
184
|
+
when "brightness"
|
|
185
|
+
level = clamp_unit(spec["value"], 0.5)
|
|
186
|
+
@page.screen_brightness.set_application_screen_brightness(
|
|
187
|
+
level, on_result: report("Brightness #{(level * 100).round}%")
|
|
188
|
+
)
|
|
189
|
+
when "brightness-get", "brightness-application"
|
|
190
|
+
invoke_named_service("screen_brightness", "get_application_screen_brightness", nil,
|
|
191
|
+
"Application brightness", show_result: true)
|
|
192
|
+
when "brightness-system-get"
|
|
193
|
+
invoke_named_service("screen_brightness", "get_system_screen_brightness", nil,
|
|
194
|
+
"System brightness", show_result: true)
|
|
195
|
+
when "brightness-can-change-system"
|
|
196
|
+
invoke_named_service("screen_brightness", "can_change_system_screen_brightness", nil,
|
|
197
|
+
"Can change system brightness", show_result: true)
|
|
198
|
+
when "brightness-animate-get"
|
|
199
|
+
invoke_named_service("screen_brightness", "is_animate", nil,
|
|
200
|
+
"Brightness animation", show_result: true)
|
|
201
|
+
when "brightness-auto-reset-get"
|
|
202
|
+
invoke_named_service("screen_brightness", "is_auto_reset", nil,
|
|
203
|
+
"Brightness auto reset", show_result: true)
|
|
204
|
+
when "brightness-system"
|
|
205
|
+
level = clamp_unit(spec["value"], 0.5)
|
|
206
|
+
@page.screen_brightness.set_system_screen_brightness(
|
|
207
|
+
level, on_result: report("System brightness #{(level * 100).round}%")
|
|
208
|
+
)
|
|
209
|
+
when "brightness-animate-set"
|
|
210
|
+
invoke_named_service("screen_brightness", "set_animate", { "value" => truthy?(spec["value"]) },
|
|
211
|
+
"Brightness animation")
|
|
212
|
+
when "brightness-auto-reset-set"
|
|
213
|
+
invoke_named_service("screen_brightness", "set_auto_reset", { "value" => truthy?(spec["value"]) },
|
|
214
|
+
"Brightness auto reset")
|
|
215
|
+
when "brightness-reset"
|
|
216
|
+
invoke_named_service("screen_brightness", "reset_application_screen_brightness", nil,
|
|
217
|
+
"Application brightness reset")
|
|
218
|
+
# --- queries ----------------------------------------------------------
|
|
219
|
+
when "battery"
|
|
220
|
+
read_battery
|
|
221
|
+
when "battery-level"
|
|
222
|
+
@page.get_battery_level(on_result: report("Battery level", show_result: true))
|
|
223
|
+
when "battery-state"
|
|
224
|
+
@page.get_battery_state(on_result: report("Battery state", show_result: true))
|
|
225
|
+
when "battery-save-mode"
|
|
226
|
+
@page.is_in_battery_save_mode(on_result: report("Battery save mode", show_result: true))
|
|
227
|
+
when "connectivity"
|
|
228
|
+
@page.get_connectivity(on_result: report("Connectivity", show_result: true))
|
|
229
|
+
when "location", "geolocate", "position"
|
|
230
|
+
geolocator.get_current_position(on_result: report("Position", show_result: true))
|
|
231
|
+
when "location-last"
|
|
232
|
+
geolocator.get_last_known_position(on_result: report("Last position", show_result: true))
|
|
233
|
+
when "location-permission"
|
|
234
|
+
geolocator.get_permission_status(on_result: report("Location permission", show_result: true))
|
|
235
|
+
when "location-request-permission"
|
|
236
|
+
geolocator.request_permission(on_result: report("Location permission", show_result: true))
|
|
237
|
+
when "location-service-enabled"
|
|
238
|
+
geolocator.is_location_service_enabled(on_result: report("Location service", show_result: true))
|
|
239
|
+
when "location-open-settings"
|
|
240
|
+
geolocator.open_location_settings(on_result: report("Location settings opened"))
|
|
241
|
+
when "location-open-app-settings"
|
|
242
|
+
geolocator.open_app_settings(on_result: report("App settings opened"))
|
|
243
|
+
when "location-distance"
|
|
244
|
+
geolocator.distance_between(
|
|
245
|
+
spec["start-latitude"], spec["start-longitude"], spec["end-latitude"], spec["end-longitude"],
|
|
246
|
+
on_result: report("Distance", show_result: true)
|
|
247
|
+
)
|
|
248
|
+
# --- secure storage ---------------------------------------------------
|
|
249
|
+
when "secure-set"
|
|
250
|
+
secure_storage.set(spec["key"].to_s, spec["value"].to_s, on_result: report("Stored securely"))
|
|
251
|
+
when "secure-get"
|
|
252
|
+
secure_storage.get(spec["key"].to_s, on_result: report("Secure value", show_result: true))
|
|
253
|
+
when "secure-contains"
|
|
254
|
+
secure_storage.contains_key(spec["key"].to_s,
|
|
255
|
+
on_result: report("Secure key exists", show_result: true))
|
|
256
|
+
when "secure-all"
|
|
257
|
+
secure_storage.get_all(on_result: report("Secure values", show_result: true))
|
|
258
|
+
when "secure-remove"
|
|
259
|
+
secure_storage.remove(spec["key"].to_s, on_result: report("Secure value removed"))
|
|
260
|
+
when "secure-clear"
|
|
261
|
+
secure_storage.clear(on_result: report("Secure storage cleared"))
|
|
262
|
+
when "secure-availability"
|
|
263
|
+
secure_storage.get_availability(on_result: report("Secure storage", show_result: true))
|
|
264
|
+
# --- shared preferences -----------------------------------------------
|
|
265
|
+
when "prefs-set", "preference-set"
|
|
266
|
+
@page.shared_preferences.set(spec["key"].to_s, spec["value"],
|
|
267
|
+
on_result: report("Preference saved"))
|
|
268
|
+
when "prefs-get", "preference-get"
|
|
269
|
+
@page.shared_preferences.get(spec["key"].to_s, on_result: report("Preference", show_result: true))
|
|
270
|
+
when "prefs-contains", "preference-contains"
|
|
271
|
+
@page.shared_preferences.contains_key(
|
|
272
|
+
spec["key"].to_s, on_result: report("Preference exists", show_result: true)
|
|
273
|
+
)
|
|
274
|
+
when "prefs-keys", "preference-keys"
|
|
275
|
+
@page.shared_preferences.get_keys(
|
|
276
|
+
spec["prefix"], on_result: report("Preference keys", show_result: true)
|
|
277
|
+
)
|
|
278
|
+
when "prefs-remove", "preference-remove"
|
|
279
|
+
@page.shared_preferences.remove(spec["key"].to_s, on_result: report("Preference removed"))
|
|
280
|
+
when "prefs-clear", "preference-clear"
|
|
281
|
+
@page.shared_preferences.clear(on_result: report("Preferences cleared"))
|
|
282
|
+
# --- permissions ------------------------------------------------------
|
|
283
|
+
when "permission", "request-permission"
|
|
284
|
+
permissions.request(spec["name"].to_s, timeout: interactive_timeout(spec),
|
|
285
|
+
on_result: report("Permission", show_result: true))
|
|
286
|
+
when "permission-status"
|
|
287
|
+
permissions.get_status(spec["name"].to_s, on_result: report("Permission", show_result: true))
|
|
288
|
+
when "permission-settings"
|
|
289
|
+
permissions.open_app_settings(on_result: report("App settings opened"))
|
|
290
|
+
# --- files and paths ---------------------------------------------------
|
|
291
|
+
when "pick-files", "file-picker"
|
|
292
|
+
@page.pick_files(
|
|
293
|
+
dialog_title: spec["dialog-title"], initial_directory: spec["initial-directory"],
|
|
294
|
+
file_type: spec["file-type"] || "any",
|
|
295
|
+
allowed_extensions: array_value(spec["allowed-extensions"]),
|
|
296
|
+
allow_multiple: truthy?(spec["allow-multiple"]), with_data: truthy?(spec["with-data"]),
|
|
297
|
+
timeout: interactive_timeout(spec),
|
|
298
|
+
on_result: report("Selected files", show_result: true)
|
|
299
|
+
)
|
|
300
|
+
when "pick-directory", "directory-picker"
|
|
301
|
+
@page.get_directory_path(
|
|
302
|
+
dialog_title: spec["dialog-title"], initial_directory: spec["initial-directory"],
|
|
303
|
+
timeout: interactive_timeout(spec),
|
|
304
|
+
on_result: report("Selected directory", show_result: true)
|
|
305
|
+
)
|
|
306
|
+
when "save-file"
|
|
307
|
+
@page.save_file(
|
|
308
|
+
dialog_title: spec["dialog-title"], file_name: spec["file-name"],
|
|
309
|
+
initial_directory: spec["initial-directory"], file_type: spec["file-type"] || "any",
|
|
310
|
+
allowed_extensions: array_value(spec["allowed-extensions"]),
|
|
311
|
+
src_bytes: binary_bytes(spec["src-bytes"]),
|
|
312
|
+
timeout: interactive_timeout(spec),
|
|
313
|
+
on_result: report("Saved file", show_result: true)
|
|
314
|
+
)
|
|
315
|
+
when /\Astorage-(.+)\z/
|
|
316
|
+
method = storage_path_method(Regexp.last_match(1))
|
|
317
|
+
method ? @page.public_send(method, on_result: report("Storage path", show_result: true)) :
|
|
318
|
+
show_service_result("Unknown storage path: #{Regexp.last_match(1)}", title: "Storage path")
|
|
319
|
+
# --- accessibility and recording --------------------------------------
|
|
320
|
+
when "announce", "semantics-announce"
|
|
321
|
+
semantics.announce_message(
|
|
322
|
+
spec["message"] || spec["text"], rtl: truthy?(spec["rtl"]),
|
|
323
|
+
assertiveness: spec["assertiveness"] || "polite",
|
|
324
|
+
on_result: report("Accessibility message announced")
|
|
325
|
+
)
|
|
326
|
+
when "semantics-tooltip"
|
|
327
|
+
semantics.announce_tooltip(spec["message"] || spec["text"],
|
|
328
|
+
on_result: report("Accessibility tooltip announced"))
|
|
329
|
+
when "semantics-features"
|
|
330
|
+
semantics.get_accessibility_features(
|
|
331
|
+
on_result: report("Accessibility features", show_result: true)
|
|
332
|
+
)
|
|
333
|
+
when /\Aaudio-recorder-(.+)\z/
|
|
334
|
+
run_audio_recorder(Regexp.last_match(1), spec)
|
|
335
|
+
when /\A(accelerometer|user-accelerometer|gyroscope|magnetometer|barometer)-(start|stop)\z/
|
|
336
|
+
toggle_sensor(Regexp.last_match(1), Regexp.last_match(2), spec)
|
|
337
|
+
when "camera-open", "camera-initialize"
|
|
338
|
+
open_camera(spec)
|
|
339
|
+
when "camera-capture", "camera-take-picture"
|
|
340
|
+
capture_camera_picture(spec)
|
|
341
|
+
# --- extension controls and generic service escape hatch --------------
|
|
342
|
+
when "control"
|
|
343
|
+
invoke_target_control(spec)
|
|
344
|
+
else
|
|
345
|
+
invoke_generic_service(name, spec)
|
|
346
|
+
end
|
|
347
|
+
rescue StandardError => e
|
|
348
|
+
show_service_result(e.message, title: "#{service_label(name)} failed",
|
|
349
|
+
target: @service_result_target)
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# Declarative extension callback used by ERB attributes such as:
|
|
353
|
+
# on-loaded-target="audio-status" on-loaded-message="Audio loaded"
|
|
354
|
+
# on-change-control="demo-rive" on-change-property="speed_multiplier"
|
|
355
|
+
def handle_control_event(spec, event)
|
|
356
|
+
data = event.respond_to?(:data) ? event.data : event
|
|
357
|
+
value = if data.is_a?(Hash)
|
|
358
|
+
data["value"] || data[:value] || (data.one? ? data.values.first : data)
|
|
359
|
+
else
|
|
360
|
+
data
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
if spec["control"] && spec["property"]
|
|
364
|
+
control = @page.get_control(spec["control"].to_s)
|
|
365
|
+
@page.update(control, spec["property"].to_s.tr("-", "_").to_sym => value) if control
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# The event can re-run a whole service (e.g. battery re-reads level /
|
|
369
|
+
# state / saver on `on_state_change`), which reports for itself.
|
|
370
|
+
if spec["service"]
|
|
371
|
+
return run_service("service" => spec["service"], "result-target" => spec["result-target"])
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
target = spec["target"]
|
|
375
|
+
return unless target
|
|
376
|
+
|
|
377
|
+
message = spec["message"]
|
|
378
|
+
message = "#{spec['prefix']}#{format_service_value(value)}" if message.to_s.empty?
|
|
379
|
+
show_service_result(message, target: target)
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
private
|
|
383
|
+
|
|
384
|
+
# --- screens --------------------------------------------------------------
|
|
385
|
+
|
|
386
|
+
# A new screen is fetched and rendered atomically, then pushed as one
|
|
387
|
+
# complete View. Painting a placeholder first (spinner + a guessed app
|
|
388
|
+
# bar) only to swap it for the real chrome makes the screen visibly
|
|
389
|
+
# flicker/shake; a single push lets the client run one clean transition.
|
|
390
|
+
def push_screen(url, root: false)
|
|
391
|
+
url = absolute_url(url)
|
|
392
|
+
return if url.empty?
|
|
393
|
+
|
|
394
|
+
invalidate_pending_services
|
|
395
|
+
screen = Screen.new(url: url, root: root, fields: {})
|
|
396
|
+
screen.route = root ? "/" : "/screen/#{@route_seq += 1}"
|
|
397
|
+
response = request(url)
|
|
398
|
+
result = render_result(screen, response)
|
|
399
|
+
screen.view = build_view(screen, result)
|
|
400
|
+
remember_chrome(screen, result)
|
|
401
|
+
@screens.push(screen)
|
|
402
|
+
# The service registry may immediately send a page update. Put the
|
|
403
|
+
# completed screen on the stack first so that first update already
|
|
404
|
+
# contains the real view instead of an empty/previous screen.
|
|
405
|
+
mount_services(result)
|
|
406
|
+
flush
|
|
407
|
+
run_on_load(result)
|
|
408
|
+
screen
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
# Run each `on-load` service once the screen is mounted and its services
|
|
412
|
+
# are registered — Studio's build-time `refresh_info.call` that fills a
|
|
413
|
+
# panel (battery, screen brightness, …) before the user taps anything.
|
|
414
|
+
def run_on_load(result)
|
|
415
|
+
Array(result.on_load).each { |spec| run_service(spec) }
|
|
416
|
+
rescue StandardError
|
|
417
|
+
nil
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
# Mount a screen's declared services (`<geolocator>`, `<battery>`, …) on
|
|
421
|
+
# the page's service registry so they register with the client. Each
|
|
422
|
+
# screen owns its services; navigating replaces them.
|
|
423
|
+
# Services are what `invoke` targets. If mounting fails the control
|
|
424
|
+
# never reaches the client, every later invoke waits for an answer that
|
|
425
|
+
# cannot come, and the screen reports "execution expired" — a timeout
|
|
426
|
+
# standing in for a mount error nobody saw. Swallowing the cause here
|
|
427
|
+
# is what made camera/recorder/audio failures unreadable, so say it.
|
|
428
|
+
def mount_services(result)
|
|
429
|
+
return unless @page.respond_to?(:services=)
|
|
430
|
+
|
|
431
|
+
@page.services = result.services
|
|
432
|
+
rescue StandardError => e
|
|
433
|
+
warn "[ruflet_rails] mounting services failed: #{e.class}: #{e.message}"
|
|
434
|
+
show_service_result("Services could not be mounted: #{e.message}",
|
|
435
|
+
title: "Screen services failed")
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
# Attributes the DSL consumes itself. Everything else on a service
|
|
439
|
+
# element becomes a native invoke argument, so anything that is really
|
|
440
|
+
# markup — the button's own styling, its icon, its variant, the
|
|
441
|
+
# `on-load` marker — has to be named here. It is not cosmetic: a `play`
|
|
442
|
+
# that arrives carrying `class`/`icon`/`variant` is a call the client
|
|
443
|
+
# cannot match to the control's method signature, which is exactly how
|
|
444
|
+
# the ERB audio buttons ended up doing nothing at all.
|
|
445
|
+
SERVICE_META_KEYS = %w[
|
|
446
|
+
service method target args toast timeout id disabled
|
|
447
|
+
class icon variant on-load file-name output-path path configuration upload encoder
|
|
448
|
+
].freeze
|
|
449
|
+
|
|
450
|
+
# Anything naming a control to update — result-target, capture-target,
|
|
451
|
+
# indicator-target, record-target, … — is markup, and apps keep
|
|
452
|
+
# inventing new ones. Match the shape instead of listing them: an
|
|
453
|
+
# invented name that leaks becomes an argument the client cannot match
|
|
454
|
+
# to the method's signature, and the tap silently does nothing.
|
|
455
|
+
CONTROL_TARGET_ATTRIBUTE = /-target\z/
|
|
456
|
+
|
|
457
|
+
# Declarative event wiring (`on-loaded-target`, `on-error-prefix`, …) is
|
|
458
|
+
# markup too, and there is one per event, so match them by shape.
|
|
459
|
+
DECLARED_EVENT_ATTRIBUTE = /\Aon-.+-(?:target|service|prefix|message|control|property|result-target)\z/
|
|
460
|
+
|
|
461
|
+
STORAGE_PATH_METHODS = {
|
|
462
|
+
"cache" => :get_application_cache_directory,
|
|
463
|
+
"documents" => :get_application_documents_directory,
|
|
464
|
+
"support" => :get_application_support_directory,
|
|
465
|
+
"downloads" => :get_downloads_directory,
|
|
466
|
+
"external-cache" => :get_external_cache_directories,
|
|
467
|
+
"external-storage-directories" => :get_external_storage_directories,
|
|
468
|
+
"external-storage" => :get_external_storage_directory,
|
|
469
|
+
"library" => :get_library_directory,
|
|
470
|
+
"temporary" => :get_temporary_directory,
|
|
471
|
+
"temp" => :get_temporary_directory,
|
|
472
|
+
"console-log" => :get_console_log_filename
|
|
473
|
+
}.freeze
|
|
474
|
+
|
|
475
|
+
def storage_path_method(name)
|
|
476
|
+
STORAGE_PATH_METHODS[name.to_s.tr("_", "-")]
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
# Reuse the service the screen already mounted (matched by type) without
|
|
480
|
+
# passing any props to it. Passing props to an existing service merges
|
|
481
|
+
# and RE-REGISTERS it — a service-container update sent right before we
|
|
482
|
+
# invoke, which can make the client tear down and rebuild the control at
|
|
483
|
+
# the very moment the invoke arrives (→ "execution expired"). Studio never
|
|
484
|
+
# re-touches a service after creating it; this mirrors that. Only when the
|
|
485
|
+
# screen declared no such service do we create one, with a stable key.
|
|
486
|
+
def reuse_service(type, key)
|
|
487
|
+
compact = type.to_s.delete("_").downcase
|
|
488
|
+
existing = @page.services.find do |service|
|
|
489
|
+
service.respond_to?(:type) && service.type.to_s.delete("_").downcase == compact
|
|
490
|
+
end
|
|
491
|
+
existing || @page.service(type, key: key)
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
def geolocator
|
|
495
|
+
reuse_service(:geolocator, "ruflet_rails_geolocator")
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
def semantics
|
|
499
|
+
reuse_service(:semantics_service, "ruflet_rails_semantics")
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
def run_audio_recorder(action, spec)
|
|
503
|
+
recorder = reuse_service(:audio_recorder, "ruflet_rails_audio_recorder")
|
|
504
|
+
callback = report("Audio recorder #{action.tr('-', ' ')}", show_result: true)
|
|
505
|
+
case action
|
|
506
|
+
when "start"
|
|
507
|
+
start_audio_recording(recorder, spec)
|
|
508
|
+
when "stop"
|
|
509
|
+
stop_audio_recording(recorder, spec)
|
|
510
|
+
when "supported-encoder"
|
|
511
|
+
recorder.is_supported_encoder(spec["encoder"], on_result: callback)
|
|
512
|
+
else
|
|
513
|
+
method = "#{action.tr('-', '_')}_recording"
|
|
514
|
+
method = action.tr("-", "_") if recorder.respond_to?(action.tr("-", "_"))
|
|
515
|
+
raise ArgumentError, "unknown recorder action #{action.inspect}" unless recorder.respond_to?(method)
|
|
516
|
+
|
|
517
|
+
recorder.public_send(method, on_result: callback)
|
|
518
|
+
end
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
# Recording needs a writable file path *on the device*, and the mic
|
|
522
|
+
# permission has to be granted first. Studio establishes the same
|
|
523
|
+
# sequence; Rails asks PermissionHandler explicitly because the record
|
|
524
|
+
# plugin's `has_permission` preflight can remain pending on an iOS
|
|
525
|
+
# simulator before the system prompt has ever been shown.
|
|
526
|
+
def start_audio_recording(recorder, spec)
|
|
527
|
+
target = spec["result-target"]
|
|
528
|
+
configuration = spec["configuration"] || { "encoder" => "wav" }
|
|
529
|
+
explicit = (spec["output-path"] || spec["path"]).to_s
|
|
530
|
+
|
|
531
|
+
begin_recording = lambda do |file|
|
|
532
|
+
start_native_recording = lambda do
|
|
533
|
+
recorder.start_recording(
|
|
534
|
+
output_path: file, configuration: configuration, upload: spec["upload"],
|
|
535
|
+
timeout: interactive_timeout(spec),
|
|
536
|
+
on_result: lambda do |*rec|
|
|
537
|
+
if service_error?(rec[1])
|
|
538
|
+
next show_service_result("Start error: #{rec[1]}",
|
|
539
|
+
title: "Audio recorder failed", target: target)
|
|
540
|
+
end
|
|
541
|
+
unless rec[0]
|
|
542
|
+
next show_service_result("The recorder could not start.",
|
|
543
|
+
title: "Audio recorder failed", target: target)
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
set_audio_recording_state(spec, recording: true)
|
|
547
|
+
show_service_result("Recording… Speak now.", title: "Audio recorder", target: target)
|
|
548
|
+
end
|
|
549
|
+
)
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
if @recording_microphone_permission_granted
|
|
553
|
+
start_native_recording.call
|
|
554
|
+
next
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
permissions = reuse_service(:permission_handler, "ruflet_rails_permissions")
|
|
558
|
+
permissions.request("microphone", timeout: interactive_timeout(spec), on_result: lambda do |*out|
|
|
559
|
+
permission_status, perm_error = out
|
|
560
|
+
if service_error?(perm_error)
|
|
561
|
+
next show_service_result("Recorder permission error: #{perm_error}",
|
|
562
|
+
title: "Audio recorder failed", target: target)
|
|
563
|
+
end
|
|
564
|
+
unless microphone_permission_granted?(permission_status)
|
|
565
|
+
next show_service_result("Microphone permission was not granted.",
|
|
566
|
+
title: "Audio recorder", target: target)
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
@recording_microphone_permission_granted = true
|
|
570
|
+
start_native_recording.call
|
|
571
|
+
end)
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
release_recording_playback do
|
|
575
|
+
next begin_recording.call(explicit) unless explicit.empty?
|
|
576
|
+
|
|
577
|
+
show_service_result("Preparing recording…", title: "Audio recorder", target: target)
|
|
578
|
+
if @recording_documents_directory
|
|
579
|
+
file_name = spec["file-name"].to_s
|
|
580
|
+
file_name = next_recording_file_name if file_name.empty?
|
|
581
|
+
next begin_recording.call("#{@recording_documents_directory}/#{file_name}")
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
@page.get_application_documents_directory(on_result: lambda do |dir, dir_error|
|
|
585
|
+
if service_error?(dir_error) || dir.to_s.empty?
|
|
586
|
+
next show_service_result(
|
|
587
|
+
"Recording path error: #{dir_error || 'documents directory unavailable'}",
|
|
588
|
+
title: "Audio recorder failed", target: target
|
|
589
|
+
)
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
@recording_documents_directory = dir.to_s.sub(%r{/+\z}, "")
|
|
593
|
+
requested_name = spec["file-name"].to_s
|
|
594
|
+
file_name = requested_name.empty? ? next_recording_file_name : requested_name
|
|
595
|
+
file = "#{@recording_documents_directory}/#{file_name}"
|
|
596
|
+
begin_recording.call(file)
|
|
597
|
+
end)
|
|
598
|
+
end
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
def next_recording_file_name
|
|
602
|
+
@recording_sequence = @recording_sequence.to_i + 1
|
|
603
|
+
"ruflet_recording_#{@recording_sequence}.wav"
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
def release_recording_playback(&after_release)
|
|
607
|
+
@recording_playback_generation = @recording_playback_generation.to_i + 1
|
|
608
|
+
previous = @recording_playback
|
|
609
|
+
@recording_playback = nil
|
|
610
|
+
return after_release.call unless previous
|
|
611
|
+
|
|
612
|
+
# A completed native player does not consistently answer a `release`
|
|
613
|
+
# invocation. Removing the service disposes the player and must not
|
|
614
|
+
# block the next recording session on an optional callback.
|
|
615
|
+
@page.remove_service(previous)
|
|
616
|
+
after_release.call
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
def microphone_permission_granted?(status)
|
|
620
|
+
status == true || status.to_s == "granted"
|
|
621
|
+
end
|
|
622
|
+
|
|
623
|
+
def stop_audio_recording(recorder, spec)
|
|
624
|
+
target = spec["result-target"]
|
|
625
|
+
recorder.stop_recording(on_result: lambda do |path, error|
|
|
626
|
+
set_audio_recording_state(spec, recording: false)
|
|
627
|
+
if service_error?(error)
|
|
628
|
+
next show_service_result("Stop error: #{error}",
|
|
629
|
+
title: "Audio recorder failed", target: target)
|
|
630
|
+
end
|
|
631
|
+
if path.to_s.empty?
|
|
632
|
+
next show_service_result("Recording stopped, but no audio file was returned.",
|
|
633
|
+
title: "Audio recorder failed", target: target)
|
|
634
|
+
end
|
|
635
|
+
|
|
636
|
+
autoplay_recording(path.to_s, target: target)
|
|
637
|
+
end)
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
def set_audio_recording_state(spec, recording:)
|
|
641
|
+
update_control_property(spec["indicator-target"], visible: recording)
|
|
642
|
+
update_control_property(spec["record-target"], disabled: recording)
|
|
643
|
+
update_control_property(spec["stop-target"], disabled: !recording)
|
|
644
|
+
end
|
|
645
|
+
|
|
646
|
+
def update_control_property(id, **props)
|
|
647
|
+
return if id.to_s.empty?
|
|
648
|
+
|
|
649
|
+
control = @page.get_control(id.to_s)
|
|
650
|
+
@page.update(control, **props) if control
|
|
651
|
+
end
|
|
652
|
+
|
|
653
|
+
def autoplay_recording(path, target:)
|
|
654
|
+
@page.remove_service(@recording_playback) if @recording_playback
|
|
655
|
+
@recording_playback_generation = @recording_playback_generation.to_i + 1
|
|
656
|
+
generation = @recording_playback_generation
|
|
657
|
+
@recording_playback = @page.service(
|
|
658
|
+
:audio,
|
|
659
|
+
id: "ruflet_rails_recording_playback",
|
|
660
|
+
src: path,
|
|
661
|
+
autoplay: true,
|
|
662
|
+
release_mode: "stop",
|
|
663
|
+
on_state_change: lambda do |event|
|
|
664
|
+
next unless generation == @recording_playback_generation
|
|
665
|
+
|
|
666
|
+
data = event.respond_to?(:data) ? event.data : event
|
|
667
|
+
state = data.is_a?(Hash) ? (data["state"] || data[:state]) : data
|
|
668
|
+
message = case state.to_s
|
|
669
|
+
when "playing" then "Playing your recording…"
|
|
670
|
+
when "completed", "stopped" then "Recording saved and playback finished."
|
|
671
|
+
end
|
|
672
|
+
show_service_result(message, target: target) if message
|
|
673
|
+
end,
|
|
674
|
+
on_error: lambda do |event|
|
|
675
|
+
next unless generation == @recording_playback_generation
|
|
676
|
+
|
|
677
|
+
data = event.respond_to?(:data) ? event.data : event
|
|
678
|
+
show_service_result("Playback error: #{format_service_value(data)}",
|
|
679
|
+
title: "Audio playback failed", target: target)
|
|
680
|
+
end
|
|
681
|
+
)
|
|
682
|
+
show_service_result("Saved. Playing your recording…", title: "Audio recorder", target: target)
|
|
683
|
+
end
|
|
684
|
+
|
|
685
|
+
def toggle_sensor(type, action, spec)
|
|
686
|
+
type = type.tr("-", "_")
|
|
687
|
+
sensor = @page.service(
|
|
688
|
+
type,
|
|
689
|
+
key: "ruflet_rails_#{type}",
|
|
690
|
+
interval: spec["interval"],
|
|
691
|
+
cancel_on_error: spec["cancel-on-error"],
|
|
692
|
+
on_reading: lambda { |event|
|
|
693
|
+
show_service_result(
|
|
694
|
+
format_service_value(event_data(event)),
|
|
695
|
+
title: service_label(type),
|
|
696
|
+
target: spec["result-target"]
|
|
697
|
+
)
|
|
698
|
+
},
|
|
699
|
+
on_error: lambda { |event|
|
|
700
|
+
show_service_result(
|
|
701
|
+
format_service_value(event_data(event)),
|
|
702
|
+
title: "#{service_label(type)} failed",
|
|
703
|
+
target: spec["result-target"]
|
|
704
|
+
)
|
|
705
|
+
}
|
|
706
|
+
)
|
|
707
|
+
enabled = action == "start"
|
|
708
|
+
@page.update(sensor, enabled: enabled)
|
|
709
|
+
show_service_result(
|
|
710
|
+
"#{service_label(type)} #{enabled ? 'started' : 'stopped'}",
|
|
711
|
+
title: "Sensor",
|
|
712
|
+
target: spec["result-target"]
|
|
713
|
+
)
|
|
714
|
+
end
|
|
715
|
+
|
|
716
|
+
def event_data(event)
|
|
717
|
+
event.respond_to?(:data) ? event.data : event
|
|
718
|
+
end
|
|
719
|
+
|
|
720
|
+
def open_camera(spec)
|
|
721
|
+
request_token = @service_request_token
|
|
722
|
+
camera = target_control!(spec, default: "demo-camera")
|
|
723
|
+
result_target = spec["result-target"]
|
|
724
|
+
capture_target = spec["capture-target"]
|
|
725
|
+
preview_target = spec["preview-target"]
|
|
726
|
+
show_service_result("Checking available cameras…", title: "Camera", target: result_target)
|
|
727
|
+
@page.invoke(
|
|
728
|
+
camera,
|
|
729
|
+
"get_available_cameras",
|
|
730
|
+
# On a real device this is the call that raises the camera
|
|
731
|
+
# permission prompt, so it waits on the person, not the hardware.
|
|
732
|
+
timeout: interactive_timeout(spec),
|
|
733
|
+
on_result: lambda do |cameras, camera_error|
|
|
734
|
+
next unless current_service_request?(request_token)
|
|
735
|
+
|
|
736
|
+
if service_error?(camera_error)
|
|
737
|
+
show_service_result(camera_error, title: "Camera failed", target: result_target)
|
|
738
|
+
next
|
|
739
|
+
end
|
|
740
|
+
|
|
741
|
+
description = Array(cameras).first
|
|
742
|
+
unless description
|
|
743
|
+
show_service_result("No camera is available on this device.", title: "Camera",
|
|
744
|
+
target: result_target)
|
|
745
|
+
next
|
|
746
|
+
end
|
|
747
|
+
|
|
748
|
+
show_service_result("Initializing camera…", title: "Camera", target: result_target)
|
|
749
|
+
@page.invoke(
|
|
750
|
+
camera,
|
|
751
|
+
"initialize",
|
|
752
|
+
args: {
|
|
753
|
+
"description" => description,
|
|
754
|
+
"resolution_preset" => spec["resolution-preset"] || "medium",
|
|
755
|
+
"enable_audio" => truthy?(spec["enable-audio"]),
|
|
756
|
+
"image_format_group" => spec["image-format-group"] || "jpeg"
|
|
757
|
+
},
|
|
758
|
+
timeout: [service_timeout(spec), 180].max,
|
|
759
|
+
on_result: lambda do |_result, init_error|
|
|
760
|
+
next unless current_service_request?(request_token)
|
|
761
|
+
|
|
762
|
+
if service_error?(init_error)
|
|
763
|
+
show_service_result(init_error, title: "Camera initialization failed",
|
|
764
|
+
target: result_target)
|
|
765
|
+
else
|
|
766
|
+
if capture_target && (capture_button = @page.get_control(capture_target))
|
|
767
|
+
@page.update(capture_button, disabled: false)
|
|
768
|
+
end
|
|
769
|
+
if preview_target && (preview = @page.get_control(preview_target))
|
|
770
|
+
@page.update(preview, height: 320)
|
|
771
|
+
end
|
|
772
|
+
show_service_result("Camera initialized and preview is ready.", title: "Camera",
|
|
773
|
+
target: result_target)
|
|
774
|
+
end
|
|
775
|
+
end
|
|
776
|
+
)
|
|
777
|
+
end
|
|
778
|
+
)
|
|
779
|
+
end
|
|
780
|
+
|
|
781
|
+
def capture_camera_picture(spec)
|
|
782
|
+
request_token = @service_request_token
|
|
783
|
+
camera = target_control!(spec, default: "demo-camera")
|
|
784
|
+
result_target = spec["result-target"]
|
|
785
|
+
show_service_result("Taking picture…", title: "Camera", target: result_target)
|
|
786
|
+
@page.invoke(
|
|
787
|
+
camera,
|
|
788
|
+
"take_picture",
|
|
789
|
+
timeout: [service_timeout(spec), 45].max,
|
|
790
|
+
on_result: lambda do |result, error|
|
|
791
|
+
next unless current_service_request?(request_token)
|
|
792
|
+
|
|
793
|
+
if service_error?(error)
|
|
794
|
+
show_service_result(error, title: "Camera capture failed", target: result_target)
|
|
795
|
+
else
|
|
796
|
+
bytes = result.respond_to?(:bytesize) ? result.bytesize : Array(result).length
|
|
797
|
+
show_service_result("Captured #{bytes} bytes.", title: "Camera", target: result_target)
|
|
798
|
+
end
|
|
799
|
+
end
|
|
800
|
+
)
|
|
801
|
+
end
|
|
802
|
+
|
|
803
|
+
# Invoke a method on an inline extension control. Give the control an
|
|
804
|
+
# `id` in ERB and target it from a service button:
|
|
805
|
+
# <%= audio id: "player", src: "..." %>
|
|
806
|
+
# <%= button "Play", service: "control", target: "player", method: "play" %>
|
|
807
|
+
def invoke_target_control(spec)
|
|
808
|
+
target = spec["target"].to_s
|
|
809
|
+
method = spec["method"].to_s.tr("-", "_")
|
|
810
|
+
raise ArgumentError, "target is required" if target.empty?
|
|
811
|
+
raise ArgumentError, "method is required" if method.empty?
|
|
812
|
+
|
|
813
|
+
control = target_control!(spec)
|
|
814
|
+
|
|
815
|
+
args = service_args(spec)
|
|
816
|
+
if method == "update"
|
|
817
|
+
@page.update(control, **args.transform_keys(&:to_sym))
|
|
818
|
+
# Setting a property is not news. Opening a picker announced
|
|
819
|
+
# "Demo Date Picker updated", and with nowhere to put it that
|
|
820
|
+
# became a dialog on top of the picker the tap had just opened.
|
|
821
|
+
# Report only what the screen asked to be told.
|
|
822
|
+
if spec["toast"] || spec["result-target"]
|
|
823
|
+
show_service_result(spec["toast"].to_s, title: "Extension", target: spec["result-target"])
|
|
824
|
+
end
|
|
825
|
+
else
|
|
826
|
+
@page.invoke(
|
|
827
|
+
control, method, args: args.empty? ? nil : args,
|
|
828
|
+
timeout: service_timeout(spec), on_result: report(spec["toast"] || service_label(method), show_result: true)
|
|
829
|
+
)
|
|
830
|
+
end
|
|
831
|
+
end
|
|
832
|
+
|
|
833
|
+
def target_control!(spec, default: nil)
|
|
834
|
+
target = (spec["target"] || default).to_s
|
|
835
|
+
raise ArgumentError, "target is required" if target.empty?
|
|
836
|
+
|
|
837
|
+
control = @page.get_control(target)
|
|
838
|
+
raise ArgumentError, "control #{target.inspect} was not found" unless control
|
|
839
|
+
|
|
840
|
+
control
|
|
841
|
+
end
|
|
842
|
+
|
|
843
|
+
# Open-ended form for services that do not need a convenience alias:
|
|
844
|
+
# service="geolocator" method="get_current_position"
|
|
845
|
+
# service="secure-storage.get_all"
|
|
846
|
+
# Any non-metadata attributes (or an `args` JSON object) become invoke
|
|
847
|
+
# arguments. This keeps new Ruflet service methods usable without a
|
|
848
|
+
# ruflet_rails release for every method addition.
|
|
849
|
+
def invoke_generic_service(name, spec)
|
|
850
|
+
service_name, dotted_method = name.split(".", 2)
|
|
851
|
+
method = (spec["method"] || dotted_method).to_s.tr("-", "_")
|
|
852
|
+
raise ArgumentError, "unknown service #{name.inspect}" if method.empty?
|
|
853
|
+
|
|
854
|
+
invoke_named_service(
|
|
855
|
+
service_name.tr("-", "_"), method, service_args(spec),
|
|
856
|
+
spec["toast"] || service_label(method), show_result: true,
|
|
857
|
+
timeout: service_timeout(spec)
|
|
858
|
+
)
|
|
859
|
+
end
|
|
860
|
+
|
|
861
|
+
def invoke_named_service(type, method, args, label, show_result: false, timeout: 10)
|
|
862
|
+
control = @page.service(type)
|
|
863
|
+
@page.invoke(
|
|
864
|
+
control, method, args: args.nil? || args.empty? ? nil : args,
|
|
865
|
+
timeout: timeout, on_result: report(label, show_result: show_result)
|
|
866
|
+
)
|
|
867
|
+
end
|
|
868
|
+
|
|
869
|
+
def service_args(spec)
|
|
870
|
+
args = spec["args"].is_a?(Hash) ? spec["args"].dup : {}
|
|
871
|
+
spec.each do |key, value|
|
|
872
|
+
next if SERVICE_META_KEYS.include?(key)
|
|
873
|
+
next if CONTROL_TARGET_ATTRIBUTE.match?(key)
|
|
874
|
+
next if DECLARED_EVENT_ATTRIBUTE.match?(key)
|
|
875
|
+
next if value.nil?
|
|
876
|
+
|
|
877
|
+
args[key.tr("-", "_")] = coerce_service_value(value)
|
|
878
|
+
end
|
|
879
|
+
args
|
|
880
|
+
end
|
|
881
|
+
|
|
882
|
+
# Markup carries strings. A property being set on a control — open,
|
|
883
|
+
# modal, disabled — means the boolean, not the word.
|
|
884
|
+
def coerce_service_value(value)
|
|
885
|
+
case value
|
|
886
|
+
when "true" then true
|
|
887
|
+
when "false" then false
|
|
888
|
+
else value
|
|
889
|
+
end
|
|
890
|
+
end
|
|
891
|
+
|
|
892
|
+
def service_timeout(spec)
|
|
893
|
+
value = spec["timeout"]
|
|
894
|
+
value.nil? || value.to_s.empty? ? 10 : value.to_f
|
|
895
|
+
end
|
|
896
|
+
|
|
897
|
+
# Anything that puts a system dialog on screen — a permission prompt, a
|
|
898
|
+
# file picker, a save sheet — waits on a person, not on the device, so
|
|
899
|
+
# the 10s default expires while they are still reading it.
|
|
900
|
+
#
|
|
901
|
+
# But a long ceiling only helps the case where an answer is coming. If
|
|
902
|
+
# the plugin is missing or never replies, every extra second is dead
|
|
903
|
+
# time the user spends staring at a screen that will fail anyway. 30s
|
|
904
|
+
# covers answering a dialog without turning a broken call into a
|
|
905
|
+
# half-minute hang; markup can raise it with `timeout=` where a flow
|
|
906
|
+
# genuinely needs longer.
|
|
907
|
+
INTERACTIVE_TIMEOUT = 30
|
|
908
|
+
|
|
909
|
+
# An interactive call waits on a person answering a permission prompt,
|
|
910
|
+
# so a declared timeout may lengthen it but never shorten it below the
|
|
911
|
+
# human floor. A screen asking for 5s meant "the hardware should be
|
|
912
|
+
# quick", and it silently expired the camera prompt before anyone could
|
|
913
|
+
# reach it.
|
|
914
|
+
def interactive_timeout(spec)
|
|
915
|
+
declared = spec["timeout"]
|
|
916
|
+
return INTERACTIVE_TIMEOUT if declared.nil? || declared.to_s.empty?
|
|
917
|
+
|
|
918
|
+
[declared.to_f, INTERACTIVE_TIMEOUT].max
|
|
919
|
+
end
|
|
920
|
+
|
|
921
|
+
# HTML attributes are text, but the client's file APIs want bytes.
|
|
922
|
+
# Msgpack sends a binary-encoded String as `bin`, which arrives as the
|
|
923
|
+
# Uint8List those APIs declare; a UTF-8 String arrives as a Dart String
|
|
924
|
+
# and fails their cast.
|
|
925
|
+
def binary_bytes(value)
|
|
926
|
+
return nil if value.nil?
|
|
927
|
+
return value if value.respond_to?(:encoding) && value.encoding == Encoding::BINARY
|
|
928
|
+
|
|
929
|
+
value.to_s.dup.force_encoding(Encoding::BINARY)
|
|
930
|
+
end
|
|
931
|
+
|
|
932
|
+
def truthy?(value)
|
|
933
|
+
value == true || %w[1 true yes on].include?(value.to_s.downcase)
|
|
934
|
+
end
|
|
935
|
+
|
|
936
|
+
def array_value(value)
|
|
937
|
+
return value if value.is_a?(Array)
|
|
938
|
+
return [] if value.nil? || value.to_s.empty?
|
|
939
|
+
|
|
940
|
+
value.to_s.split(",").map(&:strip).reject(&:empty?)
|
|
941
|
+
end
|
|
942
|
+
|
|
943
|
+
def service_label(value)
|
|
944
|
+
value.to_s.tr("_.-", " ").split.map(&:capitalize).join(" ")
|
|
945
|
+
end
|
|
946
|
+
|
|
947
|
+
def run_haptic(style)
|
|
948
|
+
case style.to_s.downcase
|
|
949
|
+
when "heavy" then @page.heavy_impact
|
|
950
|
+
when "medium" then @page.medium_impact
|
|
951
|
+
when "light" then @page.light_impact
|
|
952
|
+
when "vibrate" then @page.vibrate
|
|
953
|
+
else @page.selection_click
|
|
954
|
+
end
|
|
955
|
+
end
|
|
956
|
+
|
|
957
|
+
def toggle_flashlight
|
|
958
|
+
@flashlight_on = !@flashlight_on
|
|
959
|
+
label = @flashlight_on ? "Torch on" : "Torch off"
|
|
960
|
+
if @flashlight_on
|
|
961
|
+
@page.flashlight.on(on_result: report(label))
|
|
962
|
+
else
|
|
963
|
+
@page.flashlight.off(on_result: report(label))
|
|
964
|
+
end
|
|
965
|
+
end
|
|
966
|
+
|
|
967
|
+
def toggle_wakelock
|
|
968
|
+
@wakelock_on = !@wakelock_on
|
|
969
|
+
label = @wakelock_on ? "Staying awake" : "Sleep allowed"
|
|
970
|
+
if @wakelock_on
|
|
971
|
+
@page.wakelock.enable(on_result: report(label))
|
|
972
|
+
else
|
|
973
|
+
@page.wakelock.disable(on_result: report(label))
|
|
974
|
+
end
|
|
975
|
+
end
|
|
976
|
+
|
|
977
|
+
# Chained battery read: level, then charging state, reported together.
|
|
978
|
+
def read_battery
|
|
979
|
+
request_token = @service_request_token
|
|
980
|
+
result_target = @service_result_target
|
|
981
|
+
@page.get_battery_level(on_result: lambda do |*level_out|
|
|
982
|
+
next unless current_service_request?(request_token)
|
|
983
|
+
|
|
984
|
+
level, level_error = level_out
|
|
985
|
+
next show_service_result(level_error, title: "Battery failed", target: result_target) if service_error?(level_error)
|
|
986
|
+
|
|
987
|
+
@page.get_battery_state(on_result: lambda do |*state_out|
|
|
988
|
+
next unless current_service_request?(request_token)
|
|
989
|
+
|
|
990
|
+
state, state_error = state_out
|
|
991
|
+
next show_service_result(state_error, title: "Battery failed", target: result_target) if service_error?(state_error)
|
|
992
|
+
|
|
993
|
+
@page.is_in_battery_save_mode(on_result: lambda do |*save_out|
|
|
994
|
+
next unless current_service_request?(request_token)
|
|
995
|
+
|
|
996
|
+
save_mode, save_error = save_out
|
|
997
|
+
next show_service_result(save_error, title: "Battery failed", target: result_target) if service_error?(save_error)
|
|
998
|
+
|
|
999
|
+
level_label = level.nil? ? "Unknown" : "#{level}%"
|
|
1000
|
+
state_label = state.to_s.empty? ? "unknown" : state.to_s.upcase
|
|
1001
|
+
saver_label = save_mode ? "ON" : "OFF"
|
|
1002
|
+
show_service_result(
|
|
1003
|
+
"Battery level: #{level_label}\nBattery state: #{state_label}\nBattery saver: #{saver_label}",
|
|
1004
|
+
title: "Battery", target: result_target
|
|
1005
|
+
)
|
|
1006
|
+
end)
|
|
1007
|
+
end)
|
|
1008
|
+
end)
|
|
1009
|
+
end
|
|
1010
|
+
|
|
1011
|
+
def secure_storage
|
|
1012
|
+
reuse_service(:secure_storage, "ruflet_rails_secure_storage")
|
|
1013
|
+
end
|
|
1014
|
+
|
|
1015
|
+
def permissions
|
|
1016
|
+
reuse_service(:permission_handler, "ruflet_rails_permissions")
|
|
1017
|
+
end
|
|
1018
|
+
|
|
1019
|
+
# Build an `on_result` callback that reports the outcome in a native
|
|
1020
|
+
# dialog. Native results arrive as `(value, error)`; a variadic lambda
|
|
1021
|
+
# copes with methods that pass only a value.
|
|
1022
|
+
def report(label, show_result: false)
|
|
1023
|
+
request_token = @service_request_token
|
|
1024
|
+
result_target = @service_result_target
|
|
1025
|
+
lambda do |*out|
|
|
1026
|
+
next unless current_service_request?(request_token)
|
|
1027
|
+
|
|
1028
|
+
value, error = out
|
|
1029
|
+
if service_error?(error)
|
|
1030
|
+
show_service_result(error, title: "#{label} failed", target: result_target)
|
|
1031
|
+
elsif show_result
|
|
1032
|
+
show_service_result(format_service_value(value), title: label, target: result_target)
|
|
1033
|
+
else
|
|
1034
|
+
show_service_result("Completed successfully.", title: label, target: result_target)
|
|
1035
|
+
end
|
|
1036
|
+
end
|
|
1037
|
+
end
|
|
1038
|
+
|
|
1039
|
+
def service_error?(error)
|
|
1040
|
+
!error.nil? && !error.to_s.empty?
|
|
1041
|
+
end
|
|
1042
|
+
|
|
1043
|
+
def format_service_value(value)
|
|
1044
|
+
value = value.data if value.respond_to?(:data)
|
|
1045
|
+
case value
|
|
1046
|
+
when nil then "(none)"
|
|
1047
|
+
when Hash then value.map { |k, v| "#{k}: #{v}" }.join(", ")
|
|
1048
|
+
when Array then value.map(&:to_s).join(", ")
|
|
1049
|
+
else value.to_s.empty? ? "(empty)" : value.to_s
|
|
1050
|
+
end
|
|
1051
|
+
end
|
|
1052
|
+
|
|
1053
|
+
def clamp_unit(value, default)
|
|
1054
|
+
return default if value.nil? || value.to_s.strip.empty?
|
|
1055
|
+
|
|
1056
|
+
value.to_f.clamp(0.0, 1.0)
|
|
1057
|
+
end
|
|
1058
|
+
|
|
1059
|
+
def show_service_result(message, title: "Service result", target: nil)
|
|
1060
|
+
message = message.to_s
|
|
1061
|
+
return if message.empty?
|
|
1062
|
+
|
|
1063
|
+
# A targeted result updates its status control in place — the Studio
|
|
1064
|
+
# model. Crucially, when a target was requested but the control is
|
|
1065
|
+
# gone (the user navigated away before this async result arrived), we
|
|
1066
|
+
# drop it silently. Falling back to the dialog here is exactly what
|
|
1067
|
+
# makes a stray "…failed/expired" alert pop up on an unrelated screen.
|
|
1068
|
+
if target
|
|
1069
|
+
control = @page.get_control(target.to_s)
|
|
1070
|
+
@page.update(control, value: message) if control
|
|
1071
|
+
return
|
|
1072
|
+
end
|
|
1073
|
+
|
|
1074
|
+
ensure_service_dialog
|
|
1075
|
+
if @service_dialog.wire_id
|
|
1076
|
+
@page.update(@service_dialog_title, value: title.to_s)
|
|
1077
|
+
@page.update(@service_dialog_content, value: message)
|
|
1078
|
+
# Ruflet Studio mounts one dialog and toggles its open property.
|
|
1079
|
+
# Keeping the same control prevents delayed responses from stacking.
|
|
1080
|
+
@page.update(@service_dialog, open: true)
|
|
1081
|
+
else
|
|
1082
|
+
@service_dialog_title.props["value"] = title.to_s
|
|
1083
|
+
@service_dialog_content.props["value"] = message
|
|
1084
|
+
@page.show_dialog(@service_dialog)
|
|
1085
|
+
end
|
|
1086
|
+
rescue StandardError
|
|
1087
|
+
nil
|
|
1088
|
+
end
|
|
1089
|
+
|
|
1090
|
+
def close_service_dialog
|
|
1091
|
+
return unless @service_dialog
|
|
1092
|
+
|
|
1093
|
+
@service_request_token += 1
|
|
1094
|
+
# The dialog is opened with show_dialog, so it is dismissed with
|
|
1095
|
+
# close_dialog. Setting open: false is the other mechanism, and it
|
|
1096
|
+
# left the alert on screen with a Close button that did nothing.
|
|
1097
|
+
@page.close_dialog(@service_dialog)
|
|
1098
|
+
rescue StandardError
|
|
1099
|
+
nil
|
|
1100
|
+
end
|
|
1101
|
+
|
|
1102
|
+
def ensure_service_dialog
|
|
1103
|
+
return @service_dialog if @service_dialog
|
|
1104
|
+
|
|
1105
|
+
@service_dialog_title = text("")
|
|
1106
|
+
@service_dialog_content = text("", selectable: true)
|
|
1107
|
+
@service_dialog = alert_dialog(
|
|
1108
|
+
adaptive: true,
|
|
1109
|
+
modal: true,
|
|
1110
|
+
scrollable: true,
|
|
1111
|
+
open: false,
|
|
1112
|
+
title: @service_dialog_title,
|
|
1113
|
+
content: @service_dialog_content,
|
|
1114
|
+
actions: [
|
|
1115
|
+
text_button(
|
|
1116
|
+
content: text("Close"),
|
|
1117
|
+
on_click: ->(_event) { close_service_dialog }
|
|
1118
|
+
)
|
|
1119
|
+
],
|
|
1120
|
+
on_dismiss: ->(_event) { close_service_dialog }
|
|
1121
|
+
)
|
|
1122
|
+
end
|
|
1123
|
+
|
|
1124
|
+
def current_service_request?(token)
|
|
1125
|
+
token == @service_request_token
|
|
1126
|
+
end
|
|
1127
|
+
|
|
1128
|
+
# In-place re-render (an action or form submit on the current screen).
|
|
1129
|
+
# Only the parts that actually changed are patched: re-sending an
|
|
1130
|
+
# identical app bar / nav / FAB makes the client rebuild that widget
|
|
1131
|
+
# and flicker, so we diff each against the last render first.
|
|
1132
|
+
def rerender(screen, response)
|
|
1133
|
+
# Re-rendering a screen means parsing its markup and rebuilding the
|
|
1134
|
+
# whole control tree, which costs more than the ERB render that
|
|
1135
|
+
# produced it. When an action leaves the screen byte-identical —
|
|
1136
|
+
# a tap that changed nothing the screen shows — none of that work can
|
|
1137
|
+
# change anything, and neither can the diff it feeds.
|
|
1138
|
+
markup = response.body.to_s
|
|
1139
|
+
return if screen.view&.wire_id && screen.markup && screen.markup == markup
|
|
1140
|
+
|
|
1141
|
+
screen.markup = markup
|
|
1142
|
+
result = render_result(screen, response)
|
|
1143
|
+
mount_services(result)
|
|
1144
|
+
unless screen.view&.wire_id
|
|
1145
|
+
screen.view = build_view(screen, result)
|
|
1146
|
+
remember_chrome(screen, result)
|
|
1147
|
+
flush
|
|
1148
|
+
return
|
|
1149
|
+
end
|
|
1150
|
+
|
|
1151
|
+
new_body = body_for(result)
|
|
1152
|
+
patch = {}
|
|
1153
|
+
appbar = appbar_for(screen, result)
|
|
1154
|
+
patch[:appbar] = appbar if chrome_changed?(screen, :appbar_sig, appbar)
|
|
1155
|
+
patch[:floating_action_button] = result.fab if chrome_changed?(screen, :fab_sig, result.fab)
|
|
1156
|
+
patch[:navigation_bar] = result.bottom_nav if chrome_changed?(screen, :nav_sig, result.bottom_nav)
|
|
1157
|
+
|
|
1158
|
+
# Most re-renders change a value, not a shape: a counter ticks, a
|
|
1159
|
+
# status line fills in, a list item flips. Replacing the whole body
|
|
1160
|
+
# would re-send every control on the screen to move one string, and
|
|
1161
|
+
# would make the client rebuild the subtree (losing scroll position
|
|
1162
|
+
# with it). When the new tree has the same shape as the mounted one,
|
|
1163
|
+
# send only the props that actually differ.
|
|
1164
|
+
updates = ControlDiff.new(screen.body, new_body).updates if screen.body
|
|
1165
|
+
if updates
|
|
1166
|
+
@page.update(screen.view, **patch) unless patch.empty?
|
|
1167
|
+
updates.each { |control, props| @page.update(control, **props) }
|
|
1168
|
+
else
|
|
1169
|
+
screen.body = new_body
|
|
1170
|
+
@page.update(screen.view, controls: [new_body], **patch)
|
|
1171
|
+
end
|
|
1172
|
+
end
|
|
1173
|
+
|
|
1174
|
+
# Record the current chrome signatures so the next in-place re-render
|
|
1175
|
+
# can tell what changed.
|
|
1176
|
+
def remember_chrome(screen, result)
|
|
1177
|
+
screen.appbar_sig = chrome_sig(appbar_for(screen, result))
|
|
1178
|
+
screen.fab_sig = chrome_sig(result.fab)
|
|
1179
|
+
screen.nav_sig = chrome_sig(result.bottom_nav)
|
|
1180
|
+
end
|
|
1181
|
+
|
|
1182
|
+
def chrome_changed?(screen, field, control)
|
|
1183
|
+
sig = chrome_sig(control)
|
|
1184
|
+
return false if screen[field] == sig
|
|
1185
|
+
|
|
1186
|
+
screen[field] = sig
|
|
1187
|
+
true
|
|
1188
|
+
end
|
|
1189
|
+
|
|
1190
|
+
def chrome_sig(control)
|
|
1191
|
+
control ? JSON.generate(control.to_patch) : nil
|
|
1192
|
+
rescue StandardError
|
|
1193
|
+
control.object_id.to_s
|
|
1194
|
+
end
|
|
1195
|
+
|
|
1196
|
+
def render_result(screen, response)
|
|
1197
|
+
screen.url = response.url.to_s unless response.url.to_s.empty?
|
|
1198
|
+
screen.fields = {}
|
|
1199
|
+
body = response.body.to_s
|
|
1200
|
+
# Strings sliced from a binary body would go over the wire as
|
|
1201
|
+
# msgpack bin (byte arrays on the client); make sure we parse UTF-8.
|
|
1202
|
+
body = body.dup.force_encoding(Encoding::UTF_8) if body.encoding == Encoding::ASCII_8BIT
|
|
1203
|
+
result = transform(screen, body)
|
|
1204
|
+
screen.title = result.title
|
|
1205
|
+
if result.controls.empty?
|
|
1206
|
+
result.controls = [container(content: text("Empty screen"), alignment: { x: 0, y: 0 }, expand: true)]
|
|
1207
|
+
end
|
|
1208
|
+
result
|
|
1209
|
+
end
|
|
1210
|
+
|
|
1211
|
+
def transform(screen, body)
|
|
1212
|
+
transformer = Transformer.new(handlers: ScreenHandlers.new(self, screen),
|
|
1213
|
+
asset_base_url: asset_base_url)
|
|
1214
|
+
transformer.transform(body)
|
|
1215
|
+
rescue StandardError => e
|
|
1216
|
+
transformer.transform(error_markup(screen.url, e))
|
|
1217
|
+
end
|
|
1218
|
+
|
|
1219
|
+
# Media is the one thing the client fetches for itself, so it is the one
|
|
1220
|
+
# thing that still needs a host. Resolved per render, because the answer
|
|
1221
|
+
# depends on the connection this session is running on.
|
|
1222
|
+
def asset_base_url
|
|
1223
|
+
return nil unless defined?(::Ruflet::Rails.backend_url)
|
|
1224
|
+
|
|
1225
|
+
::Ruflet::Rails.backend_url
|
|
1226
|
+
rescue StandardError
|
|
1227
|
+
nil
|
|
1228
|
+
end
|
|
1229
|
+
|
|
1230
|
+
|
|
1231
|
+
|
|
1232
|
+
def build_view(screen, result)
|
|
1233
|
+
args = { route: screen.route }
|
|
1234
|
+
appbar = appbar_for(screen, result)
|
|
1235
|
+
args[:appbar] = appbar if appbar
|
|
1236
|
+
args[:floating_action_button] = result.fab if result.fab
|
|
1237
|
+
args[:navigation_bar] = result.bottom_nav if result.bottom_nav
|
|
1238
|
+
# Kept so the next in-place re-render can diff against what is
|
|
1239
|
+
# actually mounted instead of replacing it.
|
|
1240
|
+
screen.body = body_for(result)
|
|
1241
|
+
view([screen.body], **args)
|
|
1242
|
+
end
|
|
1243
|
+
|
|
1244
|
+
# Screens scroll by default, like a web page. But a screen whose
|
|
1245
|
+
# top-level control expands (flex-1 / h-full, e.g. a vertically
|
|
1246
|
+
# centered layout) needs the fixed viewport instead: an expanding
|
|
1247
|
+
# child inside a scrollable has unbounded height and collapses to an
|
|
1248
|
+
# empty body.
|
|
1249
|
+
def body_for(result)
|
|
1250
|
+
expanding = result.controls.any? { |control| control.respond_to?(:props) && control.props["expand"] }
|
|
1251
|
+
props = { expand: true, spacing: 0 }
|
|
1252
|
+
props[:scroll] = "auto" unless expanding
|
|
1253
|
+
column(result.controls, **props)
|
|
1254
|
+
end
|
|
1255
|
+
|
|
1256
|
+
# Declared `<appbar>` wins. A pushed screen without one still gets a
|
|
1257
|
+
# titled AppBar so the native back button exists; the root only gets
|
|
1258
|
+
# one when the app asked for a title.
|
|
1259
|
+
def appbar_for(screen, result)
|
|
1260
|
+
return result.appbar if result.appbar
|
|
1261
|
+
|
|
1262
|
+
title = screen.root ? (@title || "") : (result.title || @title || title_from_url(screen.url))
|
|
1263
|
+
return nil if title.to_s.strip.empty?
|
|
1264
|
+
|
|
1265
|
+
appbar(title: text(title.to_s))
|
|
1266
|
+
end
|
|
1267
|
+
|
|
1268
|
+
def flush
|
|
1269
|
+
@page.views = @screens.map(&:view)
|
|
1270
|
+
@page.update
|
|
1271
|
+
end
|
|
1272
|
+
|
|
1273
|
+
# --- HTTP -----------------------------------------------------------------
|
|
1274
|
+
|
|
1275
|
+
def request(url, params: nil)
|
|
1276
|
+
@fetcher.fetch(url, params: params)
|
|
1277
|
+
rescue StandardError => e
|
|
1278
|
+
TemplateSource::Response.new(body: error_markup(url, e), url: url)
|
|
1279
|
+
end
|
|
1280
|
+
|
|
1281
|
+
def error_markup(url, error)
|
|
1282
|
+
<<~HTML
|
|
1283
|
+
<column class="p-6 gap-2">
|
|
1284
|
+
<h3>Could not load screen</h3>
|
|
1285
|
+
<text class="text-slate-500">#{url}</text>
|
|
1286
|
+
<text class="text-red-600">#{error.message}</text>
|
|
1287
|
+
</column>
|
|
1288
|
+
HTML
|
|
1289
|
+
end
|
|
1290
|
+
|
|
1291
|
+
# Screens may be declared as paths ("/native") or as full URLs. A path
|
|
1292
|
+
# stays a path: the fetcher supplies the host from the live WebSocket
|
|
1293
|
+
# connection, so nothing here has to name one. Relative links still
|
|
1294
|
+
# resolve against the current screen either way — done by borrowing a
|
|
1295
|
+
# placeholder host, since URI.join needs an absolute base, and then
|
|
1296
|
+
# dropping it again.
|
|
1297
|
+
PATH_RESOLUTION_BASE = "http://ruflet.invalid"
|
|
1298
|
+
|
|
1299
|
+
def absolute_url(url)
|
|
1300
|
+
url = url.to_s
|
|
1301
|
+
return "" if url.empty?
|
|
1302
|
+
return url if URI.parse(url).absolute?
|
|
1303
|
+
|
|
1304
|
+
base = current_url
|
|
1305
|
+
return URI.join(base, url).to_s if URI.parse(base).absolute?
|
|
1306
|
+
|
|
1307
|
+
resolved = URI.join("#{PATH_RESOLUTION_BASE}#{base}", url)
|
|
1308
|
+
resolved.query ? "#{resolved.path}?#{resolved.query}" : resolved.path
|
|
1309
|
+
rescue URI::InvalidURIError, URI::BadURIError
|
|
1310
|
+
url
|
|
1311
|
+
end
|
|
1312
|
+
|
|
1313
|
+
def current_url
|
|
1314
|
+
@screens.last&.url || @start_url
|
|
1315
|
+
end
|
|
1316
|
+
|
|
1317
|
+
def title_from_url(url)
|
|
1318
|
+
path = URI.parse(url.to_s).path.to_s
|
|
1319
|
+
label = path.split("/").reject(&:empty?).last.to_s
|
|
1320
|
+
label = "Screen" if label.empty?
|
|
1321
|
+
label.tr("_-", " ").split.map(&:capitalize).join(" ")
|
|
1322
|
+
rescue URI::InvalidURIError
|
|
1323
|
+
"Screen"
|
|
1324
|
+
end
|
|
1325
|
+
end
|
|
1326
|
+
end
|
|
1327
|
+
end
|
|
1328
|
+
end
|