ruflet_rails 0.0.7 → 0.0.8

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: 91f92e0fef72cbb500ba43ac12d81590bac485bb9cf4642c93563aab2736811f
4
- data.tar.gz: 609e6b2720465ca414898482223d561d264ddca837d717e07a2b54c1f874df6f
3
+ metadata.gz: aefcbfd0f43a7987f825223bcb14311816dc94a4e81039d6d955d504b51f01ae
4
+ data.tar.gz: d274aedaf03e16b8298b2fabd68a3dbd14aea153ac0abf0664a5198244aaf4df
5
5
  SHA512:
6
- metadata.gz: 1f9e56edf7b86fccf776d7bfc8ee630ce501f42fde7854a9bb4f660a31ef85dfbe0e1a50ebe7a5c09dfe307e80eb88ca561a77d69777006a00810c34770dbba5
7
- data.tar.gz: 23792bdae076841ddc0ed099475a25ab4bfd0240a244e90a0d71eb3d011c11c8e67e5610102bf8475a86e35dae2ea2aac6c8f58d1b75c1a8bf23ddf9682da60e
6
+ metadata.gz: 5d8fb4d2f5dfc8ab871999d0aed10672fc164790a13c4a9286ef079ff4f7777820b01e01188d3fcc605919d60e8ee79e8a085805c03ee5446805ab92d2f7dc56
7
+ data.tar.gz: 061b1e5641f03da87372ed31f47b841c9c9ab0f16a0cbd05639f8bfbf7aec5248879f2b8b608fb388c68f0dbe599976a2cfd1271005cf7883570fd58f511e5f8
data/README.md CHANGED
@@ -16,20 +16,24 @@ gem "ruflet_rails", ">= 0.0.5"
16
16
 
17
17
  ```bash
18
18
  bin/rails generate ruflet:install
19
+ bin/rails generate ruflet:install --web
20
+ bin/rails generate ruflet:install --desktop
21
+ bin/rails generate ruflet:install --web --desktop
19
22
  ```
20
23
 
21
24
  This generator will:
22
- - create `app/mobile/main.rb`
25
+ - create `app/views/ruflet/main.rb`
23
26
  - create `ruflet.yaml`
24
27
  - add the Ruflet mount route to `config/routes.rb`
25
- - copy/configure `ruflet_client` when the template is available locally
28
+ - download prebuilt clients from GitHub releases when `--web`, `--desktop`, or
29
+ `--client=web|desktop|all` is used
26
30
 
27
31
  Generated `ruflet.yaml`:
28
32
 
29
33
  ```yaml
30
34
  app:
31
35
  name: My App
32
- ruflet_client_url: ""
36
+ backend_url: http://localhost:3000
33
37
 
34
38
  services: []
35
39
 
@@ -55,10 +59,147 @@ bundle exec rake ruflet:build[ios]
55
59
  bundle exec rake ruflet:build[aab]
56
60
  ```
57
61
 
