ruflet_rails 0.0.10 → 0.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2ffd81f0143fcba01b93a6d0f773d1d0e4aa983a01c4bf282ed383a631273b13
4
- data.tar.gz: 8840d1be44c36b8d82789f9a9d8055bd2ab0f8e893bafe1fc2ab6f8f082f90d0
3
+ metadata.gz: d142d63121dae012354a763ef756504d64534adbc4b2c11eaf0dc51b514c6b32
4
+ data.tar.gz: d3d1e6cbf9af94c81d5514b71325484e547b40ba34afe3d8601a111f08e4af6a
5
5
  SHA512:
6
- metadata.gz: 456e8aaf3966f272a7b5b0945163a8f96955c240577fb5a1f4f5023ec635260eb531872505e03d552199f6d52c5a68839cbb3a2a4115d82d25f3e1c0bcf95765
7
- data.tar.gz: a63099e903e698ceb130f8b678deaa8a2cb238335266615704fdac47b41f1775fc8e1d677789bab3c6a3961034173d323719325ac159d1b679b4dfd772636a03
6
+ metadata.gz: daf5bf66b258ece12da18cbb4c455d2f9f5dd141faa3638c2ef0ec9a64d85be9b25d94add15ae0374a96c6694f6e393ee015290df62368bd1d9cd6920a3185f3
7
+ data.tar.gz: 075def7cfb22688619559ba1ca55ede0d4c152c403c95a2273c1fa322a2862b20aafe7bda0507bf73bb4a8e8f40e03eb6c87b9505900fb31aa3ac08098dbf88b
data/README.md CHANGED
@@ -24,7 +24,8 @@ bin/rails generate ruflet:install --web --desktop
24
24
  This generator will:
25
25
  - create `app/views/ruflet/main.rb`
26
26
  - create `ruflet.yaml`
27
- - add the Ruflet mount route to `config/routes.rb`
27
+ - add the Ruflet WebSocket route to `config/routes.rb`
28
+ - add a `/ruflet` web mount when `--web` is used
28
29
  - download prebuilt clients from GitHub releases when `--web`, `--desktop`, or
29
30
  `--client=web|desktop|all` is used
30
31
 
@@ -44,12 +45,30 @@ assets:
44
45
 
45
46
  For Rails apps, those asset paths are resolved from `app/assets/` during build.
46
47
 
47
- ## Build client from Rails
48
+ ## Web client
48
49
 
49
- Uses the same build pipeline as `ruflet build`:
50
+ Rails installs the prebuilt web client into `frontend/`; it does not need
51
+ Flutter source or a Flutter web build:
52
+
53
+ ```bash
54
+ bundle exec rake ruflet:web
55
+ ```
56
+
57
+ Mount the installed client and a developer-owned Ruflet entrypoint:
58
+
59
+ ```ruby
60
+ mount Ruflet::Rails.web_app(app_file: Rails.root.join("app/views/ruflet/main.rb")), at: "/app"
61
+ ```
62
+
63
+ The install generator adds the same mount at `/ruflet` when `--web` is used.
64
+ The mount serves the static client and its WebSocket endpoint together. The
65
+ same `main.rb` also drives native clients through the generated `/ws` route.
66
+
67
+ ## Build native clients from Rails
68
+
69
+ Uses the same native build pipeline as `ruflet build`:
50
70
 
51
71
  ```bash
52
- bundle exec rake ruflet:build[web]
53
72
  bundle exec rake ruflet:build[macos]
54
73
  bundle exec rake ruflet:build[windows]
55
74
  bundle exec rake ruflet:build[linux]
@@ -59,8 +78,6 @@ bundle exec rake ruflet:build[ios]
59
78
  bundle exec rake ruflet:build[aab]
60
79
  ```
61
80
 
62
- Rails web builds are published to `public/ruflet` and served by Rails at `/ruflet/`.
63
-
64
81
  `desktop` is also accepted as a host-platform alias:
65
82
 
