ruflet_rails 0.0.11 → 0.0.13

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.
data/lib/ruflet/rails.rb CHANGED
@@ -14,26 +14,12 @@ module Ruflet
14
14
  # Yields the configuration object for block-style setup.
15
15
  #
16
16
  # Ruflet::Rails.configure do |c|
17
- # c.app_file = Rails.root.join("app/views/ruflet/main.rb")
18
- # c.ws_path = "/ws"
17
+ # c.backend_url = "https://example.com"
19
18
  # end
20
19
  def configure
21
20
  yield config
22
21
  end
23
22
 
24
- def view_classes
25
- @view_classes ||= []
26
- end
27
-
28
- def register_view(view_class)
29
- view_classes << view_class unless view_classes.include?(view_class)
30
- view_class
31
- end
32
-
33
- def render(page, routes: nil, default: nil)
34
- ViewRouter.new(page, routes: routes, default: default).start
35
- end
36
-
37
23
  # Flet-style routed navigation stack for complex multi-screen apps. Wires
38
24
  # up on_route_change / on_view_pop and starts at the current route. See
39
25
  # Ruflet::Rails::RouteStack.
@@ -46,17 +32,6 @@ module Ruflet
46
32
  RouteStack.new(page, &builder).start
47
33
  end
48
34
 
49
- def load_views(root)
50
- return [] if root.to_s.empty?
51
-
52
- files = Dir[File.join(root.to_s, "components", "**", "*.rb")].sort
53
- files += Dir[File.join(root.to_s, "**", "*_view.rb")].sort
54
-
55
- files.each do |file|
56
- Kernel.load(file)
57
- end
58
- end
59
-
60
35
  def sessions
61
36
  @sessions ||= SessionRegistry.new
62
37
  end
@@ -66,8 +41,8 @@ module Ruflet
66
41
  end
67
42
 
68
43
  # WebSocket endpoint for native mobile/desktop clients. The developer
69
- # declares the entry the same way they declare a web mount — so the screens
70
- # the app shows live in dev code, not in framework auto-discovery:
44
+ # declares the entry the same way they declare a web mount — the screens
45
+ # the app shows live in dev code, never in framework auto-discovery:
71
46
  #
72
47
  # # a standalone Ruflet app file (Ruflet.run/MyApp.new.run), per session:
73
48
  # match "/ws", to: Ruflet::Rails.endpoint(app_file: Rails.root.join("app/ruflet/main.rb")), via: :all
@@ -78,64 +53,46 @@ module Ruflet
78
53
  # # a custom block:
79
54
  # match "/ws", to: Ruflet::Rails.endpoint { |page| MyHome.render(page) }, via: :all
80
55
  #
81
- # With nothing declared it falls back to the convenience view router, which
82
- # auto-discovers RufletView subclasses and renders a route index. That index
83
- # is a framework fallback for the zero-config case — declare an entry above
84
- # to own the home screen in your code.
56
+ # One of view:, app_file:, or a block is required there is no
57
+ # auto-discovery fallback.
85
58
  def endpoint(view: nil, app_file: nil, &block)
86
59
  sources = [view, app_file, block].compact
87
60
  raise ArgumentError, "endpoint accepts only one of view:, app_file:, or a block" if sources.length > 1
61
+ raise ArgumentError, "endpoint requires one of view:, app_file:, or a block" if sources.empty?
88
62
 
89
63
  return Protocol::Runner.new.build_app_endpoint(file_path: app_file) if app_file
90
64
 
91
- entry =
92
- if block
93
- block
94
- elsif view
95
- web_app_entrypoint(view: view)
96
- else
97
- ->(page) { render(page) }
98
- end
65
+ entry = block || web_app_entrypoint(view: view)
99
66
  Protocol::Runner.new(&entry).build_endpoint
100
67
  end
101
68
 
102
- # Backward-compatible shorthand for a standalone app-file mobile endpoint.
69
+ # Shorthand for a standalone app-file endpoint.
103
70
  #
104
71
  # match "/ws", to: Ruflet::Rails.app(Rails.root.join("app/ruflet/main.rb")), via: :all
