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.
@@ -8,10 +8,10 @@ module Ruflet
8
8
  # the same mount point, so it mounts like any Rack app:
9
9
  #
10
10
  # # config/routes.rb
11
- # mount Ruflet::Rails.web_app, at: "/myfrontend"
11
+ # mount Ruflet::Rails.web_app(app_file: Rails.root.join("app/views/ruflet/main.rb")), at: "/myfrontend"
12
12
  #
13
13
  # # or with an explicit entrypoint and build directory:
14
- # mount Ruflet::Rails.web_app(build_dir: Rails.root.join("public/app")) { |page|
14
+ # mount Ruflet::Rails.web_app(build_dir: Rails.root.join("frontend")) { |page|
15
15
  # CounterView.render(page)
16
16
  # }, at: "/myfrontend"
17
17
  #
@@ -73,15 +73,16 @@ module Ruflet
73
73
  end
74
74
 
75
75
  def entrypoint
76
- @entrypoint_option || @app_block || lambda { |page| Ruflet::Rails.render(page) }
76
+ @entrypoint_option || @app_block ||
77
+ raise(ArgumentError, "web_app requires one of view:, app_file:, or a block")
77
78
  end
78
79
 
79
80
  # The web build must NOT live under public/, or Rails' static
80
81
  # middleware would serve it directly (e.g. /app/index.html) and bypass
81
82
  # the mount — making it reachable at a path no route declares. The
82
- # default is Rails.root/build/web, exactly where `rake ruflet:build[web]`
83
- # exports, which Rails never serves statically. A configured
84
- # web_build_dir or explicit build_dir: under public/ is rejected.
83
+ # default is Rails.root/frontend, exactly where `rake ruflet:web`
84
+ # installs the prebuilt web client, which Rails never serves statically.
85
+ # An explicit build_dir: under public/ is rejected.
85
86
  def build_dir
86
87
  dir = resolve_build_dir
87
88
  reject_public_build_dir!(dir)
@@ -92,10 +93,7 @@ module Ruflet
92
93
  explicit = @explicit_build_dir.to_s
93
94
  return explicit unless explicit.empty?
94
95
 
95
- configured = Ruflet::Rails.config.web_build_dir.to_s
96
- return configured unless configured.empty?
97
-
98
- return ::Rails.root.join("build", "web").to_s if defined?(::Rails.root) && ::Rails.root
96
+ return ::Rails.root.join("frontend").to_s if defined?(::Rails.root) && ::Rails.root
99
97
 
100
98
  ""
101
99
  end
@@ -110,19 +108,20 @@ module Ruflet
110
108
 
111
109
  raise ArgumentError,
112
110
  "Ruflet web build dir (#{dir}) is under public/, which Rails serves " \
113
- "statically and would expose the app outside its mount. Build to a " \
114
- "non-public directory such as #{::Rails.root.join('build', 'web')}."
111
+ "statically and would expose the app outside its mount. Use a " \
112
+ "non-public directory such as #{::Rails.root.join('frontend')}."
115
113
  end
116
114
 
117
115
  def serve_index(env)
118
116
  index_path = File.join(build_dir, "index.html")
119
117
  unless File.file?(index_path)
120
118
  return [404, { "content-type" => "text/plain" },
121
- ["Ruflet web build not found at #{index_path}. Run `rake ruflet:build[web]`."]]
119
+ ["Ruflet web client not found at #{index_path}. Run `rake ruflet:web`."]]
122
120
  end
123
121
 
124
122
  html = rewrite_base_href(File.read(index_path), mount_base(env))