66
83
  ```bash
@@ -82,16 +99,14 @@ bin/rails s --desktop
82
99
 
83
100
  ## Update prebuilt clients
84
101
 
85
- Uses the same GitHub release assets as `ruflet update`:
102
+ Reinstall web or update native desktop clients:
86
103
 
87
104
  ```bash
88
- bundle exec rake ruflet:update[web]
105
+ bundle exec rake ruflet:web
89
106
  bundle exec rake ruflet:update[desktop]
90
- bundle exec rake ruflet:update[all]
91
107
  ```
92
108
 
93
- For web, the downloaded static client is published to `public/ruflet` and served
94
- by Rails at `/ruflet/`. The Rails app does not vendor Flutter source code.
109
+ The Rails app does not vendor Flutter source code.
95
110
 
96
111
  ## Install mobile build
97
112
 
@@ -104,7 +119,7 @@ bundle exec rake ruflet:install[DEVICE_ID]
104
119
 
105
120
  ## Ruflet resource scaffolds
106
121
 
107
- Generate a Ruflet CRUD view for an existing Rails model:
122
+ Generate a mountable Ruflet CRUD component for an existing Rails model:
108
123
 
109
124
  ```bash
110
125
  bin/rails generate ruflet:scaffold Post
@@ -113,30 +128,6 @@ bin/rails generate ruflet:scaffold Post
113
128
  The scaffold creates generated app code the Rails developer can own and edit:
114
129
 
115
130
  ```ruby
116
- # app/views/ruflet/posts_view.rb
117
- require_relative "components/posts/post_component"
118
-
119
- class PostView < RufletView
120
- include Ruflet::Rails::FormHelpers
121
-
122
- route "/posts"
123
-
124
- def render
125
- page.title = resource_title
126
- render_index
127
- end
128
-
129
- private
130
-
131
- def records
132
- # edit resource query logic here
133
- end
134
-
135
- def component
136
- @component ||= PostComponent.new(page, controller: self)
137
- end
138
- end
139
-
140
131
  # app/views/ruflet/components/posts/post_component.rb
141
132
  class PostComponent < Ruflet::Rails::ResourceComponent
142
133
  def render
@@ -145,11 +136,16 @@ class PostComponent < Ruflet::Rails::ResourceComponent
145
136
  end
146
137
  ```
147
138
 
148
- The generated view contains the resource logic: query, model helpers, field
149
- introspection, save/delete, dialog close, and display formatting. The generated
150
- component contains the UI: tables, lists, and dialogs. `ruflet_rails` only
151
- provides the base classes and generic helpers. The generator does not dump field
152
- declarations like `title:string` or literal metadata tables into the app.
139
+ Mount it explicitly in `config/routes.rb`:
140
+
141
+ ```ruby
142
+ mount Ruflet::Rails.web_app(view: "PostComponent"), at: "/posts"
143
+ ```
144
+
145
+ The generated component contains the developer-owned UI and persistence calls.
146
+ `ruflet_rails` provides the reusable model, navigation, dialog, and formatting
147
+ helpers. Component files under `app/views/ruflet/components` are loaded by the
148
+ Railtie so both web mounts and `main.rb` can reference them.
153
149
 
154
150
  ## Ruflet model forms
155
151
 
@@ -175,8 +171,8 @@ when that base component does not already exist.
175
171
 
176
172
  ## Shared Ruflet components
177
173
 
178
- Put shared Ruflet UI components under `app/views/ruflet/components`. Component
179
- files are loaded before `*_view.rb` files, so views can call them directly:
174
+ Put shared Ruflet UI components under `app/views/ruflet/components`. Those files
175
+ are reloaded with Rails application code:
180
176
 
181
177
  ```ruby
182
178
  # app/views/ruflet/components/page_title_component.rb
@@ -188,11 +184,9 @@ end
188
184
  ```
189
185
 