62
+ Rails web builds are published to `public/ruflet` and served by Rails at `/ruflet/`.
63
+
64
+ `desktop` is also accepted as a host-platform alias:
65
+
66
+ ```bash
67
+ bundle exec rake ruflet:build[desktop]
68
+ ```
69
+
70
+ Rails desktop builds are server-driven. The built desktop app connects back to the
71
+ Rails backend configured in `ruflet.yaml`; it does not package a self-contained
72
+ Ruby runtime.
73
+
74
+ Plain Rails dev server commands do not launch the desktop app. Request a desktop
75
+ client explicitly with a flag:
76
+
77
+ ```bash
78
+ bin/dev --desktop
79
+ bin/rails server --desktop
80
+ bin/rails s --desktop
81
+ ```
82
+
83
+ ## Update prebuilt clients
84
+
85
+ Uses the same GitHub release assets as `ruflet update`:
86
+
87
+ ```bash
88
+ bundle exec rake ruflet:update[web]
89
+ bundle exec rake ruflet:update[desktop]
90
+ bundle exec rake ruflet:update[all]
91
+ ```
92
+
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.
95
+
96
+ ## Install mobile build
97
+
98
+ Uses the same install pipeline as `ruflet install`:
99
+
100
+ ```bash
101
+ bundle exec rake ruflet:install
102
+ bundle exec rake ruflet:install[DEVICE_ID]
103
+ ```
104
+
105
+ ## Ruflet resource scaffolds
106
+
107
+ Generate a Ruflet CRUD view for an existing Rails model:
108
+
109
+ ```bash
110
+ bin/rails generate ruflet:scaffold Post
111
+ ```
112
+
113
+ The scaffold creates generated app code the Rails developer can own and edit:
114
+
115
+ ```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
+ # app/views/ruflet/components/posts/post_component.rb
141
+ class PostComponent < Ruflet::Rails::ResourceComponent
142
+ def render
143
+ # edit the scaffold layout here
144
+ end
145
+ end
146
+ ```
147
+
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.
153
+
154
+ ## Ruflet model forms
155
+
156
+ Generate only a reusable Ruflet form for an existing Rails model:
157
+
158
+ ```bash
159
+ bin/rails generate ruflet:form Post
160
+ ```
161
+
162
+ When no fields are passed, the generator reads the model columns and skips `id`,
163
+ `created_at`, and `updated_at`. You can also pass fields explicitly:
164
+
165
+ ```bash
166
+ bin/rails generate ruflet:form Post title:string body:text published:boolean category:references
167
+ ```
168
+
169
+ Foreign keys and references, such as `category:references` or `user_id`, render
170
+ as Ruflet dropdowns populated from the associated Rails model.
171
+
172
+ The generated form lives at `app/views/ruflet/components/posts/post_form.rb`.
173
+ The form generator creates `app/views/ruflet/components/application_component.rb`
174
+ when that base component does not already exist.
175
+
176
+ ## Shared Ruflet components
177
+
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:
180
+
181
+ ```ruby
182
+ # app/views/ruflet/components/page_title_component.rb
183
+ class PageTitleComponent < ApplicationComponent
184
+ def render(value)
185
+ text(value, size: desktop? || web? ? 28 : 24, weight: "bold")
186
+ end
187
+ end
188
+ ```
189
+
190
+ ```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
196
+ end
197
+ ```
198
+
58
199
  ## Manual usage
59
200
 
60
201
  ```ruby
61
- # app/mobile/main.rb
202
+ # app/views/ruflet/main.rb
62
203
  require "ruflet"
63
204
 
64
205
  Ruflet.run do |page|
@@ -67,4 +208,10 @@ Ruflet.run do |page|
67
208
  end
68
209
  ```
69
210
 
70
- Connect mobile to Rails base URL, e.g. `http://10.0.2.2:3000`.
211
+ Mount it in Rails:
212
+
213
+ ```ruby
214
+ match "/ws", to: Ruflet::Rails.app(Rails.root.join("app/views/ruflet/main.rb")), via: :all
215
+ ```
216
+
217
+ The same mounted Ruby entrypoint drives mobile, web, and desktop clients.
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "active_support/core_ext/string/inflections"
5
+ require "ruflet/rails/install_support"
6
+
7
+ module Ruflet
8
+ module Generators
9
+ class FormGenerator < ::Rails::Generators::Base
10
+ argument :model_name, type: :string
11
+ argument :attributes, type: :array, default: [], banner: "field:type field:type"
12
+
13
+ desc "Generate only a Ruflet form for an existing Rails model."
14
+
15
+ def create_application_component
16
+ target = File.join(destination_root, Ruflet::Rails::InstallSupport.application_component_path)
17
+ return if File.exist?(target)
18
+
19
+ create_file target, Ruflet::Rails::InstallSupport.application_component_template
20
+ end
21
+
22
+ def create_ruflet_form
23
+ target = File.join(destination_root, form_view_path)
24
+
25
+ create_file(
26
+ target,
27
+ Ruflet::Rails::InstallSupport.form_view_template(
28
+ model_name: model_name,
29
+ attributes: form_attributes
30
+ )
31
+ )
32
+ end
33
+
34
+ def print_form_status
35
+ names = Ruflet::Rails::InstallSupport.model_names(model_name)
36
+ say "Ruflet form generated at #{form_view_path}"
37
+ say "Call #{names[:class_name]}Form.render(page, record: #{names[:class_name]}.new) from any Ruflet view."
38
+ end
39
+
40
+ private
41
+
42
+ def form_view_path
43
+ Ruflet::Rails::InstallSupport.form_view_path(model_name)
44
+ end
45
+
46
+ def form_attributes
47
+ return attributes unless attributes.empty?
48
+
49
+ model_class = model_name.to_s.camelize.safe_constantize
50
+ inferred = Ruflet::Rails::InstallSupport.attributes_from_model(model_class)
51
+ inferred.empty? ? attributes : inferred
52
+ end
53
+ end
54
+ end
55
+ end
@@ -6,50 +6,88 @@ require "ruflet/rails/install_support"
6
6
  module Ruflet
