Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
4aee5e0
Ship the HTML DSL in ruflet_rails
AdamMusa Jul 31, 2026
8b5ce8f
Support iOS device and simulator installs
AdamMusa Aug 5, 2026
f95a786
Format extension event payload values
AdamMusa Aug 5, 2026
69cd91e
Let Flutter choose the install device
AdamMusa Aug 5, 2026
0a897be
Request microphone before audio recording
AdamMusa Aug 5, 2026
017ffa3
Animate and replay audio recordings
AdamMusa Aug 5, 2026
7ddfb47
Prune undeclared client capabilities
AdamMusa Aug 5, 2026
6e673cc
Keep repeated Rails recordings active
AdamMusa Aug 5, 2026
7301758
Do not block recording on player release
AdamMusa Aug 5, 2026
cd98d50
Reuse recorder preflight results
AdamMusa Aug 5, 2026
78c8410
Align Ruflet Rails endpoint documentation
AdamMusa Aug 6, 2026
da5e49d
Merge branch 'ruflet_on_rails' into claude/ruflet-rails-erb-native-d1…
AdamMusa Aug 7, 2026
f9675fd
Keep declarative service markup out of native invoke arguments
AdamMusa Aug 7, 2026
7f2649a
Let native screens be named by path instead of by URL
AdamMusa Aug 7, 2026
95ce6ae
Resolve media URLs against the client's host, and reject host-less ones
AdamMusa Aug 7, 2026
17264cd
Resolve native screens by convention instead of by declaration
AdamMusa Aug 7, 2026
0dafb36
WIP: render ERB screens without a request cycle
AdamMusa Aug 7, 2026
2e47f12
Render every screen through ActionView, with helpers and partials
AdamMusa Aug 7, 2026
dbfbefe
Skip the rebuild when an action leaves the screen unchanged
AdamMusa Aug 7, 2026
dbe39d0
Render the compiled template directly, and reload it when it changes
AdamMusa Aug 8, 2026
7c1792c
Stop screen renders narrating themselves into the log
AdamMusa Aug 8, 2026
45f006b
Keep session working without a request behind it
AdamMusa Aug 8, 2026
dbcc768
Run a screen's own action before re-rendering it
AdamMusa Aug 8, 2026
43455c1
Leave only the three ways to build a Ruflet app on Rails
AdamMusa Aug 8, 2026
945288f
Remove what the request cycle left behind
AdamMusa Aug 8, 2026
3c00ff3
Drop arguments and prose the rewrite left behind
AdamMusa Aug 8, 2026
8166434
Drop the last two orphans
AdamMusa Aug 8, 2026
efcdfdc
Stop offering settings nobody sets
AdamMusa Aug 8, 2026
da8a712
Stop describing screens as HTTP
AdamMusa Aug 8, 2026
15cc737
Merge remote-tracking branch 'refs/remotes/origin/main' into claude/r…
AdamMusa Aug 8, 2026
e500753
Never send a control name as a service argument
AdamMusa Aug 9, 2026
4cc9206
Cover the service argument rule with a test
AdamMusa Aug 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,11 +232,19 @@ the import and the `Extension()` call from the package name, which every
extension package follows. `branch` and `tag` work in place of `ref`, and `path`
takes a local checkout instead of a repository.

At build time the managed Flutter client keeps the core `flet` package plus
only the local packages selected by `extensions:` and `services:`. Packages
removed for one build are restored automatically from the client template if a
later configuration declares them again.

### Protected capabilities are explicit

Camera, microphone, location, and motion access are declared in
`services.yaml`. Ruflet activates the required client integrations and writes
the matching Android and iOS permission descriptions during the build.
the matching Android and iOS permission descriptions during the build. Ruflet
also removes its previously managed permissions when the capability is no
longer declared, so stale camera, microphone, location, or motion access does
not ship in the next build.

```yaml
services:
Expand Down Expand Up @@ -396,6 +404,9 @@ ruflet build apk --self
ruflet build ios --self
```