105
72
  def app(file_path)
106
73
  endpoint(app_file: file_path)
107
74
  end
108
75
 
109
- # Backward-compatible alias for older Rails installs.
110
- def mobile(file_path)
111
- app(file_path)
112
- end
113
-
114
- # Serves the pre-built Flutter web build's index.html at the given route.
115
- # Static assets (JS, WASM, fonts) are served by ActionDispatch::Static.
116
- #
117
- # Usage in routes.rb:
118
- # get "/showcase", to: Ruflet::Rails.web(build: Rails.root.join("public/showcase"))
119
- def web(build:)
120
- Protocol::WebAppEndpoint.new(build_dir: build.to_s)
121
- end
122
-
123
76
  # Self-contained web frontend, mountable under any route. Serves the
124
77
  # Flutter web build (with <base href> rewritten to the mount point) and
125
78
  # answers the Ruflet WebSocket on the same path. Routes stay routing-only;
126
- # UI code lives in app/views/ruflet:
127
- #
128
- # # all registered RufletView subclasses behind the view router:
129
- # mount Ruflet::Rails.web_app, at: "/app"
79
+ # UI code lives in dev files:
130
80
  #
131
81
  # # a single view class (resolved lazily, so reloading works):
132
82
  # mount Ruflet::Rails.web_app(view: "CounterView"), at: "/myfrontend"
133
83
  #
134
84
  # # a standalone Ruflet app file (MyApp.new.run), loaded per session:
135
85
  # mount Ruflet::Rails.web_app(app_file: "app/ruflet/showcase/main.rb"), at: "/showcase"
86
+ #
87
+ # # a custom block:
88
+ # mount Ruflet::Rails.web_app { |page| MyHome.render(page) }, at: "/app"
89
+ #
90
+ # One of view:, app_file:, or a block is required — there is no
91
+ # auto-discovery fallback.
136
92
  def web_app(view: nil, app_file: nil, build_dir: nil, &app_block)
137
93
  sources = [view, app_file, app_block].compact
138
94
  raise ArgumentError, "web_app accepts only one of view:, app_file:, or a block" if sources.length > 1
95
+ raise ArgumentError, "web_app requires one of view:, app_file:, or a block" if sources.empty?
139
96
 