7
7
  module Generators
8
8
  class InstallGenerator < ::Rails::Generators::Base
9
+ class_option :frontend, type: :boolean, default: false, desc: "Install the Rails-hosted Ruflet web client"
10
+ class_option :web, type: :boolean, default: false, desc: "Install the Rails-hosted Ruflet web client"
11
+ class_option :desktop, type: :boolean, default: false, desc: "Download the server-driven desktop Ruflet client"
12
+ class_option :client, type: :string, default: nil, desc: "Download prebuilt client from GitHub releases: web, desktop, all, or none"
13
+
9
14
  desc "Install Ruflet into a Rails app."
10
15
 
11
- def create_mobile_entrypoint
12
- target = File.join(destination_root, "app/mobile/main.rb")
16
+ def create_app_entrypoint
17
+ target = File.join(destination_root, entrypoint_path)
13
18
  return if File.exist?(target)
14
19
 
15
- FileUtils.mkdir_p(File.dirname(target))
16
- File.write(
17
- target,
18
- Ruflet::Rails::InstallSupport.default_mobile_app_template(app_title: app_name)
19
- )
20
+ create_file target, Ruflet::Rails::InstallSupport.default_app_template(app_title: app_name)
20
21
  end
21
22
 
22
23
  def create_ruflet_yaml
23
24
  target = File.join(destination_root, "ruflet.yaml")
24
25
  return if File.exist?(target)
25
26
 
26
- File.write(
27
- target,
28
- Ruflet::Rails::InstallSupport.default_ruflet_yaml(app_name: app_name)
29
- )
30
- end
31
-
32
- def copy_client_template
33
- copied = Ruflet::Rails::InstallSupport.copy_ruflet_client_template(destination_root)
34
- say_status(:warn, "ruflet_client template not found; add it manually before building", :yellow) unless copied
35
- end
36
-
37
- def configure_client_template
38
- Ruflet::Rails::InstallSupport.configure_ruflet_client(destination_root)
27
+ create_file target, Ruflet::Rails::InstallSupport.default_ruflet_yaml(app_name: app_name)
39
28
  end
40
29
 
41
30
  def add_routes
42
31
  target = File.join(destination_root, "config/routes.rb")
43
32
  return unless File.file?(target)
44
33
 
45
- route = Ruflet::Rails::InstallSupport.route_snippet
34
+ route = Ruflet::Rails::InstallSupport.route_snippet(entrypoint: entrypoint_path)
46
35
  return if File.read(target).include?(route)
47
36
 
48
37
  insert_into_file target, " #{route}\n", after: /Rails\.application\.routes\.draw do\s*\n/
49
38
  end
50
39
 
40
+ def add_desktop_flag_to_binstubs
41
+ return unless desktop_requested?
42
+
43
+ install_desktop_flag_bootstrap("bin/rails")
44
+ install_desktop_flag_bootstrap("bin/dev")
45
+ end
46
+
47
+ def download_prebuilt_client
48
+ client = requested_client
49
+ @web_client_published = false
50
+ return if client == "none"
51
+
52
+ if %w[web all].include?(client)
53
+ @web_client_published = Ruflet::Rails::InstallSupport.publish_web_build(destination_root)
54
+ if @web_client_published
55
+ say "Ruflet web build copied from build/web to public/#{Ruflet::Rails::InstallSupport.default_web_public_path}"
56
+ return if client == "web"
57
+ end
58
+ end
59
+
60
+ require "ruflet/cli"
61
+ exit_code = Dir.chdir(destination_root) do
62
+ Ruflet::CLI.command_update([client])
63
+ end
64
+ unless exit_code.to_i.zero?
65
+ @client_download_failed = true
66
+ say_status(:warn, "Ruflet client download failed; install files were generated and build/update steps are printed below", :yellow)
67
+ return
68
+ end
69
+
70
+ return unless %w[web all].include?(client)
71
+
72
+ published = Ruflet::Rails::InstallSupport.publish_prebuilt_web_client(destination_root)
73
+ @web_client_published = published
74
+ if published
75
+ say "Ruflet web client published at /#{Ruflet::Rails::InstallSupport.default_web_public_path}/"
76
+ else
77
+ say_status(:warn, "Ruflet web client downloaded, but no prebuilt web index.html was found to publish", :yellow)
78
+ end
79
+ rescue StandardError => e
80
+ @client_download_failed = true
81
+ say_status(:warn, "Ruflet client download failed: #{e.class}: #{e.message}", :yellow)
82
+ end
83
+
51
84
  def print_install_status