190
186
  ```ruby
191
- # app/views/ruflet/posts/posts_view.rb
192
- class PostsView < RufletView
193
- def render
194
- page.add(PageTitleComponent.render(page, "Posts"))
195
- end
187
+ # app/views/ruflet/main.rb
188
+ Ruflet.run do |page|
189
+ page.add(PageTitleComponent.render(page, "Posts"))
196
190
  end
197
191
  ```
198
192
 
@@ -6,8 +6,9 @@ require "ruflet/rails/install_support"
6
6
  module Ruflet
7
7
  module Generators
8
8
  class InstallGenerator < ::Rails::Generators::Base
9
+ class_option :web, type: :boolean, default: false, desc: "Install the prebuilt Ruflet web client"
9
10
  class_option :desktop, type: :boolean, default: false, desc: "Download the server-driven desktop Ruflet client"
10
- class_option :client, type: :string, default: nil, desc: "Download prebuilt client from GitHub releases: desktop or none"
11
+ class_option :client, type: :string, default: nil, desc: "Install prebuilt clients: web, desktop, all, or none"
11
12
 
12
13
  desc "Install Ruflet into a Rails app."
13
14
 
@@ -25,15 +26,24 @@ module Ruflet
25
26
  create_file target, Ruflet::Rails::InstallSupport.default_ruflet_yaml(app_name: app_name)
26
27
  end
27
28
 
28
- def add_routes
29
- target = File.join(destination_root, "config/routes.rb")
30
- return unless File.file?(target)
29
+ # Mount the native WebSocket endpoint explicitly in config/routes.rb.
30
+ # Nothing is auto-mounted — the dev owns the route, like any other.
31
+ def mount_websocket
32
+ routes = File.join(destination_root, "config", "routes.rb")
33
+ return unless File.file?(routes)
34
+ return if File.read(routes).include?("Ruflet::Rails.app(")
31
35
 
32
- route = Ruflet::Rails::InstallSupport.route_snippet(entrypoint: entrypoint_path)
33
- source = File.read(target)
34
- return if source.include?(route)
36
+ route Ruflet::Rails::InstallSupport.route_snippet(entrypoint: entrypoint_path)
37
+ end
38
+
39
+ def mount_web_app
40
+ return unless web_requested?
41
+
42
+ routes = File.join(destination_root, "config", "routes.rb")
43
+ return unless File.file?(routes)
44
+ return if File.read(routes).include?("Ruflet::Rails.web_app(")
35
45
 
36
- insert_into_file target, " #{route}\n", after: /Rails\.application\.routes\.draw do\s*\n/
46
+ route Ruflet::Rails::InstallSupport.web_route_snippet(entrypoint: entrypoint_path)
37
47
  end
38
48
 
39
49
  def add_desktop_flag_to_binstubs
@@ -47,14 +57,8 @@ module Ruflet
47
57
  client = requested_client
48
58
  return if client == "none"
49
59
 
50
- require "ruflet/cli"
51
- exit_code = Dir.chdir(destination_root) do
52
- Ruflet::CLI.command_update([client])
53
- end
54
- unless exit_code.to_i.zero?
55
- @client_download_failed = true
56
- say_status(:warn, "Ruflet client download failed; install files were generated and build/update steps are printed below", :yellow)
57
- end
60
+ install_web_client if %w[web all].include?(client)
61
+ install_desktop_client if %w[desktop all].include?(client)
58
62
  rescue StandardError => e
59
63
  @client_download_failed = true
60
64
  say_status(:warn, "Ruflet client download failed: #{e.class}: #{e.message}", :yellow)
@@ -81,24 +85,47 @@ module Ruflet
81
85
  def requested_client
82
86
  explicit = options[:client].to_s.strip.downcase
83
87
  unless explicit.empty?
84
- raise Thor::Error, "--client must be desktop or none" unless %w[desktop none].include?(explicit)
88
+ raise Thor::Error, "--client must be web, desktop, all, or none" unless %w[web desktop all none].include?(explicit)
85
89
 
86
90
  return explicit
87
91
  end
88
92
 
93
+ return "all" if options[:web] && options[:desktop]
94
+ return "web" if options[:web]
89
95
  return "desktop" if options[:desktop]