125
- html = inject_mount_url_param(html)
123
+ html = inject_mount_websocket(html)
124
+ html = inject_service_worker_cleanup(html)
126
125
  [200,
127
126
  { "content-type" => "text/html; charset=utf-8",
128
127
  "cache-control" => "no-cache",
@@ -130,28 +129,47 @@ module Ruflet
130
129
  [html]]
131
130
  end
132
131
 
133
- # The Flet web client builds its WebSocket URL as
134
- # ws(s)://<page authority>/<window.flet.webSocketEndpoint || "ws">.
135
- # Without help, every build connects to the origin's /ws and escapes
136
- # the mount. Deriving the endpoint from document.baseURI (which
137
- # follows the rewritten <base href>) pins any build to <mount>/ws —
138
- # answered by this same Rack app.
139
- MOUNT_URL_BOOTSTRAP = <<~HTML
140
- <script>
141
- (function () {
142
- window.flet = window.flet || {};
143
- if (!window.flet.webSocketEndpoint) {
144
- var basePath = new URL(document.baseURI).pathname;
145
- window.flet.webSocketEndpoint = (basePath + "ws").replace(/^\\/+/, "");
146
- }
147
- })();
148
- </script>
149
- HTML
150
-
151
- def inject_mount_url_param(html)
132
+ def inject_mount_websocket(html)
152
133
  return html if html.include?("window.flet.webSocketEndpoint")
153
134
 
154
- html.sub(%r{(<base\s+href="[^"]*"\s*/?>)}) { "#{::Regexp.last_match(1)}\n#{MOUNT_URL_BOOTSTRAP}" }
135
+ html.sub(%r{(<base\s+href="[^"]*"\s*/?>)}) { "#{::Regexp.last_match(1)}\n#{mount_websocket_bootstrap}" }
136
+ end
137
+
138
+ # Mounted Rails apps are server-driven and must always load the client
139
+ # shipped by the Rails app. Remove service workers previously installed
140
+ # for this mount so they cannot keep serving a stale connection client.
141
+ def inject_service_worker_cleanup(html)
142
+ return html if html.include?("ruflet-rails-service-worker-cleanup")
143
+
144
+ script = <<~HTML
145
+ <script id="ruflet-rails-service-worker-cleanup">
146
+ if ("serviceWorker" in navigator) {
147
+ navigator.serviceWorker.getRegistrations().then(function (registrations) {
148
+ registrations.forEach(function (registration) {
149
+ if (registration.scope.indexOf(document.baseURI) === 0) registration.unregister();
150
+ });
151
+ });
152
+ }
153
+ </script>
154
+ HTML
155
+ html.include?("</head>") ? html.sub("</head>", "#{script}</head>") : "#{script}#{html}"
156
+ end
157
+
158
+ # Pins Flet-style clients to <mount>/ws (derived from the rewritten
159
+ # <base href>) instead of the origin's /ws. The Ruflet client itself
160
+ # uses the mounted page URL, so Rails needs no separate backend URL.
161
+ def mount_websocket_bootstrap
162
+ <<~HTML
163
+ <script>
164
+ (function () {
165
+ window.flet = window.flet || {};
166
+ if (!window.flet.webSocketEndpoint) {
167
+ var basePath = new URL(document.baseURI).pathname;
168
+ window.flet.webSocketEndpoint = (basePath + "ws").replace(/^\\/+/, "");
169
+ }
170
+ })();
171
+ </script>
172
+ HTML
155
173
  end
156
174
 
157
175
  # <base href> drives the Flutter client's asset and WebSocket URLs;
@@ -172,6 +190,8 @@ module Ruflet
172
190
  end
173
191
 
174
192
  def serve_static(path)
193
+ return retire_service_worker if path == "/flutter_service_worker.js"
194
+
175
195
  root = File.expand_path(build_dir)
176
196
  full = File.expand_path(File.join(root, path))
177
197
  unless full.start_with?(root + File::SEPARATOR) && File.file?(full)
@@ -179,8 +199,37 @@ module Ruflet
179
199
  end
180
200
 
181
201
  body = File.binread(full)
202
+ body = disable_service_worker(body) if path == "/flutter_bootstrap.js"
182
203
  [200,
183
204
  { "content-type" => content_type_for(full),
205
+ "cache-control" => "no-cache",
206
+ "content-length" => body.bytesize.to_s },
207
+ [body]]
208
+ end
209
+
210
+ def disable_service_worker(body)
211
+ body.sub(
212
+ /serviceWorkerSettings:\s*\{\s*serviceWorkerVersion:\s*[^}]+\}\s*,?/m,
213
+ ""
214
+ )
215
+ end
216
+
217
+ def retire_service_worker
218
+ body = <<~JS
219
+ self.addEventListener("install", function () { self.skipWaiting(); });
220
+ self.addEventListener("activate", function (event) {
221
+ event.waitUntil((async function () {
222
+ await self.registration.unregister();
223
+ for (const key of await caches.keys()) await caches.delete(key);
224
+ for (const client of await self.clients.matchAll({ type: "window" })) {
225
+ client.navigate(client.url);
226
+ }
227
+ })());
228
+ });
229
+ JS
230
+ [200,
231
+ { "content-type" => "application/javascript",
232
+ "cache-control" => "no-store",
184
233
  "content-length" => body.bytesize.to_s },
185
234
  [body]]
186
235
  end
@@ -1,13 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "protocol/context"
4
- require_relative "protocol/middleware"
5
- require_relative "protocol/wire_codec"
6
- require_relative "protocol/web_socket_connection"
7
4
  require_relative "protocol/local_server"
8
5
  require_relative "protocol/websocket_detection"
9
6
  require_relative "protocol/endpoint"
10
7
  require_relative "protocol/mobile_loader"
11
8
  require_relative "protocol/runner"
12
- require_relative "protocol/web_app_endpoint"
13
9
  require_relative "protocol/web_app"
@@ -3,10 +3,9 @@
3
3
  module Ruflet
4
4
  module Rails
5
5
  class Railtie < ::Rails::Railtie
6
- # Must run before ActionDispatch::Static so the Middleware can serve
7
- # web_path and block the build dir's index before Static can touch it.
8
- initializer "ruflet_rails.middleware", before: "ActionDispatch::Static" do |app|
9
- app.middleware.insert_before(ActionDispatch::Static, Ruflet::Rails::Protocol::Middleware)
6
+ generators do
7
+ require "ruflet/rails/generator_hooks"
8
+ Ruflet::Rails::GeneratorHooks.install!
10
9
  end
11
10
 
12
11
  # Make ruflet_frame and friends available in every .erb template.
@@ -16,6 +15,18 @@ module Ruflet
16
15
  end
17
16
  end
18
17
 
18
+ # Ruflet components live under app/views so Rails will not discover them
19
+ # by default. Add that directory as a Zeitwerk root and collapse its
20
+ # organizational subdirectories, matching generated top-level constants
21
+ # such as components/products/product_component.rb -> ProductComponent.
22
+ initializer "ruflet_rails.components", before: :bootstrap_hook do |app|
23
+ components = app.root.join("app/views/ruflet/components")
24
+ next unless components.directory?
25
+
26
+ app.autoloaders.main.push_dir(components)
27
+ app.autoloaders.main.collapse(components.join("**"))
28
+ end
29
+
19
30
  initializer "ruflet_rails.desktop_launcher", after: :load_config_initializers do |_app|
20
31
  next unless defined?(::Rails.root)
21
32
 
@@ -27,22 +38,21 @@ module Ruflet
27
38
 
28
39
  rake_tasks do
29
40
  namespace :ruflet do
30
- desc "Build Ruflet client for this Rails app. Usage: rake ruflet:build[platform]"
41
+ desc "Build Ruflet native client for this Rails app. Usage: rake ruflet:build[platform]"
31
42
  task :build, [:platform] do |_task, args|
32
43
  platform = args[:platform].to_s.strip.downcase
33
44
 
45
+ if platform == "web"
46
+ warn "ruflet_rails does not build the web client. Install the prebuilt web client with: rake ruflet:web"
47
+ next
48
+ end
49
+
34
50
  cfg = Ruflet::Rails.config
35
51
  ruflet_url = cfg.backend_url.to_s.strip
36
52
  build_args = Ruflet::Rails::InstallSupport.build_args_for_platform(platform, ruflet_url: ruflet_url)
37
53
 
38
- # Derive --base-href from config.web_build_dir so the built index.html
39
- # uses the correct path for assets and Uri.base in the Dart client.
40
- if platform == "web" && (dir = cfg.web_build_dir)
41
- build_args += ["--base-href", "/#{File.basename(dir.to_s)}/"]
42
- end
43
-
44
54
  if build_args.empty?
45
- warn "Usage: rake ruflet:build[apk|android|ios|aab|web|desktop|macos|windows|linux]"
55
+ warn "Usage: rake ruflet:build[apk|android|ios|aab|desktop|macos|windows|linux]"
46
56
  next
47
57
  end
48
58
 
@@ -72,6 +82,12 @@ module Ruflet
72
82
  raise SystemExit, exit_code unless exit_code.to_i.zero?
73
83
  end
74
84
 
85
+ desc "Install the prebuilt Ruflet web client into frontend/. Usage: rake ruflet:web"
86
+ task :web do
87
+ ok = Ruflet::Rails::WebInstaller.install!(root: ::Rails.root)
88
+ raise SystemExit, 1 unless ok
89
+ end
90
+
75
91
  desc "Download/update prebuilt Ruflet clients from GitHub releases. Usage: rake ruflet:update[target]"
76
92
  task :update, [:target] do |_task, args|
77
93
  target = args[:target].to_s.strip
@@ -12,7 +12,7 @@ module Ruflet
12
12
  # The generated subclass owns the explicit CRUD UI (render, show, the
13
13
  # create/edit form) AND the database calls (record.update, record.destroy!,
14
14
  # model_class.new) — so a developer can read and change anything. This base
15
- # provides only reusable, non-DB helpers: model resolution, record loading,
15
+ # provides reusable helpers: model resolution, record loading,
16
16
  # field inference (resource_fields/display_fields/display_value), navigation
17
17
  # (render_index/render_show/refresh), dialog management, snackbars, and the
18
18
  # date/time picker value helpers.
@@ -88,9 +88,8 @@ module Ruflet
88
88
  model_class.respond_to?(:model_name) ? model_class.model_name.human.titleize : self.class.singular_title
89
89
  end
90
90
 
91
- # Fields rendered on the detail (show) screen. The generated subclass
92
- # overrides this with the scaffolded attributes; the default falls back to
93
- # the model's own attribute names.
91
+ # Fields rendered on the detail (show) screen. The default uses the
92
+ # model's own attribute names; subclasses can override it.
94
93
  def resource_fields
95
94
  default_resource_fields
96
95
  end
@@ -105,7 +104,7 @@ module Ruflet
105
104
  record.public_send(field).to_s
106
105
  end
107
106
 
108
- # --- Record loading & persistence --------------------------------------
107
+ # --- Record loading & navigation ---------------------------------------
109
108
 
110
109
  def records
111
110
  scope = model_class.respond_to?(:limit) ? model_class.limit(50) : model_class.all
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+ require "tmpdir"
6
+ require "open3"
7
+
8
+ module Ruflet
9
+ module Rails
10
+ # Installs the prebuilt Ruflet web client into a Rails app's frontend/
11
+ # directory. The web client is the `ruflet_client-web.tar.gz` release asset
12
+ # (the same prebuilt Flutter web build shipped on GitHub releases) — so the
13
+ # Rails app serves a web frontend without ever running a Flutter build.
14
+ #
15
+ # rake ruflet:web
16
+ #
17
+ # The extracted build is what Ruflet::Rails.web_app serves (its default
18
+ # build dir is <Rails.root>/frontend).
19
+ module WebInstaller
20
+ module_function
21
+
22
+ REPO = "AdamMusa/Ruflet"
23
+ ASSET_NAME = "ruflet_client-web.tar.gz"
24
+ TARGET_DIR = "frontend"
25
+
26
+ # Downloads and extracts the web client into <root>/<dir>. Returns true on
27
+ # success. The asset name and release tag are overridable via ENV so a
28
+ # project can pin a specific build.
29
+ def install!(root:, dir: TARGET_DIR, asset_name: env_asset_name, tag: env_tag, force: true)
30
+ target = File.join(root.to_s, dir)
31
+
32
+ url = release_asset_url(asset_name, tag: tag)
33
+ unless url
34
+ warn "Ruflet web client asset #{asset_name.inspect} not found in the #{REPO} release#{tag ? " #{tag}" : " (latest)"}."
35
+ return false
36
+ end
37
+
38
+ Dir.mktmpdir("ruflet-web-") do |tmp|
39
+ archive = File.join(tmp, asset_name)
40
+ return false unless download(url, archive)
41
+
42
+ staging = File.join(tmp, "frontend")
43
+ FileUtils.mkdir_p(staging)
44
+ unless safe_archive?(archive) && extract(archive, staging)
45
+ warn "Failed to safely extract #{asset_name}."
46
+ return false
47
+ end
48
+
49
+ unless File.file?(File.join(staging, "index.html"))
50
+ warn "Extracted web client but no index.html was found."
51
+ return false
52
+ end
53
+
54
+ return false if Dir.exist?(target) && !force
55
+
56
+ replace_target(staging, target)
57
+ end
58
+
59
+ puts "Installed Ruflet web client into #{target} (#{asset_name})."
60
+ true
61
+ rescue SystemCallError => e
62
+ warn "Failed to install Ruflet web client into #{target}: #{e.message}"
63
+ false
64
+ end
65
+
66
+ def release_asset_url(asset_name, tag: nil)
67
+ api = tag ? "releases/tags/#{tag}" : "releases/latest"
68
+ release = github_json("https://api.github.com/repos/#{REPO}/#{api}")
69
+ return nil unless release
70
+
71
+ asset = Array(release["assets"]).find { |a| a["name"] == asset_name }
72
+ unless asset
73
+ names = Array(release["assets"]).map { |a| a["name"] }
74
+ warn "Available release assets: #{names.join(', ')}" unless names.empty?
75
+ end
76
+ asset && asset["browser_download_url"]
77
+ end
78
+
79
+ def github_json(url)
80
+ out, status = Open3.capture2(
81
+ "curl", "-sSL", "--fail",
82
+ "-H", "Accept: application/vnd.github+json",
83
+ "-H", "User-Agent: ruflet_rails",
84
+ url
85
+ )
86
+ return nil unless status.success?
87
+
88
+ JSON.parse(out)
89
+ rescue JSON::ParserError
90
+ nil
91
+ end
92
+
93
+ def download(url, path)
94
+ ok = system("curl", "-sSL", "--fail", "-o", path, url)
95
+ warn "Download failed: #{url}" unless ok
96
+ ok
97
+ end
98
+
99
+ def extract(archive, target)
100
+ system("tar", "-xzf", archive, "-C", target)
101
+ end
102
+
103
+ def safe_archive?(archive)
104
+ out, status = Open3.capture2("tar", "-tzf", archive)
105
+ return false unless status.success?
106
+
107
+ out.lines.all? do |line|
108
+ path = line.strip
109
+ !path.start_with?("/") && path.split("/").none?("..")
110
+ end
111
+ end
112
+
113
+ def replace_target(staging, target)
114
+ FileUtils.mkdir_p(File.dirname(target))
115
+ backup = "#{target}.ruflet-backup"
116
+ FileUtils.rm_rf(backup)
117
+ FileUtils.mv(target, backup) if File.exist?(target)
118
+ FileUtils.mv(staging, target)
119
+ FileUtils.rm_rf(backup)
120
+ rescue StandardError
121
+ FileUtils.rm_rf(target)
122
+ FileUtils.mv(backup, target) if File.exist?(backup)
123
+ raise
124
+ end
125
+
126
+ def env_asset_name
127
+ value = ENV["RUFLET_RAILS_WEB_ARTIFACT"].to_s.strip
128
+ value.empty? ? ASSET_NAME : value
129
+ end
130
+
131
+ def env_tag
132
+ value = ENV["RUFLET_RAILS_WEB_TAG"].to_s.strip
133
+ value.empty? ? nil : value
134
+ end
135
+ end
136
+ end
137
+ end
@@ -33,7 +33,7 @@ module Ruflet
33
33
  def webview_app(url:, appbar: nil, navigation_bar: nil, bottom_appbar: nil,
34
34
  route: "/", prevent_links: nil, on_navigate: nil,
35
35
  on_page_started: nil, on_page_ended: nil, **webview_props)
36
- webview_args = { url: url, expand: true }
36
+ webview_args = { url: url, method: "get", expand: true }
37
37
  webview_args[:prevent_links] = prevent_links unless prevent_links.nil?
38
38
  webview_args[:on_url_change] = ->(event) { on_navigate.call(event.data) } if on_navigate
39
39
  webview_args[:on_page_started] = on_page_started if on_page_started