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.
@@ -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