90
96
 
91
97
  "none"
92
98
  end
93
99
 
94
100
  def desktop_requested?
95
- requested_client == "desktop"
101
+ %w[desktop all].include?(requested_client)
102
+ end
103
+
104
+ def web_requested?
105
+ %w[web all].include?(requested_client)
96
106
  end
97
107
 
98
108
  def install_target
99
109
  "ruflet"
100
110
  end
101
111
 
112
+ def install_web_client
113
+ return if Ruflet::Rails::WebInstaller.install!(root: destination_root)
114
+
115
+ client_download_failed("web")
116
+ end
117
+
118
+ def install_desktop_client
119
+ require "ruflet/cli"
120
+ exit_code = Dir.chdir(destination_root) { Ruflet::CLI.command_update(["desktop"]) }
121
+ client_download_failed("desktop") unless exit_code.to_i.zero?
122
+ end
123
+
124
+ def client_download_failed(client)
125
+ @client_download_failed = true
126
+ say_status(:warn, "Ruflet #{client} client download failed; install files were still generated", :yellow)
127
+ end
128
+
102
129
  def install_desktop_flag_bootstrap(relative_path)
103
130
  target = File.join(destination_root, relative_path)
104
131
  return unless File.file?(target)
@@ -10,17 +10,7 @@ module Ruflet
10
10
  argument :model_name, type: :string
11
11
  argument :attributes, type: :array, default: [], banner: "field:type field:type"
12
12
 
13
- desc "Generate a Rails-first Ruflet resource view for an existing model."
14
-
15
- def create_ruflet_resource_view
16
- create_file(
17
- File.join(destination_root, scaffold_view_path),
18
- Ruflet::Rails::InstallSupport.scaffold_view_template(
19
- model_name: model_name,
20
- attributes: scaffold_attributes
21
- )
22
- )
23
- end
13
+ desc "Generate a Rails-first Ruflet resource component for an existing model."
24
14
 
25
15
  def create_ruflet_resource_component