140
97
  Protocol::WebApp.new(
141
98
  build_dir: build_dir,
@@ -166,147 +123,5 @@ module Ruflet
166
123
  view.to_s.constantize
167
124
  end
168
125
 
169
- # Reads index.html from the web build dir, injects window.__RUFLET_URL__
170
- # from config.backend_url so the Flutter client knows the WS backend
171
- # without a rebuild. Falls back to the request base_url if not configured.
172
- #
173
- # Usage in a Rails controller:
174
- # render html: Ruflet::Rails.web_html(request), layout: false
175
- def web_html(request)
176
- build_dir = config.web_build_dir.to_s
177
- html = File.read(File.join(build_dir, "index.html"))
178
- url = config.backend_url.to_s.strip
179
- url = request.base_url if url.empty?
180
- script = "<script>window.__RUFLET_URL__=#{url.to_json};</script>"
181
- html.include?("</head>") ? html.sub("</head>", "#{script}</head>") : "#{script}#{html}"
182
- end
183
- end
184
-
185
- module Rails
186
- # ViewRouter dispatches incoming page connections to the correct RufletView
187
- # subclass based on the current route. It includes SharedControlForwarders
188
- # so that widget helpers (text, column, safe_area, …) can be called directly
189
- # inside its own rendering helpers — the same pattern showcase uses.
190
- class ViewRouter
191
- include Ruflet::UI::SharedControlForwarders
192
-
193
- def initialize(page, routes: nil, default: nil)
194
- @page = page
195
- @routes = normalize_routes(routes || self.class.discovered_routes)
196
- @default = default || @routes["/"]
197
- end
198
-
199
- def start
200
- @page.on_route_change = ->(_event) { render }
201
- render
202
- end
203
-
204
- def render
205
- if root_route_without_default?
206
- render_route_index
207
- return
208
- end
209
-
210
- target = route_target(@page.route)
211
-
212
- if target.respond_to?(:render)
213
- target.render(@page)
214
- elsif target.respond_to?(:call)
215
- target.call(@page)
216
- else
217
- render_empty_state
218
- end
219
- end
220
-
221
- def self.discovered_routes
222
- Ruflet::Rails.view_classes.each_with_object({}) do |view_class, routes|
223
- next unless view_class.respond_to?(:route)
224
-
225
- routes[view_class.route] = view_class
226
- end
227
- end
228
-
229
- private
230
-
231
- # Widget builder calls delegate to the global DSL, same as Kernel does,
232
- # keeping the showcase pattern consistent across all contexts.
233
- def control_delegate
234
- Ruflet::DSL
235
- end
236
-
237
- def route_target(route)
238
- path = route_path(route)
239
- return @routes[path] if @routes.key?(path)
240
- return @default if path == "/"
241
-
242
- nil
243
- end
244
-
245
- def root_route_without_default?
246
- route_path(@page.route) == "/" && @default.nil? && @routes["/"].nil? && @routes.any?
247
- end
248
-
249
- def normalize_routes(routes)
250
- routes.to_h.transform_keys { |path| normalize_route(path) }
251
- end
252
-
253
- def route_path(route)
254
- normalize_route(route.to_s.split("?", 2).first)
255
- end
256
-
257
- def normalize_route(path)
258
- value = path.to_s.strip
259
- return "/" if value.empty? || value == "/"
260
-
261
- "/#{value.gsub(%r{\A/+|/+\z}, "")}"
262
- end
263
-
264
- def render_empty_state
265
- @page.title = "Ruflet"
266
- @page.add(
267
- container(
268
- expand: true,
269
- alignment: Ruflet::MainAxisAlignment::CENTER,
270
- content: text("No Ruflet views found")
271
- )
272
- )
273
- @page.update
274
- end
275
-
276
- def render_route_index
277
- @page.title = "Ruflet"
278
- @page.add(
279
- safe_area(
280
- container(
281
- expand: true,
282
- padding: { left: 24, top: 16, right: 24, bottom: 24 },
283
- content: column(
284
- spacing: 12,
285
- controls: [
286
- text("Ruflet", size: 24, weight: "bold"),
287
- *route_index_buttons
288
- ]
289
- )
290
- ),
291
- expand: true
292
- )
293
- )
294
- @page.update
295
- end
296
-
297
- def route_index_buttons
298
- @routes.keys.sort.map do |path|
299
- filled_button(
300
- content: text(route_label(path)),
301
- on_click: ->(_event) { @page.go(path) }
302
- )
303
- end
304
- end
305
-
306
- def route_label(path)
307
- label = path.to_s.delete_prefix("/").tr("_-", " ")
308
- label.empty? ? "Home" : label.split.map(&:capitalize).join(" ")
309
- end
310
- end
311
126
  end
312
127
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ruflet
4
- VERSION = "0.0.11" unless const_defined?(:VERSION)
4
+ VERSION = "0.0.13" unless const_defined?(:VERSION)
5
5
  end
data/lib/ruflet_rails.rb CHANGED
@@ -11,7 +11,6 @@ require "ruflet_core"
11
11
  require "ruflet_server"
12
12
  require_relative "ruflet/rails/session_registry"
13
13
  require_relative "ruflet/rails/form_helpers"
14
- require_relative "ruflet/rails/view"
15
14
  require_relative "ruflet/rails/resource_component"
16
15
  require_relative "ruflet/rails/route_stack"
17
16
  require_relative "ruflet/rails/webview_app"
@@ -20,6 +19,7 @@ require_relative "ruflet/rails/view_helpers"
20
19
  require_relative "ruflet/rails/desktop_launcher"
21
20
  require_relative "ruflet/rails/protocol"
22
21
  require_relative "ruflet/rails/assets"
22
+ require_relative "ruflet/rails/web_installer"
23
23
  require_relative "ruflet/rails/configuration"
24
24
  require_relative "ruflet/rails/install_support"
25
25
  require_relative "ruflet/rails"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruflet_rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.11
4
+ version: 0.0.13
5
5
  platform: ruby
6
6
  authors:
7
7
  - Adam Moussa Ali
@@ -29,42 +29,42 @@ dependencies:
29
29
  requirements:
30
30
  - - ">="
31
31
  - !ruby/object:Gem::Version
32
- version: 0.0.15
32
+ version: 0.0.16
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
37
  - - ">="
38
38
  - !ruby/object:Gem::Version
39
- version: 0.0.15
39
+ version: 0.0.16
40
40
  - !ruby/object:Gem::Dependency
41
41
  name: ruflet_core
42
42
  requirement: !ruby/object:Gem::Requirement
43
43
  requirements:
44
44
  - - ">="
45
45
  - !ruby/object:Gem::Version
46
- version: 0.0.15
46
+ version: 0.0.16
47
47
  type: :runtime
48
48
  prerelease: false
49
49
  version_requirements: !ruby/object:Gem::Requirement
50
50
  requirements:
51
51
  - - ">="
52
52
  - !ruby/object:Gem::Version
53
- version: 0.0.15
53
+ version: 0.0.16
54
54
  - !ruby/object:Gem::Dependency
55
55
  name: ruflet_server
56
56
  requirement: !ruby/object:Gem::Requirement
57
57
  requirements:
58
58
  - - ">="
59
59
  - !ruby/object:Gem::Version
60
- version: 0.0.15
60
+ version: 0.0.16
61
61
  type: :runtime
62
62
  prerelease: false
63
63
  version_requirements: !ruby/object:Gem::Requirement
64
64
  requirements:
65
65
  - - ">="
66
66
  - !ruby/object:Gem::Version
67
- version: 0.0.15
67
+ version: 0.0.16
68
68
  description: Build cross-platform mobile and desktop apps with Ruby on Rails using
69
69
  Ruflet.
70
70
  email:
@@ -82,32 +82,29 @@ files:
82
82
  - lib/ruflet/rails/configuration.rb
83
83
  - lib/ruflet/rails/desktop_launcher.rb
84
84
  - lib/ruflet/rails/form_helpers.rb
85
+ - lib/ruflet/rails/generator_hooks.rb
85
86
  - lib/ruflet/rails/install_support.rb
86
87
  - lib/ruflet/rails/native_app.rb
87
88
  - lib/ruflet/rails/protocol.rb
88
89
  - lib/ruflet/rails/protocol/context.rb
89
90
  - lib/ruflet/rails/protocol/endpoint.rb
90
91
  - lib/ruflet/rails/protocol/local_server.rb
91
- - lib/ruflet/rails/protocol/middleware.rb
92
92
  - lib/ruflet/rails/protocol/mobile_loader.rb
93
93
  - lib/ruflet/rails/protocol/runner.rb
94
- - lib/ruflet/rails/protocol/static_index_guard.rb
95
94
  - lib/ruflet/rails/protocol/web_app.rb
96
- - lib/ruflet/rails/protocol/web_app_endpoint.rb
97
- - lib/ruflet/rails/protocol/web_socket_connection.rb
98
95
  - lib/ruflet/rails/protocol/websocket_detection.rb
99
- - lib/ruflet/rails/protocol/wire_codec.rb
100
96
  - lib/ruflet/rails/railtie.rb
101
97
  - lib/ruflet/rails/resource_component.rb
102
98
  - lib/ruflet/rails/route_stack.rb
103
99
  - lib/ruflet/rails/session_registry.rb
104
- - lib/ruflet/rails/view.rb
105
100
  - lib/ruflet/rails/view_helpers.rb
101
+ - lib/ruflet/rails/web_installer.rb
106
102
  - lib/ruflet/rails/webview_app.rb
107
103
  - lib/ruflet/version.rb
108
104
  - lib/ruflet_rails.rb
109
105
  homepage: https://github.com/AdamMusa/ruflet/tree/main/packages/ruflet_rails
110
- licenses: []
106
+ licenses:
107
+ - MIT
111
108
  metadata: {}
112
109
  rdoc_options: []
113
110
  require_paths:
@@ -1,90 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Ruflet
4
- module Rails
5
- module Protocol
6
- # Rack middleware inserted before ActionDispatch::Static by the Railtie.
7
- #
8
- # Responsibilities:
9
- # 1. WebSocket at ws_path (/ws) — native mobile/desktop clients.
10
- # 2. WebSocket at the build dir base path (e.g. /app, /app/) — the
11
- # Flutter web client uses Uri.base from <base href="/app/"> as its
12
- # connection URL, so it naturally connects back to /app/ as WebSocket.
13
- # No URL injection needed — the client finds the server on its own.
14
- # 3. Block direct HTTP access to the build dir's index so
15
- # ActionDispatch::Static never leaks it at /app — serves Rails' 404.
16
- class Middleware
17
- INDEX_SUFFIXES = ["", "/", "/index.html"].freeze
18
-
19
- def initialize(app)
20
- @app = app
21
- @ws_endpoint = nil
22
- end
23
-
24
- def call(env)
25
- cfg = Ruflet::Rails.config
26
- path = env["PATH_INFO"].to_s
27
-
28
- if cfg.app_file
29
- # WS at configured ws_path — native/desktop clients
30
- if cfg.ws_path && path == normalize(cfg.ws_path) && websocket_upgrade?(env)
31
- return Context.with_env(env) { ws_endpoint(cfg).call(env) }
32
- end
33
-
34
- # WS at build dir base path — Flutter web client (Uri.base = /app/)
35
- if cfg.web_build_dir && build_dir_base?(path, cfg.web_build_dir.to_s) && websocket_upgrade?(env)
36
- return Context.with_env(env) { ws_endpoint(cfg).call(env) }
37
- end
38
- end
39
-
40
- # Block direct HTTP access to the build dir index
41
- if cfg.web_build_dir && build_dir_index?(path, cfg.web_build_dir.to_s)
42
- return rails_404
43
- end
44
-
45
- Context.with_env(env) { @app.call(env) }
46
- end
47
-
48
- private
49
-
50
- def ws_endpoint(cfg)
51
- @ws_endpoint ||= Runner.new.build_app_endpoint(file_path: cfg.app_file.to_s)
52
- end
53
-
54
- def websocket_upgrade?(env)
55
- return false unless env["REQUEST_METHOD"] == "GET"
56
-
57
- env["HTTP_UPGRADE"].to_s.downcase == "websocket" &&
58
- env["HTTP_CONNECTION"].to_s.downcase.include?("upgrade") &&
59
- env["HTTP_SEC_WEBSOCKET_KEY"].to_s != ""
60
- end
61
-
62
- # Matches /app and /app/ — the Flutter web client connects here.
63
- def build_dir_base?(path, build_dir)
64
- base = "/#{File.basename(build_dir)}"
65
- path == base || path == "#{base}/"
66
- end
67
-
68
- # Matches /app, /app/, /app/index.html — blocks static serving of index.
69
- def build_dir_index?(path, build_dir)
70
- base = "/#{File.basename(build_dir)}"
71
- INDEX_SUFFIXES.any? { |suffix| path == "#{base}#{suffix}" }
72
- end
73
-
74
- def rails_404
75
- page = rails_404_page
76
- [404, { "content-type" => "text/html; charset=utf-8", "content-length" => page.bytesize.to_s }, [page]]
77
- end
78
-
79
- def rails_404_page
80
- path = defined?(::Rails) ? ::Rails.public_path.join("404.html") : nil
81
- path && File.file?(path) ? File.read(path) : "Not Found"
82
- end
83
-
84
- def normalize(path)
85
- "/#{path.to_s.gsub(%r{\A/+|/+\z}, "")}"
86
- end
87
- end
88
- end
89
- end
90
- end
@@ -1,6 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # This file is intentionally empty.
4
- # StaticIndexGuard was removed — ruflet_rails does not intercept any static
5
- # paths. The route where the web build is served is defined explicitly in
6
- # routes.rb, and the backend URL comes from ruflet.yaml at build time.
@@ -1,44 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Ruflet
4
- module Rails
5
- module Protocol
6
- # Rack endpoint that serves the pre-built Flutter web app's index.html.
7
- #
8
- # The HTML is read from build_dir and returned as-is — no mutation.
9
- # The backend URL is baked into the build at compile time via:
10
- #
11
- # rake ruflet:build[web] # reads backend_url from ruflet.yaml
12
- #
13
- # which passes --dart-define=RUFLET_URL=<backend_url> to flutter build.
14
- # The Flutter client reads it via String.fromEnvironment('RUFLET_URL').
15
- #
16
- # Static assets (JS, WASM, fonts) in the build dir are served by Rails'
17
- # ActionDispatch::Static as normal.
18
- #
19
- # Usage in routes.rb:
20
- #
21
- # match "/ws", to: Ruflet::Rails.app(Rails.root.join("app/views/mobile/main.rb")), via: :all
22
- # get "/showcase", to: Ruflet::Rails.web(build: Rails.root.join("public/showcase"))
23
- #
24
- class WebAppEndpoint
25
- def initialize(build_dir:)
26
- @index_path = File.join(build_dir.to_s, "index.html")
27
- end
28
-
29
- def call(_env)
30
- return not_found unless File.file?(@index_path)
31
-
32
- html = File.read(@index_path)
33
- [200, { "content-type" => "text/html; charset=utf-8", "content-length" => html.bytesize.to_s }, [html]]
34
- end
35
-
36
- private
37
-
38
- def not_found
39
- [404, { "content-type" => "text/plain" }, ["Web build not found"]]
40
- end
41
- end
42
- end
43
- end
44
- end
@@ -1,11 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "ruflet_server"
4
-
5
- module Ruflet
6
- module Rails
7
- module Protocol
8
- WebSocketConnection = ::Ruflet::WebSocketConnection unless const_defined?(:WebSocketConnection, false)
9
- end
10
- end
11
- end
@@ -1,11 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "ruflet_server"
4
-
5
- module Ruflet
6
- module Rails
7
- module Protocol
8
- WireCodec = ::Ruflet::WireCodec unless const_defined?(:WireCodec, false)
9
- end
10
- end
11
- end
@@ -1,57 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "active_support/core_ext/string/inflections"
4
-
5
- # RufletView is the base class for all server-driven Ruflet views in a Rails app.
6
- # It includes Ruflet::UI::SharedControlForwarders so that all widget builder
7
- # methods (text, column, row, container, safe_area, filled_button, …) are
8
- # available directly on the view instance — the same way showcase uses them.
9
- class RufletView
10
- include Ruflet::UI::SharedControlForwarders
11
-
12
- attr_reader :page
13
-
14
- class << self
15
- def route(path = nil)
16
- @route_path = normalize_route(path) if path
17
- @route_path || inferred_route
18
- end
19
-
20
- def inherited(child)
21
- super
22
- Ruflet::Rails.register_view(child) if defined?(Ruflet::Rails) && Ruflet::Rails.respond_to?(:register_view)
23
- end
24
-
25
- private
26
-
27
- def inferred_route
28
- name_part = name.to_s.split("::").last.to_s.sub(/View\z/, "")
29
- path = name_part.underscore.pluralize
30
- normalize_route(path.empty? ? "/" : path)
31
- end
32
-
33
- def normalize_route(path)
34
- value = path.to_s.strip
35
- return "/" if value.empty? || value == "/"
36
-
37
- "/#{value.gsub(%r{\A/+|/+\z}, "")}"
38
- end
39
- end
40
-
41
- def self.render(page, *args, **kwargs, &block)
42
- new(page).render(*args, **kwargs, &block)
43
- end
44
-
45
- def initialize(page)
46
- @page = page
47
- end
48
-
49
- private
50
-
51
- # Delegates all widget builder calls (text, column, row, …) to Ruflet::DSL,
52
- # matching the same delegate target that Kernel uses. Subclasses can override
53
- # this to scope widget building to a local WidgetBuilder instance instead.
54
- def control_delegate
55
- Ruflet::DSL
56
- end
57
- end