`ruflet build ios --self` prepares both the physical-device and simulator app
bundles. You do not need a separate simulator build command.

Install the latest mobile build on a connected device:

```bash
Expand All @@ -404,6 +415,10 @@ ruflet install
ruflet install --device DEVICE_ID
```

With one compatible mobile target connected, plain `ruflet install` selects it
automatically; `--device` remains available when several matching targets are
connected.

Ruflet uses the canonical external Flutter template, resolves packages,
applies app identity, generates splash screens and launcher icons, prepares
native dependencies, and then runs the platform build.
Expand Down
85 changes: 80 additions & 5 deletions packages/ruflet/lib/ruflet/cli/build_command.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ module BuildCommand
"qrcode_scanner" => { package: "ruflet_qrcode_scanner", alias: "ruflet_qrcode_scanner" },
"rive" => { package: "flet_rive", alias: "ruflet_rive" },
"secure_storage" => { package: "flet_secure_storage", alias: "ruflet_secure_storage" },
"spinkit" => { package: "flet_spinkit", alias: "ruflet_spinkit" },
"video" => { package: "flet_video", alias: "ruflet_video" },
"webview" => { package: "flet_webview", alias: "ruflet_webview" }
}.freeze
Expand All @@ -53,6 +54,14 @@ module BuildCommand
"location" => "NSLocationWhenInUseUsageDescription",
"motion" => "NSMotionUsageDescription"
}.freeze
MANAGED_ANDROID_PERMISSIONS = (
ANDROID_SERVICE_PERMISSIONS.values.flatten +
%w[android.permission.FLASHLIGHT android.permission.MODIFY_AUDIO_SETTINGS]
).uniq.freeze
MANAGED_IOS_USAGE_KEYS = (
IOS_SERVICE_USAGE_KEYS.values +
%w[NSLocationAlwaysAndWhenInUseUsageDescription NSPhotoLibraryUsageDescription]
).uniq.freeze
# Clients that are told which server to use at launch rather than at build
# time, so they may be built without a configured backend_url.
RUNTIME_RESOLVED_BACKEND_PLATFORMS = %w[web macos windows linux].freeze
Expand Down Expand Up @@ -156,6 +165,25 @@ def command_build(args)

build_note("Running Flutter #{build_args.join(' ')}")
ok = run_external_command(command_env, tools[:flutter], *build_args, chdir: client_dir, unbundled: true)

# A physical iPhone app and a simulator app are different Apple
# products, but Ruflet users should not have to learn that distinction.
# Keep both outputs behind the ordinary self-contained iOS build so the
# following `ruflet install` can target whichever mobile device is
# connected. An explicit --simulator remains a simulator-only build.
if ok && self_contained && requested_platform == "ios" && !build_args.include?("--simulator")
simulator_args = build_args.dup
simulator_args.delete("--codesign")
simulator_args.insert(simulator_args.index("ios") + 1, "--simulator")
stage_ios_simulator_ruby_runtime(client_dir, simulator_args, verbose: !!verbose)
build_log(verbose, "command=#{([tools[:flutter]] + simulator_args).join(' ')}")
build_note("Running Flutter #{simulator_args.join(' ')}")
ok = run_external_command(
command_env, tools[:flutter], *simulator_args,
chdir: client_dir, unbundled: true
)
end

