Module: Ruflet::Rails

Defined in:
lib/ruflet/rails.rb,
lib/ruflet/rails.rb,
lib/ruflet/rails/assets.rb,
lib/ruflet/rails/railtie.rb,
lib/ruflet/rails/native_app.rb,
lib/ruflet/rails/route_stack.rb,
lib/ruflet/rails/webview_app.rb,
lib/ruflet/rails/form_helpers.rb,
lib/ruflet/rails/view_helpers.rb,
lib/ruflet/rails/configuration.rb,
lib/ruflet/rails/install_support.rb,
lib/ruflet/rails/protocol/runner.rb,
lib/ruflet/rails/desktop_launcher.rb,
lib/ruflet/rails/protocol/context.rb,
lib/ruflet/rails/protocol/web_app.rb,
lib/ruflet/rails/session_registry.rb,
lib/ruflet/rails/protocol/endpoint.rb,
lib/ruflet/rails/resource_component.rb,
lib/ruflet/rails/protocol/middleware.rb,
lib/ruflet/rails/protocol/wire_codec.rb,
lib/ruflet/rails/protocol/local_server.rb,
lib/ruflet/rails/protocol/mobile_loader.rb,
lib/ruflet/rails/protocol/web_app_endpoint.rb,
lib/ruflet/rails/protocol/websocket_detection.rb,
lib/ruflet/rails/protocol/web_socket_connection.rb

Defined Under Namespace

Modules: DesktopLauncher, FormHelpers, InstallSupport, Protocol, ViewHelpers Classes: Configuration, NativeApp, Railtie, ResourceComponent, RouteStack, Session, SessionRegistry, ViewRouter

Class Method Summary collapse

Class Method Details

.app(file_path) ⇒ Object

Backward-compatible shorthand for a standalone app-file mobile endpoint.

match "/ws", to: Ruflet::Rails.app(Rails.root.join("app/ruflet/main.rb")), via: :all


105
106
107
# File 'lib/ruflet/rails.rb', line 105

def app(file_path)
  endpoint(app_file: file_path)
end

.asset_url(source, host: nil) ⇒ Object



43
44
45
46
47
48
49
50
51
52
# File 'lib/ruflet/rails/assets.rb', line 43

def asset_url(source, host: nil)
  raw = source.to_s
  return raw if absolute_url?(raw)

  path = asset_pipeline_path(raw)
  return path if absolute_url?(path)

  base = backend_url(host: host)
  base.empty? ? path : "#{base}#{path}"
end

.backend_url(host: nil) ⇒ Object

The base URL the Flutter client uses to reach this Rails app — the single source of truth for asset URLs, the build-time RUFLET_URL define and the desktop launcher. A Rails Ruflet app always needs one, so this always resolves to a usable value:

1. an explicit host: argument
2. Ruflet::Rails.config.backend_url (set it in config/initializers/ruflet.rb)
3. the host the client connected on (the live WebSocket request)

Returns “” only when none of those are available (e.g. a build with no configured backend_url) — set config.backend_url to cover that case.



37
38
39
40
41
# File 'lib/ruflet/rails/assets.rb', line 37

def backend_url(host: nil)
  candidate = host || config.backend_url
  candidate = request_base_url if candidate.to_s.strip.empty?
  candidate.to_s.strip.sub(%r{/+\z}, "")
end

.broadcast(&block) ⇒ Object



64
65
66
# File 'lib/ruflet/rails.rb', line 64

def broadcast(&block)
  sessions.broadcast(&block)
end

.configObject

Returns the global configuration object.



10
11
12
# File 'lib/ruflet/rails.rb', line 10

def config
  @config ||= Configuration.new
end

.configure {|config| ... } ⇒ Object

Yields the configuration object for block-style setup.

Ruflet::Rails.configure do |c|
  c.app_file = Rails.root.join("app/views/ruflet/main.rb")
  c.ws_path  = "/ws"
end

Yields:



20
21
22
# File 'lib/ruflet/rails.rb', line 20

def configure
  yield config
end

.endpoint(view: nil, app_file: nil, &block) ⇒ Object

WebSocket endpoint for native mobile/desktop clients. The developer declares the entry the same way they declare a web mount — so the screens the app shows live in dev code, not in framework auto-discovery:

# a standalone Ruflet app file (Ruflet.run/MyApp.new.run), per session:
match "/ws", to: Ruflet::Rails.endpoint(app_file: Rails.root.join("app/ruflet/main.rb")), via: :all

# a single component/view class (resolved lazily, so reloading works):
match "/ws", to: Ruflet::Rails.endpoint(view: "ProductComponent"), via: :all

# a custom block:
match "/ws", to: Ruflet::Rails.endpoint { |page| MyHome.render(page) }, via: :all

With nothing declared it falls back to the convenience view router, which auto-discovers RufletView subclasses and renders a route index. That index is a framework fallback for the zero-config case — declare an entry above to own the home screen in your code.

Raises:

  • (ArgumentError)


85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
# File 'lib/ruflet/rails.rb', line 85

def endpoint(view: nil, app_file: nil, &block)
  sources = [view, app_file, block].compact
  raise ArgumentError, "endpoint accepts only one of view:, app_file:, or a block" if sources.length > 1

  return Protocol::Runner.new.build_app_endpoint(file_path: app_file) if app_file

  entry =
    if block
      block
    elsif view
      web_app_entrypoint(view: view)
    else
      ->(page) { render(page) }
    end
  Protocol::Runner.new(&entry).build_endpoint
end

.image_url(source, host: nil) ⇒ Object

Readability alias for image sources — identical resolution.



55
56
57
# File 'lib/ruflet/rails/assets.rb', line 55

def image_url(source, host: nil)
  asset_url(source, host: host)
end

.load_views(root) ⇒ Object



49
50
51
52
53
54
55
56
57
58
# File 'lib/ruflet/rails.rb', line 49

def load_views(root)
  return [] if root.to_s.empty?

  files = Dir[File.join(root.to_s, "components", "**", "*.rb")].sort
  files += Dir[File.join(root.to_s, "**", "*_view.rb")].sort

  files.each do |file|
    Kernel.load(file)
  end
end

.mobile(file_path) ⇒ Object

Backward-compatible alias for older Rails installs.



110
111
112
# File 'lib/ruflet/rails.rb', line 110

def mobile(file_path)
  app(file_path)
end

.native_app(page, **opts) ⇒ Object

Start a Hotwire Native-style app. See NativeApp.



249
250
251
# File 'lib/ruflet/rails/native_app.rb', line 249

def native_app(page, **opts)
  NativeApp.new(page, **opts).start
end

.register_view(view_class) ⇒ Object



28
29
30
31
# File 'lib/ruflet/rails.rb', line 28

def register_view(view_class)
  view_classes << view_class unless view_classes.include?(view_class)
  view_class
end

.render(page, routes: nil, default: nil) ⇒ Object



33
34
35
# File 'lib/ruflet/rails.rb', line 33

def render(page, routes: nil, default: nil)
  ViewRouter.new(page, routes: routes, default: default).start
end

.routed(page, &builder) ⇒ Object

Flet-style routed navigation stack for complex multi-screen apps. Wires up on_route_change / on_view_pop and starts at the current route. See Ruflet::Rails::RouteStack.

Ruflet::Rails.routed(page) do |route, nav|
  nav.push(home_view)
  nav.push(store_view) if route == "/store"
end


45
46
47
# File 'lib/ruflet/rails.rb', line 45

def routed(page, &builder)
  RouteStack.new(page, &builder).start
end

.sessionsObject



60
61
62
# File 'lib/ruflet/rails.rb', line 60

def sessions
  @sessions ||= SessionRegistry.new
end

.view_class_for(view) ⇒ Object

Resolved lazily on each session so Rails code reloading picks up edits.



163
164
165
166
167
# File 'lib/ruflet/rails.rb', line 163

def view_class_for(view)
  return view if view.is_a?(Class)

  view.to_s.constantize
end

.view_classesObject



24
25
26
# File 'lib/ruflet/rails.rb', line 24

def view_classes
  @view_classes ||= []
end

.web(build:) ⇒ Object

Serves the pre-built Flutter web build’s index.html at the given route. Static assets (JS, WASM, fonts) are served by ActionDispatch::Static.

Usage in routes.rb:

get "/showcase", to: Ruflet::Rails.web(build: Rails.root.join("public/showcase"))


119
120
121
# File 'lib/ruflet/rails.rb', line 119