52
- say "ruflet.yaml generated"
85
+ Ruflet::Rails::InstallSupport.install_next_steps(
86
+ target: install_target,
87
+ entrypoint: entrypoint_path,
88
+ client: requested_client,
89
+ web_published: !!@web_client_published
90
+ ).each { |line| say line }
53
91
  end
54
92
 
55
93
  private
@@ -57,6 +95,57 @@ module Ruflet
57
95
  def app_name
58
96
  File.basename(destination_root).gsub(/[_-]+/, " ").split.map(&:capitalize).join(" ")
59
97
  end
98
+
99
+ def entrypoint_path
100
+ Ruflet::Rails::InstallSupport.default_entrypoint_path
101
+ end
102
+
103
+ def requested_client
104
+ explicit = options[:client].to_s.strip.downcase
105
+ unless explicit.empty?
106
+ raise Thor::Error, "--client must be web, desktop, all, or none" unless %w[web desktop all none].include?(explicit)
107
+
108
+ return explicit
109
+ end
110
+
111
+ wants_web = options[:frontend] || options[:web]
112
+ wants_desktop = options[:desktop]
113
+ return "all" if wants_web && wants_desktop
114
+ return "web" if wants_web
115
+ return "desktop" if wants_desktop
116
+
117
+ "none"
118
+ end
119
+
120
+ def desktop_requested?
121
+ %w[desktop all].include?(requested_client)
122
+ end
123
+
124
+ def install_target
125
+ "ruflet"
126
+ end
127
+
128
+ def install_desktop_flag_bootstrap(relative_path)
129
+ target = File.join(destination_root, relative_path)
130
+ return unless File.file?(target)
131
+
132
+ source = File.read(target)
133
+ return if source.include?("ruflet_rails desktop flag")
134
+
135
+ bootstrap =
136
+ if source.start_with?("#!/usr/bin/env ruby") || source.start_with?("#!/usr/bin/ruby")
137
+ if File.basename(relative_path) == "dev"
138
+ Ruflet::Rails::InstallSupport.ruby_dev_desktop_flag_bootstrap
139
+ else
140
+ Ruflet::Rails::InstallSupport.ruby_desktop_flag_bootstrap
141
+ end
142
+ elsif source.start_with?("#!/usr/bin/env sh") || source.start_with?("#!/bin/sh")
143
+ Ruflet::Rails::InstallSupport.shell_desktop_flag_bootstrap
144
+ end
145
+ return unless bootstrap
146
+
147
+ insert_into_file target, "#{bootstrap}\n", after: /\A#!.*\n/
148
+ end
60
149
  end
61
150
  end
62
151
  end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "active_support/core_ext/string/inflections"