export_platform_build_outputs(client_dir, platform, verbose: !!verbose) if ok
ok ? 0 : 1
end
Expand Down Expand Up @@ -1554,6 +1582,7 @@ def apply_service_extension_config(client_dir, config = {}, self_contained: @ruf
prune_client_pubspec(pubspec_path, extension_packages)
sync_external_extension_dependencies(pubspec_path, external)
end
sync_client_package_directories(client_dir, extension_packages)
client_entrypoint_paths(client_dir).each do |entrypoint|
next unless File.file?(entrypoint)

Expand Down Expand Up @@ -1748,8 +1777,6 @@ def apply_native_service_permissions(client_dir, config)
extension_services.each do |service|
entries << { name: service, description: "" } unless entries.any? { |entry| entry[:name] == service }
end
return if entries.empty?

apply_android_service_permissions(client_dir, entries)
apply_ios_service_usage_descriptions(client_dir, entries)
end
Expand All @@ -1759,6 +1786,12 @@ def apply_android_service_permissions(client_dir, entries)
return unless File.file?(path)

content = read_text_file(path)
MANAGED_ANDROID_PERMISSIONS.each do |permission|
content.gsub!(
%r{^\s*<uses-permission\b[^>]*android:name="#{Regexp.escape(permission)}"[^>]*/>\s*\n?},
""
)
end
entries.flat_map { |entry| ANDROID_SERVICE_PERMISSIONS.fetch(entry[:name], []) }.uniq.each do |permission|
next if content.include?(%(android:name="#{permission}"))

Expand All @@ -1772,6 +1805,12 @@ def apply_ios_service_usage_descriptions(client_dir, entries)
return unless File.file?(path)

content = read_text_file(path)
MANAGED_IOS_USAGE_KEYS.each do |key|
content.gsub!(
%r{\s*<key>#{Regexp.escape(key)}</key>\s*<string>.*?</string>}m,
""
)
end
entries.each do |entry|
key = IOS_SERVICE_USAGE_KEYS[entry[:name]]
next unless key
Expand Down Expand Up @@ -1841,8 +1880,6 @@ def sync_client_pubspec_for_runtime_mode(client_dir, self_contained:)
data = YAML.safe_load(read_text_file(pubspec_path), aliases: true) || {}
dependencies = data["dependencies"]
dependencies = data["dependencies"] = {} unless dependencies.is_a?(Hash)
spinkit_dependency = template_client_pubspec_dependencies["flet_spinkit"]
dependencies["flet_spinkit"] = spinkit_dependency if spinkit_dependency
flutter = data["flutter"]
flutter = data["flutter"] = {} unless flutter.is_a?(Hash)
assets = Array(flutter["assets"]).map(&:to_s)
Expand Down Expand Up @@ -1909,7 +1946,6 @@ def refresh_managed_client_template_files(client_dir, verbose: false)
"lib/main.dart",
"lib/main.self.dart",
"lib/main.server.dart",
"lib/ruflet_file_picker_service.dart",
"lib/connection_probe.dart",
"lib/connection_probe_io.dart",
"lib/connection_probe_stub.dart",
Expand All @@ -1934,6 +1970,11 @@ def refresh_managed_client_template_files(client_dir, verbose: false)
FileUtils.cp(source, destination)
build_log(verbose, "refreshed template file #{relative_path}")
end

# Older managed clients carried a macOS-only FilePicker override. Flet's
# core service owns FilePicker now, so this duplicate must not survive a
# template refresh.
FileUtils.rm_f(File.join(client_dir, "lib", "ruflet_file_picker_service.dart"))
end

def write_pubspec_yaml(path, data)
Expand Down Expand Up @@ -2214,6 +2255,40 @@ def prune_client_pubspec(path, selected_packages)
write_pubspec_yaml(path, data)
end

def sync_client_package_directories(client_dir, selected_packages)
packages_root = File.join(client_dir, "flet_packages")
return unless Dir.exist?(packages_root)

kept_packages = (["flet"] + selected_packages).uniq
template_root =
if Ruflet::CLI.respond_to?(:resolve_ruflet_client_template_root, true)
Ruflet::CLI.send(:resolve_ruflet_client_template_root)
end
template_packages_root = template_root && File.join(template_root, "flet_packages")

# A declaration can change between builds. Restore newly selected local
# packages from the immutable template before pruning the old selection.
kept_packages.each do |package_name|
destination = File.join(packages_root, package_name)
next if Dir.exist?(destination)
next unless template_packages_root

source = File.join(template_packages_root, package_name)
next unless Dir.exist?(source)
next if File.expand_path(source) == File.expand_path(destination)

FileUtils.cp_r(source, destination)
end

Dir.children(packages_root).each do |entry|
path = File.join(packages_root, entry)
next unless Dir.exist?(path)
next if kept_packages.include?(entry)

FileUtils.rm_rf(path)
end
end

def sync_client_extension_dependencies(path, selected_packages)
return if selected_packages.empty?

Expand Down
133 changes: 133 additions & 0 deletions packages/ruflet/test/build_package_pruning_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
# frozen_string_literal: true

require_relative "test_helper"
require "fileutils"

class RufletCliBuildPackagePruningTest < Minitest::Test
class DummyBuilder
include Ruflet::CLI::BuildCommand
end

def test_keeps_only_core_and_packages_declared_by_services_and_extensions
builder = DummyBuilder.new

Dir.mktmpdir do |dir|
template_root = File.join(dir, "template")
client_dir = File.join(dir, "client")
package_names = %w[
flet flet_audio_recorder flet_permission_handler flet_spinkit flet_video
]
package_names.each do |name|
FileUtils.mkdir_p(File.join(template_root, "flet_packages", name))
File.write(File.join(template_root, "flet_packages", name, "marker"), name)
FileUtils.mkdir_p(File.join(client_dir, "flet_packages", name))
end
FileUtils.mkdir_p(File.join(client_dir, "flet_packages", "flet_ads"))
FileUtils.mkdir_p(File.join(client_dir, "lib"))
File.write(
File.join(template_root, "pubspec.yaml"),
<<~YAML
dependencies:
flet_audio_recorder:
path: flet_packages/flet_audio_recorder
flet_permission_handler:
path: flet_packages/flet_permission_handler
flet_spinkit:
path: flet_packages/flet_spinkit
flet_video:
path: flet_packages/flet_video
YAML
)
File.write(
File.join(client_dir, "pubspec.yaml"),
"dependencies:\n flet:\n path: flet_packages/flet\n"
)

Ruflet::CLI.stub(:resolve_ruflet_client_template_root, template_root) do
builder.send(
:apply_service_extension_config,
client_dir,
{ "services" => ["microphone"], "extensions" => ["spinkit"] }
)

assert_equal(
%w[flet flet_audio_recorder flet_permission_handler flet_spinkit],
package_directories(client_dir)
)

# A later build with a different declaration restores its package from
# the immutable template before pruning packages no longer selected.
builder.send(
:apply_service_extension_config,
client_dir,
{ "extensions" => ["video"] }
)
assert_equal %w[flet flet_video], package_directories(client_dir)
assert_equal "flet_video", File.read(File.join(client_dir, "flet_packages", "flet_video", "marker"))
end
end
end

def test_native_permissions_are_replaced_by_current_service_declarations
builder = DummyBuilder.new

Dir.mktmpdir do |dir|
manifest = File.join(dir, "android", "app", "src", "main", "AndroidManifest.xml")
plist = File.join(dir, "ios", "Runner", "Info.plist")
FileUtils.mkdir_p(File.dirname(manifest))
FileUtils.mkdir_p(File.dirname(plist))
File.write(
manifest,
<<~XML
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.FLASHLIGHT"/>
<application/>
</manifest>
XML
)
File.write(
plist,
<<~XML
<plist><dict>
<key>NSCameraUsageDescription</key><string>old camera</string>
<key>NSMicrophoneUsageDescription</key><string>old microphone</string>
<key>NSLocationWhenInUseUsageDescription</key><string>old location</string>
<key>NSPhotoLibraryUsageDescription</key><string>old photos</string>
</dict></plist>
XML
)

builder.send(
:apply_native_service_permissions,
dir,
{ "services" => [{ "microphone" => { "description" => "Record voice notes." } }] }
)

android = File.read(manifest)
assert_includes android, "android.permission.INTERNET"
assert_includes android, "android.permission.RECORD_AUDIO"
refute_includes android, "android.permission.CAMERA"
refute_includes android, "android.permission.ACCESS_FINE_LOCATION"
refute_includes android, "android.permission.FLASHLIGHT"

ios = File.read(plist)
assert_includes ios, "NSMicrophoneUsageDescription"
assert_includes ios, "Record voice notes."
refute_includes ios, "NSCameraUsageDescription"
refute_includes ios, "NSLocationWhenInUseUsageDescription"
refute_includes ios, "NSPhotoLibraryUsageDescription"
end
end

private

def package_directories(client_dir)
Dir.children(File.join(client_dir, "flet_packages"))
.select { |entry| Dir.exist?(File.join(client_dir, "flet_packages", entry)) }
.sort
end
end
10 changes: 7 additions & 3 deletions packages/ruflet/test/update_command_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,7 @@ def test_prepare_flutter_client_uses_source_ruby_runtime_dependency
pubspec = File.read(File.join(client_dir, "pubspec.yaml"))
ruby_runtime = YAML.safe_load(pubspec, aliases: true).dig("dependencies", "ruby_runtime")
assert_equal File.expand_path("../../../ruby_runtime", __dir__), ruby_runtime["path"]
assert YAML.safe_load(pubspec, aliases: true).dig("dependencies", "flet_spinkit")
refute YAML.safe_load(pubspec, aliases: true).dig("dependencies", "flet_spinkit")
refute_path_exists File.join(client_dir, "lib", "ruflet_spinkit.dart")
assert_includes calls, client_dir
end
Expand Down Expand Up @@ -876,7 +876,8 @@ def test_command_build_ios_uses_unbundled_env_for_flutter

assert_equal 0, code
refute calls.first[:env].key?("BUNDLE_GEMFILE")
assert_equal ["flutter", "build", "ios", "--codesign", "--target", "lib/main.self.dart"], calls.first[:args]
assert_equal ["flutter", "build", "ios", "--codesign", "--target", "lib/main.self.dart"], calls.first[:args]
assert_equal ["flutter", "build", "ios", "--simulator", "--target", "lib/main.self.dart"], calls.last[:args]
refute_includes calls.first[:env]["PATH"], "/Users/macbookpro/.gem/ruby/3.4.0/bin"
assert_includes calls.first[:env]["PATH"], File.join(client_dir, ".ruflet", "bin")
assert File.executable?(File.join(client_dir, ".ruflet", "bin", "pod"))
Expand Down Expand Up @@ -934,14 +935,14 @@ def test_command_install_syncs_ios_build_outputs_and_runs_flutter_install
ios_app_dir = File.join(dir, "build", "ios", "iphonesimulator", "Runner.app")
FileUtils.mkdir_p(ios_app_dir)
File.write(File.join(ios_app_dir, "Info.plist"), "plist")
File.write(File.join(ios_app_dir, "Runner"), "simulator executable")

builder.define_singleton_method(:detect_flutter_client_dir) { client_dir }
builder.define_singleton_method(:load_ruflet_config) { {} }
builder.define_singleton_method(:ensure_flutter!) do |_command_name, client_dir: nil, auto_install: true|
{ flutter: "flutter", dart: "dart", env: {} }
end
builder.define_singleton_method(:prepare_flutter_client) { |_client_dir, **_kwargs| flunk("install should not run build preparation") }

calls = []
builder.define_singleton_method(:system) do |_env, *_args, chdir: nil|
calls << { env: _env, args: _args, chdir: chdir }
Expand Down Expand Up @@ -1005,6 +1006,8 @@ def test_command_install_fails_when_no_built_outputs_exist
builder = DummyBuilder.new

Dir.mktmpdir do |dir|
previous_dir = Dir.pwd
Dir.chdir(dir)
client_dir = File.join(dir, "ruflet_client")
FileUtils.mkdir_p(File.join(client_dir, "lib"))
File.write(File.join(client_dir, "lib", "main.server.dart"), "void main() {}\n")
Expand All @@ -1027,6 +1030,7 @@ def test_command_install_fails_when_no_built_outputs_exist
assert_includes err.string, "Could not find built app outputs under ./build"
ensure
$stderr = original_stderr
Dir.chdir(previous_dir) if previous_dir
end
end

Expand Down
Loading
Loading