def web(build:)
  Protocol::WebAppEndpoint.new(build_dir: build.to_s)
end

.web_app(view: nil, app_file: nil, build_dir: nil, &app_block) ⇒ Object

Self-contained web frontend, mountable under any route. Serves the Flutter web build (with <base href> rewritten to the mount point) and answers the Ruflet WebSocket on the same path. Routes stay routing-only; UI code lives in app/views/ruflet:

# all registered RufletView subclasses behind the view router:
mount Ruflet::Rails.web_app, at: "/app"

# a single view class (resolved lazily, so reloading works):
mount Ruflet::Rails.web_app(view: "CounterView"), at: "/myfrontend"

# a standalone Ruflet app file (MyApp.new.run), loaded per session:
mount Ruflet::Rails.web_app(app_file: "app/ruflet/showcase/main.rb"), at: "/showcase"

Raises:

  • (ArgumentError)


136
137
138
139
140
141
142
143
144
145
# File 'lib/ruflet/rails.rb', line 136

def web_app(view: nil, app_file: nil, build_dir: nil, &app_block)
  sources = [view, app_file, app_block].compact
  raise ArgumentError, "web_app accepts only one of view:, app_file:, or a block" if sources.length > 1

  Protocol::WebApp.new(
    build_dir: build_dir,
    entrypoint: web_app_entrypoint(view: view, app_file: app_file),
    &app_block
  )
end

.web_app_entrypoint(view: nil, app_file: nil) ⇒ Object



147
148
149
150
151
152
153
154
155
156
157
158
159
160
# File 'lib/ruflet/rails.rb', line 147

def web_app_entrypoint(view: nil, app_file: nil)
  if view
    lambda do |page|
      view_class_for(view).render(page)
    end
  elsif app_file
    absolute = app_file.to_s
    lambda do |page, env|
      loaded = Protocol::MobileLoader.new(File.expand_path(absolute)).load!
      entry = loaded[:entrypoint]
      entry.arity == 1 ? entry.call(page) : entry.call(page, env)
    end
  end
end

.web_html(request) ⇒ Object

Reads index.html from the web build dir, injects window.RUFLET_URL from config.backend_url so the Flutter client knows the WS backend without a rebuild. Falls back to the request base_url if not configured.

Usage in a Rails controller:

render html: Ruflet::Rails.web_html(request), layout: false


175
176
177
178
179
180
181
182
# File 'lib/ruflet/rails.rb', line 175

def web_html(request)
  build_dir = config.web_build_dir.to_s
  html      = File.read(File.join(build_dir, "index.html"))
  url       = config.backend_url.to_s.strip
  url       = request.base_url if url.empty?
  script    = "<script>window.__RUFLET_URL__=#{url.to_json};</script>"
  html.include?("</head>") ? html.sub("</head>", "#{script}</head>") : "#{script}#{html}"
end

.webview_app(url:, appbar: nil, navigation_bar: nil, bottom_appbar: nil, route: "/", prevent_links: nil, on_navigate: nil, on_page_started: nil, on_page_ended: nil, **webview_props) {|body| ... } ⇒ Object

Yields:

  • (body)


33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
# File 'lib/ruflet/rails/webview_app.rb', line 33

def webview_app(url:, appbar: nil, navigation_bar: nil, bottom_appbar: nil,
                route: "/", prevent_links: nil, on_navigate: nil,
                on_page_started: nil, on_page_ended: nil, **webview_props)
  webview_args = { url: url, expand: true }
  webview_args[:prevent_links] = prevent_links unless prevent_links.nil?
  webview_args[:on_url_change] = ->(event) { on_navigate.call(event.data) } if on_navigate
  webview_args[:on_page_started] = on_page_started if on_page_started
  webview_args[:on_page_ended] = on_page_ended if on_page_ended
  webview_args.merge!(webview_props)

  body = Ruflet::UI::ControlFactory.build(:webview, **webview_args)
  yield body if block_given?

  view_args = { route: route, controls: [body] }
  view_args[:appbar] = appbar unless appbar.nil?
  view_args[:navigation_bar] = navigation_bar unless navigation_bar.nil?
  view_args[:bottom_appbar] = bottom_appbar unless bottom_appbar.nil?

  Ruflet::UI::ControlFactory.build(:view, **view_args)
end