26
16
  create_file(
@@ -33,15 +23,19 @@ module Ruflet
33
23
  end
34
24
 
35
25
  def print_scaffold_status
36
- say "Ruflet scaffold generated at #{scaffold_view_path}"
37
- say "Ruflet UI component generated at #{scaffold_component_path}"
38
- say "The generated view owns the resource logic; the generated component owns the UI."
26
+ say "Ruflet resource component generated at #{scaffold_component_path}"
27
+ say "Mount it in config/routes.rb:"
28
+ say " mount Ruflet::Rails.web_app(view: #{scaffold_component_class.inspect}), at: \"/#{scaffold_route_segment}\""
39
29
  end
40
30
 
41
31
  private
42
32
 
43
- def scaffold_view_path
44
- Ruflet::Rails::InstallSupport.scaffold_view_path(model_name)
33
+ def scaffold_component_class
34
+ "#{model_name.to_s.camelize}Component"
35
+ end
36
+
37
+ def scaffold_route_segment
38
+ model_name.to_s.underscore.pluralize
45
39
  end
46
40
 
47
41
  def scaffold_component_path
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruflet
4
+ module Rails
5
+ # Resolve Rails assets to absolute URLs the Flutter client can load over
6
+ # HTTP, so server-driven UI can show app images:
7
+ #
8
+ # image(src: Ruflet::Rails.asset_url("logo.png"))
9
+ # image(src: Ruflet::Rails.image_url("brand/header.png"), fit: "cover")
10
+ #
11
+ # The *path* comes from the Rails asset pipeline — digested in production
12
+ # (/assets/logo-<digest>.png), plain otherwise — so it survives
13
+ # fingerprinting and CDNs. The *host* is resolved, in order, from:
14
+ #
15
+ # 1. an explicit `host:` argument
16
+ # 2. Ruflet::Rails.config.backend_url
17
+ # 3. the host the client connected on (the live WebSocket request)
18
+ #
19
+ # The client is a separate device (simulator, phone, browser), so a bare
20
+ # "/assets/..." path would not resolve — the URL must be absolute. If Rails
21
+ # already has an asset_host/CDN configured, the pipeline returns an absolute
22
+ # URL and it is used unchanged. A value that is already a full URL passes
23
+ # through untouched.
24
+ module_function
25
+
26
+ # The base URL the Flutter client uses to reach this Rails app — the single
27
+ # source of truth for asset URLs, the build-time RUFLET_URL define and the
28
+ # desktop launcher. A Rails Ruflet app always needs one, so this always
29
+ # resolves to a usable value:
30
+ #
31
+ # 1. an explicit host: argument
32
+ # 2. Ruflet::Rails.config.backend_url (set it in config/initializers/ruflet.rb)
33
+ # 3. the host the client connected on (the live WebSocket request)
34
+ #
35
+ # Returns "" only when none of those are available (e.g. a build with no
36
+ # configured backend_url) — set config.backend_url to cover that case.
37
+ def backend_url(host: nil)
38
+ candidate = host || config.backend_url
39
+ candidate = request_base_url if candidate.to_s.strip.empty?
40
+ candidate.to_s.strip.sub(%r{/+\z}, "")
41
+ end
42
+
43
+ def asset_url(source, host: nil)
44
+ raw = source.to_s
45
+ return raw if absolute_url?(raw)
46
+
47
+ path = asset_pipeline_path(raw)
48
+ return path if absolute_url?(path)
49
+
50
+ base = backend_url(host: host)
51
+ base.empty? ? path : "#{base}#{path}"
52
+ end
53
+
54
+ # Readability alias for image sources — identical resolution.
55
+ def image_url(source, host: nil)
56
+ asset_url(source, host: host)
57
+ end
58
+
59
+ def asset_pipeline_path(source)
60
+ ::ActionController::Base.helpers.asset_path(source)
61
+ rescue StandardError
62
+ source.start_with?("/") ? source : "/#{source}"
63
+ end
64
+ private_class_method :asset_pipeline_path
65
+
66
+ # Derive scheme://host from the live WebSocket request env so the URL points
67
+ # back at the exact host the client reached — the one address guaranteed to
68
+ # be reachable from that device.
69
+ def request_base_url
70
+ env = Protocol::Context.current_env
71
+ return nil unless env
72
+
73
+ host = env["HTTP_X_FORWARDED_HOST"] || env["HTTP_HOST"]
74
+ return nil if host.to_s.strip.empty?
75
+
76
+ scheme = (env["HTTP_X_FORWARDED_PROTO"] || env["rack.url_scheme"] || "http").to_s.split(",").first.to_s.strip
77
+ scheme = "http" if scheme.empty?
78
+ "#{scheme}://#{host}"
79
+ end
80
+ private_class_method :request_base_url
81
+
82
+ def absolute_url?(value)
83
+ !(value.to_s =~ %r{\A[a-z][a-z0-9+.-]*://}i).nil?
84
+ end
85
+ private_class_method :absolute_url?
86
+ end
87
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruflet
4
+ module Rails
5
+ # Central configuration for ruflet_rails.
6
+ #
7
+ # An install needs no config/initializers/ruflet.rb: routes mount
8
+ # app/views/ruflet/main.rb explicitly and build metadata is read from
9
+ # ruflet.yaml. Add an initializer only to override these settings:
10
+ #
11
+ # Ruflet::Rails.configure do |config|
12
+ # # Runtime / server
13
+ # config.backend_url = Rails.env.production? ? "https://example.com" : "http://localhost:3000"
14
+ #
15
+ # # App metadata (ruflet.yaml → app:)
16
+ # config.app_name = "My App"
17
+ #
18
+ # # Services (ruflet.yaml → services:)
19
+ # config.services = []
20
+ #
21
+ # # Assets (ruflet.yaml → assets:)
22
+ # config.splash_screen = Rails.root.join("app/assets/images/splash.png")
23
+ # config.splash_dark = Rails.root.join("app/assets/images/splash_dark.png")
24
+ # config.icon_launcher = Rails.root.join("app/assets/images/icon.png")
25
+ # config.icon_android = Rails.root.join("app/assets/images/icon_android.png")
26
+ # config.icon_ios = Rails.root.join("app/assets/images/icon_ios.png")
27
+ # config.icon_web = Rails.root.join("app/assets/images/icon_web.png")
28
+ # config.icon_windows = Rails.root.join("app/assets/images/icon_windows.png")
29
+ # config.icon_macos = Rails.root.join("app/assets/images/icon_macos.png")
30
+ #
31
+ # # Build options (ruflet.yaml → build:)
32
+ # config.splash_color = "#FFFFFF"
33
+ # config.splash_dark_color = "#000000"
34
+ # config.icon_background = "#FFFFFF"
35
+ # config.theme_color = "#FFFFFF"
36
+ # end
37
+ class Configuration
38
+ # --- Runtime / server ---
39
+
40
+ # Backend base URL. Used as --dart-define=RUFLET_URL at build time
41
+ # and by the desktop launcher. Replaces ruflet.yaml → app.backend_url.
42
+ attr_accessor :backend_url
43
+
44
+ # --- App metadata (ruflet.yaml → app:) ---
45
+
46
+ attr_accessor :app_name
47
+
48
+ # --- Services (ruflet.yaml → services:) ---
49
+
50
+ attr_accessor :services
51
+
52
+ # --- Assets (ruflet.yaml → assets:) ---
53
+
54
+ attr_accessor :splash_screen
55
+ attr_accessor :splash_dark
56
+ attr_accessor :icon_launcher
57
+ attr_accessor :icon_android
58
+ attr_accessor :icon_ios
59
+ attr_accessor :icon_web
60
+ attr_accessor :icon_windows
61
+ attr_accessor :icon_macos
62
+
63
+ # --- Build options (ruflet.yaml → build:) ---
64
+
65
+ attr_accessor :splash_color
66
+ attr_accessor :splash_dark_color
67
+ attr_accessor :icon_background
68
+ attr_accessor :theme_color
69
+
70
+ def initialize
71
+ @backend_url = nil
72
+ @app_name = nil
73
+ @services = []
74
+ end
75
+
76
+ # Serialises config to the ruflet.yaml hash structure so the CLI can
77
+ # consume it without a yaml file on disk (written to a temp file by
78
+ # the Railtie's build task).
79
+ def to_ruflet_yaml_hash
80
+ hash = {}
81
+
82
+ app = {}
83
+ app["name"] = @app_name if @app_name
84
+ app["backend_url"] = @backend_url if @backend_url
85
+ hash["app"] = app unless app.empty?
86
+
87
+ hash["services"] = Array(@services)
88
+
89
+ assets = {}
90
+ assets["splash_screen"] = @splash_screen.to_s if @splash_screen
91
+ assets["splash_dark"] = @splash_dark.to_s if @splash_dark
92
+ assets["icon_launcher"] = @icon_launcher.to_s if @icon_launcher
93
+ assets["icon_android"] = @icon_android.to_s if @icon_android
94
+ assets["icon_ios"] = @icon_ios.to_s if @icon_ios
95
+ assets["icon_web"] = @icon_web.to_s if @icon_web
96
+ assets["icon_windows"] = @icon_windows.to_s if @icon_windows
97
+ assets["icon_macos"] = @icon_macos.to_s if @icon_macos
98
+ hash["assets"] = assets unless assets.empty?
99
+
100
+ build = {}
101
+ build["splash_color"] = @splash_color if @splash_color
102
+ build["splash_dark_color"] = @splash_dark_color if @splash_dark_color
103
+ build["icon_background"] = @icon_background if @icon_background
104
+ build["theme_color"] = @theme_color if @theme_color
105
+ hash["build"] = build unless build.empty?
106
+
107
+ hash
108
+ end
109
+ end
110
+ end
111
+ end