5
+ require "ruflet/rails/install_support"
6
+
7
+ module Ruflet
8
+ module Generators
9
+ class ScaffoldGenerator < ::Rails::Generators::Base
10
+ argument :model_name, type: :string
11
+ argument :attributes, type: :array, default: [], banner: "field:type field:type"
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
24
+
25
+ def create_ruflet_resource_component
26
+ create_file(
27
+ File.join(destination_root, scaffold_component_path),
28
+ Ruflet::Rails::InstallSupport.scaffold_component_template(
29
+ model_name: model_name,
30
+ attributes: scaffold_attributes
31
+ )
32
+ )
33
+ end
34
+
35
+ 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."
39
+ end
40
+
41
+ private
42
+
43
+ def scaffold_view_path
44
+ Ruflet::Rails::InstallSupport.scaffold_view_path(model_name)
45
+ end
46
+
47
+ def scaffold_component_path
48
+ Ruflet::Rails::InstallSupport.scaffold_component_path(model_name)
49
+ end
50
+
51
+ def scaffold_attributes
52
+ return attributes unless attributes.empty?
53
+
54
+ model_class = model_name.to_s.camelize.safe_constantize
55
+ inferred = Ruflet::Rails::InstallSupport.attributes_from_model(model_class)
56
+ inferred.empty? ? attributes : inferred
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Ruflet
6
+ module Rails
7
+ module DesktopLauncher
8
+ module_function
9
+
10
+ def launch_once(root:, argv: ARGV, env: ENV, wait: 1.0)
11
+ return false unless desktop_enabled?(root: root, argv: argv, env: env)
12
+ return false unless env["RUFLET_RAILS_DESKTOP_SERVER"].to_s.downcase == "true" || rails_server_command?(argv)
13
+ return false if env["RUFLET_RAILS_DESKTOP"].to_s.downcase == "false"
14
+ return false if launched?
15
+
16
+ @launched = true
17
+ url = backend_url(root: root, argv: argv, env: env)
18
+ Thread.new do
19
+ sleep wait.to_f if wait.to_f.positive?
20
+ launch(url, root: root)
21
+ end
22
+ true
23
+ end
24
+
25
+ def launch(url, root:)
26
+ require "ruflet/cli"
27
+
28
+ Dir.chdir(root.to_s) do
29
+ wait_for_backend(url)
30
+ if Ruflet::CLI.respond_to?(:launch_desktop_client, true)
31
+ Ruflet::CLI.send(:launch_desktop_client, url)
32
+ else
33
+ warn "Ruflet desktop launcher is unavailable in this ruflet version."
34
+ nil
35
+ end
36
+ end
37
+ rescue StandardError => e
38
+ warn "Failed to launch Ruflet desktop client: #{e.class}: #{e.message}"
39
+ nil
40
+ end
41
+
42
+ def backend_url(root:, argv: ARGV, env: ENV)
43
+ explicit = env["RUFLET_BACKEND_URL"].to_s.strip
44
+ return explicit unless explicit.empty?
45
+
46
+ config_url = ruflet_yaml_backend_url(root)
47
+ cli_port = rails_server_port(argv)
48
+ return replace_url_port(config_url, cli_port) if cli_port
49
+ return config_url if config_url
50
+
51
+ "http://localhost:#{env["PORT"].to_s.empty? ? 3000 : env["PORT"]}"
52
+ end
53
+
54
+ def desktop_enabled?(root:, argv: ARGV, env: ENV)
55
+ return true if env["RUFLET_RAILS_DESKTOP_SERVER"].to_s.downcase == "true"
56
+ return true if env["RUFLET_RAILS_DESKTOP"].to_s.downcase == "true"
57
+ return true if Array(argv).map(&:to_s).include?("--desktop")
58
+
59
+ false
60
+ end
61
+
62
+ def rails_server_command?(argv)
63
+ command = Array(argv).find { |value| !value.to_s.start_with?("-") }.to_s
64
+ %w[server s].include?(command)
65
+ end
66
+
67
+ def rails_server_port(argv)
68
+ values = Array(argv).map(&:to_s)
69
+ values.each_with_index do |value, index|
70
+ return values[index + 1].to_i if %w[-p --port].include?(value) && values[index + 1].to_i.positive?
71
+ return value.split("=", 2).last.to_i if value.start_with?("--port=") && value.split("=", 2).last.to_i.positive?
72
+ end
73
+ nil
74
+ end
75
+
76
+ def ruflet_yaml_backend_url(root)
77
+ path = %w[ruflet.yaml ruflet.yml].map { |name| File.join(root.to_s, name) }.find { |candidate| File.file?(candidate) }
78
+ return nil unless path
79
+
80
+ require "yaml"
81
+ config = YAML.safe_load(File.read(path), aliases: true) || {}
82
+ value = config.dig("app", "backend_url") || config["backend_url"] || config["server_url"]
83
+ value.to_s.strip.empty? ? nil : value.to_s.strip
84
+ rescue StandardError
85
+ nil
86
+ end
87
+
88
+ def replace_url_port(url, port)
89
+ return "http://localhost:#{port}" if url.to_s.strip.empty?
90
+
91
+ uri = URI.parse(url)
92
+ uri.port = port.to_i
93
+ uri.to_s
94
+ rescue URI::InvalidURIError
95
+ "http://localhost:#{port}"
96
+ end
97
+
98
+ def wait_for_backend(url)
99
+ return unless Ruflet::CLI.respond_to?(:wait_for_server_boot, true)
100
+
101
+ uri = URI.parse(url)
102
+ Ruflet::CLI.send(:wait_for_server_boot, uri.port) if uri.port
103
+ rescue URI::InvalidURIError
104
+ nil
105
+ end
106
+
107
+ def launched?
108
+ !!@launched
109
+ end
110
+
111
+ def reset!
112
+ @launched = false
113
+ end
114
+ end
115
+ end
116
+ end