diff --git a/.DS_Store b/.DS_Store index c0c9170..88c631e 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/README.md b/README.md index 080a7e6..939355a 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,7 @@ Use `page` only for runtime/page operations (`add`, `update`, `go`, `show_dialog ruflet new ruflet run [scriptname|path] [--web|--desktop] [--port PORT] ruflet update [web|desktop|all] [--check] [--force] [--platform PLATFORM] -ruflet build +ruflet build [--self] [--verbose] ``` For monorepo development (always uses local CLI source), run: @@ -119,6 +119,9 @@ For monorepo development (always uses local CLI source), run: By default `ruflet build ...` looks for Flutter client at `./ruflet_client`. Set `RUFLET_CLIENT_DIR` to override. +- `ruflet build ... --self` uses the self-contained Flutter entrypoint with `ruby_runtime`. +- `ruflet build ...` without `--self` builds the server-driven client entrypoint. + ## Development (Monorepo) ```bash diff --git a/docs/widgets.md b/docs/widgets.md index b75d629..2b597ab 100644 --- a/docs/widgets.md +++ b/docs/widgets.md @@ -153,7 +153,7 @@ class CounterApp < Ruflet::App text(value: "You clicked:") counter end, - floating_action_button: fab("+", on_click: ->(e) { + floating_action_button: fab(Ruflet::MaterialIcons::ADD, on_click: ->(e) { @count += 1 e.page.update(counter, value: @count.to_s) }) diff --git a/generated/embedded_ruflet_runtime.h b/generated/embedded_ruflet_runtime.h index 55a363d..f20cf56 100644 --- a/generated/embedded_ruflet_runtime.h +++ b/generated/embedded_ruflet_runtime.h @@ -2165,12 +2165,11 @@ module Ruflet value = if v.is_a?(Symbol) v.to_s - elsif v.is_a?(Ruflet::IconData) - v.value else v end value = normalize_icon_prop(mapped_key, value) + value = value.value if value.is_a?(Ruflet::IconData) value = normalize_color_prop(mapped_key, value) result[mapped_key] = value @@ -2194,25 +2193,17 @@ module Ruflet def normalize_icon_prop(key, value) return value unless icon_prop_key?(key) - codepoint = resolve_icon_codepoint(value) - codepoint.nil? ? value : codepoint + return value if value.nil? + return value if value.is_a?(Integer) + return value if value.is_a?(Ruflet::IconData) + + raise ArgumentError, "#{type} #{key} must use Ruflet::MaterialIcons (or another Ruflet::IconData), not #{value.inspect}" end def icon_prop_key?(key) key == "icon" || key.end_with?("_icon") end - def resolve_icon_codepoint(value) - return nil unless value.is_a?(Integer) || value.is_a?(Symbol) || value.is_a?(String) - - codepoint = Ruflet::MaterialIconLookup.codepoint_for(value) - if codepoint.nil? || (value.is_a?(Integer) && codepoint == value) - cupertino = Ruflet::CupertinoIconLookup.codepoint_for(value) - codepoint = cupertino unless cupertino.nil? - end - codepoint - end - def normalized_event_name(event_name) event_name.to_s.sub(/\Aon_/, "") end @@ -18569,13 +18560,39 @@ module Ruflet def webview(**props) = web_view(**props) def fab(content = nil, **props) - mapped = props.dup - mapped[:content] = content unless content.nil? + mapped = normalize_fab_props(props.dup, content) build_widget(:floatingactionbutton, **mapped) end private + def normalize_fab_props(props, content) + mapped = props.dup + + explicit_icon = mapped[:icon] || mapped["icon"] + if explicit_icon.is_a?(Ruflet::Control) && content.nil? + mapped.delete(:icon) + mapped.delete("icon") + content = explicit_icon + elsif !explicit_icon.nil? && !explicit_icon.is_a?(Ruflet::IconData) + raise ArgumentError, "fab icon must use Ruflet::MaterialIcons (or another Ruflet::IconData) or an icon(...) control" + end + + unless content.nil? + mapped[:content] = + case content + when Ruflet::Control + content + when Ruflet::IconData + icon(icon: content) + else + raise ArgumentError, "fab content must be an icon(...) control or Ruflet::MaterialIcons value" + end + end + + mapped + end + def normalize_image_source(value) return value unless value.is_a?(Array) return value.pack("C*") if value.all? { |v| v.is_a?(Integer) } @@ -20459,9 +20476,12 @@ module Ruflet end def normalize_value(key, value) - if icon_prop_key?(key) && (value.is_a?(String) || value.is_a?(Symbol) || value.is_a?(Integer)) - codepoint = resolve_icon_codepoint(value) - return codepoint unless codepoint.nil? + if icon_prop_key?(key) + return value if value.is_a?(Integer) + return value.value if value.is_a?(Ruflet::IconData) + return value if value.nil? + + raise ArgumentError, "page #{key} must use Ruflet::MaterialIcons (or another Ruflet::IconData), not #{value.inspect}" end return value.value if value.is_a?(Ruflet::IconData) @@ -20613,14 +20633,6 @@ module Ruflet end end - def resolve_icon_codepoint(value) - codepoint = Ruflet::MaterialIconLookup.codepoint_for(value) - if codepoint.nil? || codepoint == value - codepoint = Ruflet::CupertinoIconLookup.codepoint_for(value) - end - codepoint - end - def ensure_clipboard_service clipboard = services.find { |service| service.is_a?(Control) && service.type == "clipboard" } return [clipboard, false] if clipboard diff --git a/generated/embedded_ruflet_runtime.rb b/generated/embedded_ruflet_runtime.rb index d26bb6d..a98a079 100644 --- a/generated/embedded_ruflet_runtime.rb +++ b/generated/embedded_ruflet_runtime.rb @@ -2162,12 +2162,11 @@ def normalize_props(hash) value = if v.is_a?(Symbol) v.to_s - elsif v.is_a?(Ruflet::IconData) - v.value else v end value = normalize_icon_prop(mapped_key, value) + value = value.value if value.is_a?(Ruflet::IconData) value = normalize_color_prop(mapped_key, value) result[mapped_key] = value @@ -2191,25 +2190,17 @@ def color_prop_key?(key) def normalize_icon_prop(key, value) return value unless icon_prop_key?(key) - codepoint = resolve_icon_codepoint(value) - codepoint.nil? ? value : codepoint + return value if value.nil? + return value if value.is_a?(Integer) + return value if value.is_a?(Ruflet::IconData) + + raise ArgumentError, "#{type} #{key} must use Ruflet::MaterialIcons (or another Ruflet::IconData), not #{value.inspect}" end def icon_prop_key?(key) key == "icon" || key.end_with?("_icon") end - def resolve_icon_codepoint(value) - return nil unless value.is_a?(Integer) || value.is_a?(Symbol) || value.is_a?(String) - - codepoint = Ruflet::MaterialIconLookup.codepoint_for(value) - if codepoint.nil? || (value.is_a?(Integer) && codepoint == value) - cupertino = Ruflet::CupertinoIconLookup.codepoint_for(value) - codepoint = cupertino unless cupertino.nil? - end - codepoint - end - def normalized_event_name(event_name) event_name.to_s.sub(/\Aon_/, "") end @@ -18566,13 +18557,39 @@ def web_view(**props) = build_widget(:webview, **props) def webview(**props) = web_view(**props) def fab(content = nil, **props) - mapped = props.dup - mapped[:content] = content unless content.nil? + mapped = normalize_fab_props(props.dup, content) build_widget(:floatingactionbutton, **mapped) end private + def normalize_fab_props(props, content) + mapped = props.dup + + explicit_icon = mapped[:icon] || mapped["icon"] + if explicit_icon.is_a?(Ruflet::Control) && content.nil? + mapped.delete(:icon) + mapped.delete("icon") + content = explicit_icon + elsif !explicit_icon.nil? && !explicit_icon.is_a?(Ruflet::IconData) + raise ArgumentError, "fab icon must use Ruflet::MaterialIcons (or another Ruflet::IconData) or an icon(...) control" + end + + unless content.nil? + mapped[:content] = + case content + when Ruflet::Control + content + when Ruflet::IconData + icon(icon: content) + else + raise ArgumentError, "fab content must be an icon(...) control or Ruflet::MaterialIcons value" + end + end + + mapped + end + def normalize_image_source(value) return value unless value.is_a?(Array) return value.pack("C*") if value.all? { |v| v.is_a?(Integer) } @@ -20456,9 +20473,12 @@ def normalize_props(hash) end def normalize_value(key, value) - if icon_prop_key?(key) && (value.is_a?(String) || value.is_a?(Symbol) || value.is_a?(Integer)) - codepoint = resolve_icon_codepoint(value) - return codepoint unless codepoint.nil? + if icon_prop_key?(key) + return value if value.is_a?(Integer) + return value.value if value.is_a?(Ruflet::IconData) + return value if value.nil? + + raise ArgumentError, "page #{key} must use Ruflet::MaterialIcons (or another Ruflet::IconData), not #{value.inspect}" end return value.value if value.is_a?(Ruflet::IconData) @@ -20610,14 +20630,6 @@ def build_page_patch_ops end end - def resolve_icon_codepoint(value) - codepoint = Ruflet::MaterialIconLookup.codepoint_for(value) - if codepoint.nil? || codepoint == value - codepoint = Ruflet::CupertinoIconLookup.codepoint_for(value) - end - codepoint - end - def ensure_clipboard_service clipboard = services.find { |service| service.is_a?(Control) && service.type == "clipboard" } return [clipboard, false] if clipboard diff --git a/packages/ruflet/lib/ruflet/cli.rb b/packages/ruflet/lib/ruflet/cli.rb index d268414..39e4e00 100644 --- a/packages/ruflet/lib/ruflet/cli.rb +++ b/packages/ruflet/lib/ruflet/cli.rb @@ -39,6 +39,8 @@ def run(argv = ARGV) command_debug(argv) when "build" command_build(argv) + when "install" + command_install(argv) when "devices" command_devices(argv) when "emulators" @@ -66,7 +68,8 @@ def print_help ruflet run [scriptname|path] [--web|--desktop] [--port PORT] ruflet update [web|desktop|all] [--check] [--force] [--platform PLATFORM] ruflet debug [scriptname|path] - ruflet build + ruflet build [--self] [--verbose] + ruflet install [--device DEVICE_ID] [--verbose] ruflet devices ruflet emulators ruflet doctor diff --git a/packages/ruflet/lib/ruflet/cli/build_command.rb b/packages/ruflet/lib/ruflet/cli/build_command.rb index 490ef6f..2d0a668 100644 --- a/packages/ruflet/lib/ruflet/cli/build_command.rb +++ b/packages/ruflet/lib/ruflet/cli/build_command.rb @@ -1,6 +1,10 @@ # frozen_string_literal: true require "fileutils" +require "find" +require "json" +require "pathname" +require "rbconfig" require "uri" require "yaml" @@ -28,9 +32,11 @@ module BuildCommand }.freeze def command_build(args) + self_contained = args.delete("--self") + verbose = args.delete("--verbose") || args.delete("-v") platform = (args.shift || "").downcase if platform.empty? - warn "Usage: ruflet build " + warn "Usage: ruflet build [--self] [--verbose]" return 1 end @@ -40,34 +46,194 @@ def command_build(args) return 1 end - client_dir = detect_flutter_client_dir + client_dir = ensure_flutter_client_dir(verbose: !!verbose) unless client_dir warn "Could not find Flutter client directory." - warn "Set RUFLET_CLIENT_DIR or place client at ./ruflet_client" + warn "Set RUFLET_CLIENT_DIR or let Ruflet manage the hidden client under ./build/.ruflet/client" return 1 end config = load_ruflet_config tools = ensure_flutter!("build", client_dir: client_dir) - ok = prepare_flutter_client(client_dir, tools: tools, config: config) + command_env = build_tool_env(tools[:env], platform, client_dir) + ok = prepare_flutter_client( + client_dir, + platform: platform, + tools: tools.merge(env: command_env), + config: config, + self_contained: !!self_contained, + verbose: !!verbose + ) return 1 unless ok build_args = [*flutter_cmd, *args] - client_url = configured_client_url(config) - if client_url - build_args += ["--dart-define", "RUFLET_CLIENT_URL=#{client_url}"] + target_entrypoint = flutter_target_entrypoint(client_dir, self_contained: !!self_contained) + build_args += ["--target", target_entrypoint] if target_entrypoint + backend_url = configured_backend_url(config) + if self_contained + build_args += ["--dart-define", "RUFLET_BACKEND_URL=#{backend_url}"] if backend_url + else + unless backend_url + warn "build config error: backend_url is required for server-driven builds" + warn "Set app.backend_url or backend_url in ruflet.yaml" + return 1 + end + build_args += ["--dart-define", "RUFLET_BACKEND_URL=#{backend_url}"] end + build_args << "-v" if verbose - ok = system(tools[:env], tools[:flutter], *build_args, chdir: client_dir) + build_log(verbose, "mode=#{self_contained ? 'self' : 'server'}") + build_log(verbose, "client_dir=#{client_dir}") + build_log(verbose, "flutter=#{tools[:flutter]}") + build_log(verbose, "dart=#{tools[:dart]}") + build_log(verbose, "target=#{target_entrypoint}") if target_entrypoint + build_log(verbose, "command=#{([tools[:flutter]] + build_args).join(' ')}") + + ok = run_external_command(command_env, tools[:flutter], *build_args, chdir: client_dir, unbundled: true) + export_platform_build_outputs(client_dir, platform, verbose: !!verbose) if ok + ok ? 0 : 1 + end + + def command_install(args) + verbose = args.delete("--verbose") || args.delete("-v") + device_id = extract_option_value!(args, "--device", "-d") + + client_dir = ensure_flutter_client_dir(verbose: !!verbose) + unless client_dir + warn "Could not find Flutter client directory." + warn "Set RUFLET_CLIENT_DIR or let Ruflet manage the hidden client under ./build/.ruflet/client" + return 1 + end + + tools = ensure_flutter!("install", client_dir: client_dir) + command_env = install_tool_env(tools[:env], client_dir) + unless sync_built_outputs_for_install(client_dir, verbose: !!verbose) + warn "Could not find built app outputs under ./build" + warn "Run `ruflet build ...` first, then `ruflet install`." + return 1 + end + + install_args = ["install"] + install_args += ["-d", device_id] if device_id + install_args << "-v" if verbose + + build_log(verbose, "client_dir=#{client_dir}") + build_log(verbose, "flutter=#{tools[:flutter]}") + build_log(verbose, "dart=#{tools[:dart]}") + build_log(verbose, "install_command=#{([tools[:flutter]] + install_args).join(' ')}") + build_note("Installing app#{device_id ? " to device #{device_id}" : ""}") + + ok = run_external_command(command_env, tools[:flutter], *install_args, chdir: client_dir, unbundled: true) ok ? 0 : 1 end private + def extract_option_value!(args, *flags) + flags.each do |flag| + index = args.index(flag) + next unless index + + value = args[index + 1] + args.slice!(index, 2) + return value + end + nil + end + + def ensure_flutter_client_dir(verbose: false) + client_dir = detect_flutter_client_dir + return client_dir if client_dir + + bootstrapped = bootstrap_flutter_client_template + build_log(verbose, "bootstrapped client template at #{bootstrapped}") if bootstrapped + bootstrapped + end + + def build_tool_env(env, platform, client_dir = nil) + return env unless %w[ios macos].include?(platform) + + apple_env = unbundled_command_env(env) + apple_env["PATH"] = apple_build_path(apple_env["PATH"]) + install_apple_pod_shim(client_dir, apple_env) if client_dir + apple_env + end + + def install_tool_env(env, client_dir) + return build_tool_env(env, inferred_install_platform, client_dir) if inferred_install_platform + + command_env = unbundled_command_env(env) + command_env["PATH"] = apple_build_path(command_env["PATH"]) + install_apple_pod_shim(client_dir, command_env) + command_env + end + + def inferred_install_platform + host_os = RbConfig::CONFIG["host_os"] + return "ios" if host_os.match?(/darwin/i) + + nil + end + + def export_platform_build_outputs(client_dir, platform, verbose: false) + exports_for(platform).each do |relative_source, relative_target| + source = File.join(client_dir, "build", relative_source) + next unless File.exist?(source) + + target = File.join(user_build_root, relative_target) + FileUtils.rm_rf(target) + FileUtils.mkdir_p(File.dirname(target)) + FileUtils.cp_r(source, target) + build_log(verbose, "exported #{source} -> #{target}") + end + end + + def sync_built_outputs_for_install(client_dir, verbose: false) + synced = false + + %w[android ios macos windows linux web apk aab appbundle].each do |platform| + exports_for(platform).each do |relative_source, relative_target| + source = File.join(user_build_root, relative_target) + next unless File.exist?(source) + + target = File.join(client_dir, "build", relative_source) + FileUtils.rm_rf(target) + FileUtils.mkdir_p(File.dirname(target)) + FileUtils.cp_r(source, target) + build_log(verbose, "synced #{source} -> #{target}") + synced = true + end + end + + synced + end + + def exports_for(platform) + case platform + when "apk", "android", "aab", "appbundle" + { File.join("app", "outputs") => "android" } + when "ios" + { "ios" => "ios" } + when "macos" + { "macos" => "macos" } + when "windows" + { "windows" => "windows" } + when "linux" + { "linux" => "linux" } + when "web" + { "web" => "web" } + else + {} + end + end + def detect_flutter_client_dir env_dir = ENV["RUFLET_CLIENT_DIR"] return env_dir if env_dir && Dir.exist?(env_dir) + hidden = hidden_flutter_client_dir + return hidden if Dir.exist?(hidden) + local = File.expand_path("ruflet_client", Dir.pwd) return local if Dir.exist?(local) @@ -77,27 +243,61 @@ def detect_flutter_client_dir nil end - def prepare_flutter_client(client_dir, tools:, config:) + def bootstrap_flutter_client_template + return nil if ENV["RUFLET_CLIENT_DIR"] + + target = hidden_flutter_client_dir + return target if Dir.exist?(target) + + if Ruflet::CLI.respond_to?(:copy_ruflet_client_template, true) + Ruflet::CLI.send(:copy_ruflet_client_template, Dir.pwd) + end + + Dir.exist?(target) ? target : nil + end + + def hidden_flutter_client_dir(root = Dir.pwd) + File.join(root, "build", ".ruflet", "client") + end + + def user_build_root(root = Dir.pwd) + File.join(root, "build") + end + + def prepare_flutter_client(client_dir, platform:, tools:, config:, self_contained: false, verbose: false) + sync_client_metadata(client_dir, config, verbose: verbose) + configure_client_runtime_mode(client_dir, self_contained: self_contained, verbose: verbose) apply_service_extension_config(client_dir, config) asset_flags = apply_build_config(client_dir, config) if asset_flags[:error] warn asset_flags[:error] return false end - unless system(tools[:env], tools[:flutter], "pub", "get", chdir: client_dir) + announce_asset_configuration(asset_flags) + clear_flutter_build_state(client_dir, verbose: verbose) + build_log(verbose, "running flutter pub get") + unless run_external_command(tools[:env], tools[:flutter], "pub", "get", chdir: client_dir, unbundled: true) warn "flutter pub get failed" return false end + unless ensure_native_build_dependencies(client_dir, platform, tools[:env], verbose: verbose) + return false + end + if asset_flags[:has_splash] - unless system(tools[:env], tools[:dart], "run", "flutter_native_splash:create", chdir: client_dir) + build_note("Generating splash screen with flutter_native_splash") + build_log(verbose, "running flutter_native_splash:create") + unless run_external_command(tools[:env], tools[:dart], "run", "flutter_native_splash:create", chdir: client_dir, unbundled: true) warn "flutter_native_splash failed" return false end end if asset_flags[:has_icon] - unless system(tools[:env], tools[:dart], "run", "flutter_launcher_icons", chdir: client_dir) + build_note("Generating launcher icons with flutter_launcher_icons") + build_log(verbose, "running flutter_launcher_icons") + unless run_external_command(tools[:env], tools[:dart], "run", "flutter_launcher_icons", chdir: client_dir, unbundled: true) warn "flutter_launcher_icons failed" return false end @@ -106,9 +306,111 @@ def prepare_flutter_client(client_dir, tools:, config:) true end - def configured_client_url(config) + def ensure_native_build_dependencies(client_dir, platform, env, verbose: false) + case platform + when "ios" + ensure_cocoapods_install(client_dir, "ios", env, verbose: verbose) + when "macos" + ok = true + ok &&= ensure_cocoapods_install(client_dir, "ios", env, verbose: verbose) + ok &&= ensure_cocoapods_install(client_dir, "macos", env, verbose: verbose) + ok + else + true + end + end + + def ensure_cocoapods_install(client_dir, platform_dir, env, verbose: false) + pod_dir = File.join(client_dir, platform_dir) + return true unless Dir.exist?(pod_dir) + return true unless File.file?(File.join(pod_dir, "Podfile")) + + build_note("Running CocoaPods install for #{platform_dir}") + build_log(verbose, "pod install in #{pod_dir}") + ok = + if defined?(Bundler) && Bundler.respond_to?(:with_unbundled_env) + Bundler.with_unbundled_env do + run_external_command(unbundled_command_env(env), "pod", "install", chdir: pod_dir, unbundled: false) + end + else + run_external_command(unbundled_command_env(env), "pod", "install", chdir: pod_dir, unbundled: false) + end + return true if ok + + warn "CocoaPods install failed for #{platform_dir}" + warn "Make sure `pod` is installed and working for the Ruby used by Flutter." + false + end + + def unbundled_command_env(env) + sanitized_env = env.reject { |key, _value| key.start_with?("BUNDLE_") || key == "RUBYOPT" || key == "RUBYLIB" } + cleared_env = {} + ENV.each_key do |key| + next unless key.start_with?("BUNDLE_") || key == "RUBYOPT" || key == "RUBYLIB" || key.start_with?("GEM_") + + cleared_env[key] = nil + end + + cleared_env.merge(sanitized_env) + end + + def run_external_command(env, *cmd, chdir:, unbundled: false) + if unbundled && defined?(Bundler) && Bundler.respond_to?(:with_unbundled_env) + Bundler.with_unbundled_env do + system(env, *cmd, chdir: chdir) + end + else + system(env, *cmd, chdir: chdir) + end + end + + def apple_build_path(existing_path) + segments = existing_path.to_s.split(File::PATH_SEPARATOR) + segments.reject! { |segment| segment.include?("/.gem/ruby/") && segment.end_with?("/bin") } + + preferred = [] + preferred << "/opt/homebrew/bin" if File.executable?("/opt/homebrew/bin/pod") + preferred << "/usr/local/bin" if File.executable?("/usr/local/bin/pod") + + (preferred + segments).uniq.join(File::PATH_SEPARATOR) + end + + def install_apple_pod_shim(client_dir, env) + pod_executable = resolve_working_pod_executable + return unless pod_executable + + shim_dir = File.join(client_dir, ".ruflet", "bin") + FileUtils.mkdir_p(shim_dir) + shim_path = File.join(shim_dir, "pod") + File.write( + shim_path, + <<~SH + #!/bin/sh + exec "#{pod_executable}" "$@" + SH + ) + FileUtils.chmod("+x", shim_path) + env["PATH"] = ([shim_dir] + env["PATH"].to_s.split(File::PATH_SEPARATOR)).uniq.join(File::PATH_SEPARATOR) + env["COCOAPODS_DISABLE_STATS"] = "true" + env["GEM_HOME"] = nil + env["GEM_PATH"] = nil + env["GEM_ROOT"] = nil + end + + def resolve_working_pod_executable + return "/opt/homebrew/bin/pod" if File.executable?("/opt/homebrew/bin/pod") + return "/usr/local/bin/pod" if File.executable?("/usr/local/bin/pod") + + nil + end + + def configured_backend_url(config) candidates = [ + config["backend_url"], + config["server_url"], config["ruflet_client_url"], + (config["app"].is_a?(Hash) ? config["app"]["backend_url"] : nil), + (config["app"].is_a?(Hash) ? config["app"]["server_url"] : nil), (config["app"].is_a?(Hash) ? config["app"]["ruflet_client_url"] : nil) ] raw = candidates.find { |v| !v.to_s.strip.empty? } @@ -196,19 +498,38 @@ def apply_build_config(client_dir, config = {}) end copy_asset.call(icon_macos, "icon_macos.png") + default_splash = File.file?(File.join(assets_dir, "splash.png")) + default_icon = File.file?(File.join(assets_dir, "icon.png")) + + using_default_splash = false + using_default_icon = false + if splash_defined && splash.nil? - return { has_icon: false, has_splash: false, error: "build config error: splash_screen is set but file was not found" } + if default_splash + using_default_splash = true + build_note("Configured splash_screen was not found; using default template asset assets/splash.png") + else + return { has_icon: false, has_splash: false, error: "build config error: splash_screen is set but file was not found, and no default splash asset exists" } + end end if icon_defined && icon.nil? - return { has_icon: false, has_splash: false, error: "build config error: icon_launcher is set but file was not found" } + if default_icon + using_default_icon = true + build_note("Configured icon_launcher was not found; using default template asset assets/icon.png") + else + return { has_icon: false, has_splash: false, error: "build config error: icon_launcher is set but file was not found, and no default icon asset exists" } + end end + has_splash = !splash.nil? || default_splash + has_icon = !icon.nil? || default_icon + pubspec_path = File.join(client_dir, "pubspec.yaml") unless File.file?(pubspec_path) - return { has_icon: icon_defined && !icon.nil?, has_splash: splash_defined && !splash.nil?, error: nil } + return { has_icon: has_icon, has_splash: has_splash, error: nil } end - if icon_defined && icon + if has_icon update_pubspec_value(pubspec_path, "flutter_launcher_icons", "image_path", "\"assets/icon.png\"", multiple: true) end update_pubspec_value(pubspec_path, "flutter_launcher_icons", "image_path_android", "\"assets/icon_android.png\"", multiple: true) if icon_android @@ -223,12 +544,306 @@ def apply_build_config(client_dir, config = {}) update_pubspec_value(pubspec_path, "flutter_launcher_icons", "background_color", "\"#{icon_background}\"") if icon_background update_pubspec_value(pubspec_path, "flutter_launcher_icons", "theme_color", "\"#{theme_color}\"") if theme_color - update_pubspec_value(pubspec_path, "flutter_native_splash", "image", "\"assets/splash.png\"") if splash_defined && splash + update_pubspec_value(pubspec_path, "flutter_native_splash", "image", "\"assets/splash.png\"") if has_splash update_pubspec_value(pubspec_path, "flutter_native_splash", "image_dark", "\"assets/splash_dark.png\"") if splash_dark update_pubspec_value(pubspec_path, "flutter_native_splash", "color", "\"#{splash_color}\"") if splash_color update_pubspec_value(pubspec_path, "flutter_native_splash", "color_dark", "\"#{splash_dark_color}\"") if splash_dark_color - { has_icon: icon_defined && !icon.nil?, has_splash: splash_defined && !splash.nil?, error: nil } + { + has_icon: has_icon, + has_splash: has_splash, + using_default_icon: using_default_icon, + using_default_splash: using_default_splash, + error: nil + } + end + + def sync_client_metadata(client_dir, config = {}, verbose: false) + metadata = build_client_metadata(config, client_dir) + apply_pubspec_metadata(client_dir, metadata) + apply_android_metadata(client_dir, metadata) + apply_ios_metadata(client_dir, metadata) + apply_macos_metadata(client_dir, metadata) + apply_web_metadata(client_dir, metadata) + apply_windows_metadata(client_dir, metadata) + apply_linux_metadata(client_dir, metadata) + build_log( + verbose, + "app=#{metadata[:display_name]} package=#{metadata[:package_name]} org=#{metadata[:organization]} bundle=#{metadata[:bundle_identifier]}" + ) + end + + def build_client_metadata(config, client_dir) + app = config["app"].is_a?(Hash) ? config["app"] : {} + current_pubspec = load_client_pubspec(client_dir) + current_name = current_pubspec["name"].to_s + inferred_display_name = app["name"] || config["name"] || humanize_name(File.basename(Dir.pwd)) + package_name = normalize_package_name(app["package_name"] || config["package_name"] || current_name || inferred_display_name) + display_name = first_present(app["display_name"], app["name"], config["display_name"], config["name"], humanize_name(package_name)) + organization = normalize_bundle_prefix( + first_present(app["org"], app["organization"], config["org"], config["organization"], "com.example") + ) + bundle_identifier = normalize_bundle_identifier( + first_present(app["bundle_identifier"], config["bundle_identifier"], "#{organization}.#{package_name}") + ) + + { + package_name: package_name, + display_name: display_name, + description: first_present(app["description"], config["description"], current_pubspec["description"], "A new Flutter project."), + version: first_present(app["version"], config["version"], current_pubspec["version"], "1.0.0+1"), + organization: organization, + company_name: first_present(app["company_name"], config["company_name"], organization), + bundle_identifier: bundle_identifier, + android_application_id: normalize_bundle_identifier( + first_present(app["android_application_id"], config["android_application_id"], bundle_identifier) + ), + ios_bundle_identifier: normalize_bundle_identifier( + first_present(app["ios_bundle_identifier"], config["ios_bundle_identifier"], bundle_identifier) + ), + macos_bundle_identifier: normalize_bundle_identifier( + first_present(app["macos_bundle_identifier"], config["macos_bundle_identifier"], bundle_identifier) + ), + linux_application_id: normalize_bundle_identifier( + first_present(app["linux_application_id"], config["linux_application_id"], bundle_identifier) + ), + short_name: first_present(app["short_name"], config["short_name"], display_name) + } + end + + def load_client_pubspec(client_dir) + pubspec_path = File.join(client_dir, "pubspec.yaml") + return {} unless File.file?(pubspec_path) + + YAML.safe_load(File.read(pubspec_path), aliases: true) || {} + rescue StandardError + {} + end + + def apply_pubspec_metadata(client_dir, metadata) + pubspec_path = File.join(client_dir, "pubspec.yaml") + return unless File.file?(pubspec_path) + + data = YAML.safe_load(File.read(pubspec_path), aliases: true) || {} + data["name"] = metadata[:package_name] + data["description"] = metadata[:description] + data["version"] = metadata[:version] + File.write(pubspec_path, YAML.dump(data)) + end + + def apply_android_metadata(client_dir, metadata) + gradle_path = File.join(client_dir, "android", "app", "build.gradle.kts") + replace_in_file( + gradle_path, + /^\s*namespace = ".*"$/, + %( namespace = "#{metadata[:android_application_id]}") + ) + replace_in_file( + gradle_path, + /^\s*applicationId = ".*"$/, + %( applicationId = "#{metadata[:android_application_id]}") + ) + + manifest_path = File.join(client_dir, "android", "app", "src", "main", "AndroidManifest.xml") + replace_in_file( + manifest_path, + /android:label="[^"]*"/, + %(android:label="#{xml_escape(metadata[:display_name])}") + ) + end + + def apply_ios_metadata(client_dir, metadata) + info_plist_path = File.join(client_dir, "ios", "Runner", "Info.plist") + replace_plist_value(info_plist_path, "CFBundleDisplayName", metadata[:display_name]) + replace_plist_value(info_plist_path, "CFBundleName", metadata[:display_name]) + + pbxproj_path = File.join(client_dir, "ios", "Runner.xcodeproj", "project.pbxproj") + return unless File.file?(pbxproj_path) + + content = File.read(pbxproj_path) + content.gsub!(/INFOPLIST_KEY_CFBundleDisplayName = "[^"]*";/, %(INFOPLIST_KEY_CFBundleDisplayName = "#{xcode_escape(metadata[:display_name])}";)) + content.gsub!(/PRODUCT_BUNDLE_IDENTIFIER = ([^;]+);/) do |match| + identifier = Regexp.last_match(1).to_s.strip + if identifier.include?("RunnerTests") + match + else + "PRODUCT_BUNDLE_IDENTIFIER = #{metadata[:ios_bundle_identifier]};" + end + end + File.write(pbxproj_path, content) + end + + def apply_macos_metadata(client_dir, metadata) + app_info_path = File.join(client_dir, "macos", "Runner", "Configs", "AppInfo.xcconfig") + replace_in_file( + app_info_path, + /^PRODUCT_NAME = .*$/, + "PRODUCT_NAME = #{metadata[:display_name]}" + ) + replace_in_file( + app_info_path, + /^PRODUCT_BUNDLE_IDENTIFIER = .*$/, + "PRODUCT_BUNDLE_IDENTIFIER = #{metadata[:macos_bundle_identifier]}" + ) + replace_in_file( + app_info_path, + /^PRODUCT_COPYRIGHT = .*$/, + "PRODUCT_COPYRIGHT = Copyright © #{Time.now.year} #{metadata[:company_name]}. All rights reserved." + ) + end + + def apply_web_metadata(client_dir, metadata) + manifest_path = File.join(client_dir, "web", "manifest.json") + if File.file?(manifest_path) + data = JSON.parse(File.read(manifest_path)) + data["name"] = metadata[:display_name] + data["short_name"] = metadata[:short_name] + data["description"] = metadata[:description] + File.write(manifest_path, JSON.pretty_generate(data) + "\n") + end + + index_path = File.join(client_dir, "web", "index.html") + replace_in_file( + index_path, + //, + %() + ) + replace_in_file( + index_path, + //, + %() + ) + replace_in_file( + index_path, + /.*<\/title>/, + "<title>#{html_escape(metadata[:display_name])}" + ) + end + + def apply_windows_metadata(client_dir, metadata) + cmake_path = File.join(client_dir, "windows", "CMakeLists.txt") + replace_in_file(cmake_path, /^project\(.*\)$/, "project(#{metadata[:package_name]} LANGUAGES CXX)") + replace_in_file(cmake_path, /^set\(BINARY_NAME ".*"\)$/, %(set(BINARY_NAME "#{metadata[:package_name]}"))) + + runner_rc_path = File.join(client_dir, "windows", "runner", "Runner.rc") + replace_in_file( + runner_rc_path, + /VALUE "CompanyName", ".*" "\\0"/, + %(VALUE "CompanyName", "#{windows_string_escape(metadata[:company_name])}" "\\0") + ) + replace_in_file( + runner_rc_path, + /VALUE "FileDescription", ".*" "\\0"/, + %(VALUE "FileDescription", "#{windows_string_escape(metadata[:display_name])}" "\\0") + ) + replace_in_file( + runner_rc_path, + /VALUE "InternalName", ".*" "\\0"/, + %(VALUE "InternalName", "#{windows_string_escape(metadata[:package_name])}" "\\0") + ) + replace_in_file( + runner_rc_path, + /VALUE "LegalCopyright", ".*" "\\0"/, + %(VALUE "LegalCopyright", "Copyright (C) #{Time.now.year} #{windows_string_escape(metadata[:company_name])}. All rights reserved." "\\0") + ) + replace_in_file( + runner_rc_path, + /VALUE "OriginalFilename", ".*" "\\0"/, + %(VALUE "OriginalFilename", "#{windows_string_escape(metadata[:package_name])}.exe" "\\0") + ) + replace_in_file( + runner_rc_path, + /VALUE "ProductName", ".*" "\\0"/, + %(VALUE "ProductName", "#{windows_string_escape(metadata[:display_name])}" "\\0") + ) + end + + def apply_linux_metadata(client_dir, metadata) + cmake_path = File.join(client_dir, "linux", "CMakeLists.txt") + replace_in_file(cmake_path, /^set\(BINARY_NAME ".*"\)$/, %(set(BINARY_NAME "#{metadata[:package_name]}"))) + replace_in_file(cmake_path, /^set\(APPLICATION_ID ".*"\)$/, %(set(APPLICATION_ID "#{metadata[:linux_application_id]}"))) + end + + def replace_plist_value(path, key, value) + return unless File.file?(path) + + content = File.read(path) + pattern = %r{(#{Regexp.escape(key)}\s*)(.*?)()}m + updated = content.gsub(pattern) do + "#{Regexp.last_match(1)}#{xml_escape(value)}#{Regexp.last_match(3)}" + end + File.write(path, updated) unless updated == content + end + + def replace_in_file(path, pattern, replacement) + return unless File.file?(path) + + content = File.read(path) + updated = content.gsub(pattern, replacement) + File.write(path, updated) unless updated == content + end + + def first_present(*values) + values.find { |value| !value.to_s.strip.empty? } + end + + def normalize_package_name(value) + normalized = value.to_s.strip.downcase.gsub(/[^a-z0-9_]+/, "_") + normalized.gsub!(/\A_+|_+\z/, "") + normalized.gsub!(/_+/, "_") + normalized = "ruflet_client" if normalized.empty? + normalized = "app_#{normalized}" if normalized.match?(/\A\d/) + normalized + end + + def normalize_bundle_prefix(value) + segments = value.to_s.strip.downcase.split(".").map do |segment| + normalized = segment.gsub(/[^a-z0-9_]+/, "") + normalized = "app" if normalized.empty? + normalized = "app#{normalized}" if normalized.match?(/\A\d/) + normalized + end + segments.reject!(&:empty?) + segments = %w[com example] if segments.empty? + segments.join(".") + end + + def normalize_bundle_identifier(value) + segments = value.to_s.strip.downcase.split(".").map do |segment| + normalized = segment.gsub(/[^a-z0-9_]+/, "_") + normalized.gsub!(/\A_+|_+\z/, "") + normalized = "app" if normalized.empty? + normalized = "app#{normalized}" if normalized.match?(/\A\d/) + normalized + end + segments.reject!(&:empty?) + segments = %w[com example ruflet_client] if segments.empty? + segments.join(".") + end + + def humanize_name(name) + name.to_s.gsub(/[_-]+/, " ").split.map(&:capitalize).join(" ") + end + + def xml_escape(value) + value.to_s + .gsub("&", "&") + .gsub("<", "<") + .gsub(">", ">") + .gsub('"', """) + .gsub("'", "'") + end + + def html_escape(value) + xml_escape(value) + end + + def xcode_escape(value) + value.to_s.gsub("\\", "\\\\\\").gsub('"', '\"') + end + + def windows_string_escape(value) + value.to_s.gsub('"', '""') end def key_defined?(hash, key) @@ -242,9 +857,207 @@ def apply_service_extension_config(client_dir, config = {}) extension_aliases = extension_keys.filter_map { |key| CLIENT_EXTENSION_MAP[key]&.fetch(:alias) }.uniq pubspec_path = File.join(client_dir, "pubspec.yaml") - main_path = File.join(client_dir, "lib", "main.dart") prune_client_pubspec(pubspec_path, extension_packages) if File.file?(pubspec_path) - prune_client_main(main_path, extension_aliases) if File.file?(main_path) + client_entrypoint_paths(client_dir).each do |entrypoint| + prune_client_main(entrypoint, extension_aliases) if File.file?(entrypoint) + end + end + + def clear_flutter_build_state(client_dir, verbose: false) + flutter_build_dir = File.join(client_dir, ".dart_tool", "flutter_build") + return unless Dir.exist?(flutter_build_dir) + + FileUtils.rm_rf(flutter_build_dir) + build_log(verbose, "cleared .dart_tool/flutter_build") + end + + def client_entrypoint_paths(client_dir) + %w[main.dart main.self.dart main.server.dart].map do |name| + File.join(client_dir, "lib", name) + end + end + + def configure_client_runtime_mode(client_dir, self_contained:, verbose: false) + build_log(verbose, "configuring #{self_contained ? 'self-contained' : 'server-driven'} runtime") + sync_client_pubspec_for_runtime_mode(client_dir, self_contained: self_contained) + if self_contained + sync_self_contained_project_assets(client_dir, verbose: verbose) + ensure_local_ruby_runtime_override(client_dir, verbose: verbose) + else + remove_self_contained_project_assets(client_dir, verbose: verbose) + remove_local_ruby_runtime_override(client_dir, verbose: verbose) + end + end + + def sync_client_pubspec_for_runtime_mode(client_dir, self_contained:) + pubspec_path = File.join(client_dir, "pubspec.yaml") + return unless File.file?(pubspec_path) + + data = YAML.safe_load(File.read(pubspec_path), aliases: true) || {} + dependencies = data["dependencies"] + dependencies = data["dependencies"] = {} unless dependencies.is_a?(Hash) + flutter = data["flutter"] + flutter = data["flutter"] = {} unless flutter.is_a?(Hash) + assets = Array(flutter["assets"]).map(&:to_s) + + if self_contained + dependencies["ruby_runtime"] ||= "^0.0.1" + assets << "assets/main.rb" unless assets.include?("assets/main.rb") + assets << "assets/ruby_project/" unless assets.include?("assets/ruby_project/") + else + dependencies.delete("ruby_runtime") + assets.delete("assets/main.rb") + assets.delete("assets/ruby_project/") + end + + flutter["assets"] = assets unless assets.empty? + flutter.delete("assets") if assets.empty? + File.write(pubspec_path, YAML.dump(data)) + end + + def sync_self_contained_project_assets(client_dir, verbose: false) + project_root = Pathname.new(Dir.pwd) + destination_root = File.join(client_dir, "assets", "ruby_project") + FileUtils.rm_rf(destination_root) + FileUtils.mkdir_p(destination_root) + + copied = 0 + project_asset_relative_paths.each do |relative_path| + source = project_root.join(relative_path) + next unless source.exist? && source.file? + + destination = File.join(destination_root, relative_path) + FileUtils.mkdir_p(File.dirname(destination)) + FileUtils.cp(source.to_s, destination) + copied += 1 + end + + build_log(verbose, "copied #{copied} project file#{copied == 1 ? '' : 's'} to assets/ruby_project") + end + + def remove_self_contained_project_assets(client_dir, verbose: false) + destination_root = File.join(client_dir, "assets", "ruby_project") + return unless Dir.exist?(destination_root) + + FileUtils.rm_rf(destination_root) + build_log(verbose, "removed assets/ruby_project") + end + + def project_asset_relative_paths + root = Pathname.new(Dir.pwd) + included = [] + + Find.find(root.to_s) do |path| + pathname = Pathname.new(path) + relative = pathname.relative_path_from(root).to_s + next if relative.empty? + + if pathname.directory? + if skip_project_asset_directory?(relative) + Find.prune + else + next + end + end + + next unless include_project_asset_file?(relative) + + included << relative + end + + included.sort + end + + def skip_project_asset_directory?(relative) + first = relative.split(File::SEPARATOR).first + %w[ + .git + .bundle + .dart_tool + .idea + .vscode + build + coverage + log + node_modules + pkg + ruflet_client + tmp + vendor + ].include?(first) + end + + def include_project_asset_file?(relative) + basename = File.basename(relative) + return false if %w[Gemfile.lock pubspec.lock Podfile.lock package-lock.json yarn.lock pnpm-lock.yaml].include?(basename) + return true if %w[main.rb Gemfile ruflet.yaml ruflet.yml manifest.json].include?(basename) + + ext = File.extname(relative).downcase + return true if %w[.rb .json .yml .yaml].include?(ext) + + first = relative.split(File::SEPARATOR).first + return true if first == "assets" + + false + end + + def flutter_target_entrypoint(client_dir, self_contained:) + candidate = File.join( + client_dir, + "lib", + self_contained ? "main.self.dart" : "main.server.dart" + ) + return nil unless File.file?(candidate) + + File.join("lib", File.basename(candidate)) + end + + def ensure_local_ruby_runtime_override(client_dir, verbose: false) + pubspec_path = File.join(client_dir, "pubspec.yaml") + return unless File.file?(pubspec_path) + + pubspec = YAML.safe_load(File.read(pubspec_path), aliases: true) || {} + dependencies = pubspec["dependencies"] || {} + return unless dependencies.is_a?(Hash) && dependencies.key?("ruby_runtime") + + override_path = discover_local_ruby_runtime_path(client_dir) + return unless override_path + + overrides_path = File.join(client_dir, "pubspec_overrides.yaml") + content = <<~YAML + dependency_overrides: + ruby_runtime: + path: #{override_path} + YAML + File.write(overrides_path, content) + build_log(verbose, "ruby_runtime override=#{override_path}") + rescue StandardError => e + warn "Failed to prepare ruby_runtime override: #{e.class}: #{e.message}" + end + + def remove_local_ruby_runtime_override(client_dir, verbose: false) + overrides_path = File.join(client_dir, "pubspec_overrides.yaml") + return unless File.file?(overrides_path) + + File.delete(overrides_path) + build_log(verbose, "removed ruby_runtime override") + rescue StandardError => e + warn "Failed to remove ruby_runtime override: #{e.class}: #{e.message}" + end + + def discover_local_ruby_runtime_path(client_dir) + candidates = [] + + env_path = ENV["RUFLET_RUBY_RUNTIME_PATH"].to_s.strip + candidates << env_path unless env_path.empty? + candidates << File.expand_path("../ruby_runtime", client_dir) + candidates << File.expand_path("../../../../../ruby_runtime", __dir__) + + candidates.find do |path| + next false if path.to_s.empty? + + File.file?(File.join(path, "pubspec.yaml")) + end end def normalize_extension_key(value) @@ -274,38 +1087,34 @@ def prune_client_pubspec(path, selected_packages) end def prune_client_main(path, selected_aliases) - lines = File.readlines(path) + content = File.read(path) alias_to_package = {} - lines.each do |line| - match = line.match(%r{\Aimport 'package:(flet_[^/]+)/\1\.dart' as ([a-zA-Z0-9_]+);}) - next unless match - - alias_to_package[match[2]] = match[1] + content.scan(%r{^import 'package:(flet_[^/]+)/\1\.dart'\s+as ([a-zA-Z0-9_]+);$}m) do |package_name, import_alias| + alias_to_package[import_alias] = package_name end - kept = lines.select do |line| - import_match = line.match(%r{\Aimport 'package:(flet_[^/]+)/\1\.dart' as ([a-zA-Z0-9_]+);}) - if import_match - package_name = import_match[1] - next true if package_name == "flet" - next true if selected_aliases.include?(import_match[2]) - next false + content = content.gsub(%r{^import 'package:(flet_[^/]+)/\1\.dart'\s+as ([a-zA-Z0-9_]+);\n}m) do |match| + package_name = Regexp.last_match(1) + import_alias = Regexp.last_match(2) + if package_name == "flet" || selected_aliases.include?(import_alias) + match + else + "" end + end - extension_match = line.match(/\A\s*([a-zA-Z0-9_]+)\.Extension\(\),\s*\z/) - if extension_match - extension_alias = extension_match[1] - package_name = alias_to_package[extension_alias] - next true if package_name.nil? - next true if selected_aliases.include?(extension_alias) - next false + content = content.gsub(/^(\s*)([a-zA-Z0-9_]+)\.Extension\(\),\s*$/) do |match| + extension_alias = Regexp.last_match(2) + package_name = alias_to_package[extension_alias] + if package_name.nil? || selected_aliases.include?(extension_alias) + match + else + "" end - - true end - File.write(path, kept.join) + File.write(path, content) end def update_pubspec_value(path, block, key, value, multiple: false) @@ -367,6 +1176,38 @@ def flutter_build_command(platform) nil end end + + def build_log(verbose, message) + return unless verbose + + puts "[ruflet build] #{message}" + end + + def build_note(message) + puts "[ruflet build] #{message}" + end + + def announce_asset_configuration(asset_flags) + if asset_flags[:has_splash] + if asset_flags[:using_default_splash] + build_note("Splash screen will use the default template asset") + else + build_note("Splash screen is configured") + end + else + build_note("No splash screen configured") + end + + if asset_flags[:has_icon] + if asset_flags[:using_default_icon] + build_note("Launcher icons will use the default template asset") + else + build_note("Launcher icons are configured") + end + else + build_note("No launcher icons configured") + end + end end end end diff --git a/packages/ruflet/lib/ruflet/cli/extra_command.rb b/packages/ruflet/lib/ruflet/cli/extra_command.rb index 35ec107..6e0746a 100644 --- a/packages/ruflet/lib/ruflet/cli/extra_command.rb +++ b/packages/ruflet/lib/ruflet/cli/extra_command.rb @@ -6,6 +6,7 @@ module Ruflet module CLI module ExtraCommand include FlutterSdk + include NewCommand def command_create(args) command_new(args) @@ -15,9 +16,24 @@ def command_doctor(args) verbose = args.delete("--verbose") || args.delete("-v") fix = args.delete("--fix") client_dir = detect_client_dir + template_root = resolve_ruflet_client_template_root puts "Ruflet doctor" puts " Ruby: #{RUBY_VERSION}" puts " Flutter host target: #{flutter_host || 'unsupported'}" + if template_root + puts " Template: #{template_root}" + elsif fix + template_root = ensure_cached_ruflet_client_template!(verbose: !!verbose) + unless template_root + warn " Template: missing" + warn "Failed to fetch the Ruflet Flutter template from GitHub." + return 1 + end + puts " Template: #{template_root}" + else + warn " Template: missing" + warn "Run `ruflet doctor --fix` to fetch the Flutter template from GitHub." + end if fix tools = ensure_flutter!("doctor", client_dir: client_dir, auto_install: true) else @@ -97,7 +113,7 @@ def command_debug(args) client_dir = detect_client_dir unless client_dir warn "Could not find Flutter client directory." - warn "Set RUFLET_CLIENT_DIR or place client at ./ruflet_client" + warn "Set RUFLET_CLIENT_DIR or let Ruflet manage the hidden client under ./build/.ruflet/client" warn "`ruflet debug` requires Flutter client source code." warn "For prebuilt clients, use: `ruflet run --web` or `ruflet run --desktop`." return 1 @@ -132,6 +148,9 @@ def detect_client_dir env_dir = ENV["RUFLET_CLIENT_DIR"] return env_dir if env_dir && Dir.exist?(env_dir) + hidden = File.expand_path(File.join("build", ".ruflet", "client"), Dir.pwd) + return hidden if Dir.exist?(hidden) + local = File.expand_path("ruflet_client", Dir.pwd) return local if Dir.exist?(local) diff --git a/packages/ruflet/lib/ruflet/cli/flutter_sdk.rb b/packages/ruflet/lib/ruflet/cli/flutter_sdk.rb index 72a8bd5..dddc282 100644 --- a/packages/ruflet/lib/ruflet/cli/flutter_sdk.rb +++ b/packages/ruflet/lib/ruflet/cli/flutter_sdk.rb @@ -75,7 +75,7 @@ def flutter_tools_via_fvm(client_dir: nil, auto_install: true) system(fvm_env, fvm, "install", version.to_s, chdir: project_dir, out: File::NULL, err: File::NULL) system(fvm_env, fvm, "use", "--force", version.to_s, chdir: project_dir, out: File::NULL, err: File::NULL) - flutter = File.join(project_dir, ".fvm", "flutter_sdk", "bin", windows_host? ? "flutter.bat" : "flutter") + flutter = flutter_bin_path(project_dir) return nil unless File.executable?(flutter) tools_from_flutter_bin(flutter) @@ -91,28 +91,35 @@ def ensure_fvm_available(client_dir: nil) dart = which_command("dart") unless dart sdk_root = ensure_flutter_sdk_downloaded(client_dir: client_dir) - dart = sdk_root ? File.join(sdk_root, "bin", windows_host? ? "dart.bat" : "dart") : nil + dart = sdk_root ? File.join(sdk_root, "bin", dart_executable_name) : nil end return nil unless dart && File.executable?(dart) system(dart, "pub", "global", "activate", "fvm", out: File::NULL, err: File::NULL) + installed_fvm = File.join(pub_cache_bin_dir, fvm_executable_name) + return installed_fvm if File.executable?(installed_fvm) + which_command("fvm") end def fvm_env - pub_bin = File.join(Dir.home, ".pub-cache", "bin") - { "PATH" => "#{pub_bin}#{File::PATH_SEPARATOR}#{ENV.fetch('PATH', '')}" } + { "PATH" => "#{pub_cache_bin_dir}#{File::PATH_SEPARATOR}#{ENV.fetch('PATH', '')}" } end def tools_from_flutter_bin(flutter_bin) return nil unless File.executable?(flutter_bin) bin_dir = File.dirname(flutter_bin) - dart = File.join(bin_dir, windows_host? ? "dart.bat" : "dart") + sdk_root = File.expand_path("..", bin_dir) + dart = File.join(bin_dir, dart_executable_name) { flutter: flutter_bin, dart: (File.executable?(dart) ? dart : "dart"), - env: { "PATH" => "#{bin_dir}#{File::PATH_SEPARATOR}#{ENV.fetch('PATH', '')}" } + env: { + "PATH" => "#{bin_dir}#{File::PATH_SEPARATOR}#{pub_cache_bin_dir}#{File::PATH_SEPARATOR}#{ENV.fetch('PATH', '')}", + "FLUTTER_ROOT" => sdk_root, + "FVM_FLUTTER_SDK" => sdk_root + } } end @@ -125,7 +132,7 @@ def ensure_flutter_sdk_downloaded(client_dir: nil) archive = release.fetch("archive") install_root = File.join(Dir.home, ".ruflet", "flutter", release.fetch("version"), host) sdk_root = File.join(install_root, "flutter") - flutter_bin = File.join(sdk_root, "bin", windows_host? ? "flutter.bat" : "flutter") + flutter_bin = File.join(sdk_root, "bin", flutter_executable_name) return sdk_root if File.executable?(flutter_bin) FileUtils.mkdir_p(install_root) @@ -138,9 +145,9 @@ def ensure_flutter_sdk_downloaded(client_dir: nil) return sdk_root if File.executable?(flutter_bin) # Some archives may unpack into a different folder name. - guessed = Dir.glob(File.join(install_root, "**", windows_host? ? "flutter.bat" : "flutter")) + guessed = Dir.glob(File.join(install_root, "**", flutter_executable_name)) .map { |p| File.expand_path("../..", p) } - .find { |root| File.executable?(File.join(root, "bin", windows_host? ? "flutter.bat" : "flutter")) } + .find { |root| File.executable?(File.join(root, "bin", flutter_executable_name)) } return guessed if guessed nil @@ -195,12 +202,16 @@ def fvm_project_dir(client_dir: nil) end def existing_fvm_flutter_bin(project_dir) - flutter = File.join(project_dir, ".fvm", "flutter_sdk", "bin", windows_host? ? "flutter.bat" : "flutter") + flutter = flutter_bin_path(project_dir) return flutter if File.executable?(flutter) nil end + def flutter_bin_path(project_dir) + File.join(project_dir, ".fvm", "flutter_sdk", "bin", flutter_executable_name) + end + def find_fvmrc(client_dir) candidates = [] candidates << File.join(client_dir, ".fvmrc") if client_dir @@ -308,6 +319,22 @@ def windows_host? RbConfig::CONFIG["host_os"].match?(/mswin|mingw|cygwin/i) end + def flutter_executable_name + windows_host? ? "flutter.bat" : "flutter" + end + + def dart_executable_name + windows_host? ? "dart.bat" : "dart" + end + + def fvm_executable_name + windows_host? ? "fvm.bat" : "fvm" + end + + def pub_cache_bin_dir + File.join(Dir.home, ".pub-cache", "bin") + end + def which_command(name) exts = windows_host? ? ENV.fetch("PATHEXT", ".EXE;.BAT;.CMD").split(";") : [""] ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).each do |dir| diff --git a/packages/ruflet/lib/ruflet/cli/new_command.rb b/packages/ruflet/lib/ruflet/cli/new_command.rb index 68ed068..949e8a8 100644 --- a/packages/ruflet/lib/ruflet/cli/new_command.rb +++ b/packages/ruflet/lib/ruflet/cli/new_command.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "fileutils" +require "tmpdir" require "yaml" module Ruflet @@ -43,33 +44,90 @@ def command_new(args) File.write(File.join(root, "Gemfile"), Ruflet::CLI::GEMFILE_TEMPLATE) File.write(File.join(root, "README.md"), format(Ruflet::CLI::README_TEMPLATE, app_name: File.basename(root))) write_default_ruflet_config(root, File.basename(root)) - copy_ruflet_client_template(root) - configure_ruflet_client(root) - project_name = File.basename(root) puts "Run:" puts " cd #{project_name}" puts " bundle install" puts " bundle exec ruflet run main.rb" puts - puts "Client template:" - puts " cd ruflet_client" - puts " flutter pub get" - puts " flutter run" + puts "Build:" + puts " bundle exec ruflet build android --self" + puts " bundle exec ruflet build ios --self" 0 end private def copy_ruflet_client_template(root) - template_root = File.expand_path("../../../../../ruflet_client", __dir__) + template_root = resolve_ruflet_client_template_root return unless Dir.exist?(template_root) - target = File.join(root, "ruflet_client") + target = hidden_flutter_client_dir(root) + FileUtils.mkdir_p(File.dirname(target)) FileUtils.cp_r(template_root, target) prune_client_template(target) end + def hidden_flutter_client_dir(root = Dir.pwd) + File.join(root, "build", ".ruflet", "client") + end + + def resolve_ruflet_client_template_root + repo_template = File.expand_path("../../../../../templates/ruflet_flutter_template", __dir__) + return repo_template if Dir.exist?(repo_template) + + cached_template = cached_ruflet_client_template_root + return cached_template if Dir.exist?(cached_template) + + fallback = File.expand_path("../../../../../ruflet_client", __dir__) + return fallback if Dir.exist?(fallback) + + nil + end + + def ensure_cached_ruflet_client_template!(verbose: false) + cached_template = cached_ruflet_client_template_root + return cached_template if Dir.exist?(cached_template) + + download_ruflet_client_template(verbose: verbose) + end + + def cached_ruflet_client_template_root + File.join(template_cache_root, "ruflet_flutter_template") + end + + def template_cache_root + File.join(Dir.home, ".ruflet", "templates") + end + + def download_ruflet_client_template(verbose: false) + target = cached_ruflet_client_template_root + FileUtils.mkdir_p(template_cache_root) + + Dir.mktmpdir("ruflet-template") do |tmp| + repo_dir = File.join(tmp, "Ruflet") + clone_cmd = ["git", "clone", "--depth", "1", "--filter=blob:none", "--sparse", "https://github.com/AdamMusa/Ruflet.git", repo_dir] + return nil unless run_template_command(clone_cmd, verbose: verbose) + return nil unless run_template_command(["git", "-C", repo_dir, "sparse-checkout", "set", "templates/ruflet_flutter_template"], verbose: verbose) + + source = File.join(repo_dir, "templates", "ruflet_flutter_template") + return nil unless Dir.exist?(source) + + FileUtils.rm_rf(target) + FileUtils.cp_r(source, target) + end + + target + rescue StandardError => e + warn "Failed to fetch Ruflet template: #{e.class}: #{e.message}" + nil + end + + def run_template_command(cmd, verbose: false) + output = verbose ? $stdout : File::NULL + system(*cmd, out: output, err: verbose ? $stderr : File::NULL) + end + def prune_client_template(target) paths = %w[ .dart_tool @@ -83,6 +141,7 @@ def prune_client_template(target) android/.gradle android/.kotlin android/local.properties + pubspec_overrides.yaml ] paths.each do |path| full = File.join(target, path) @@ -94,9 +153,14 @@ def write_default_ruflet_config(root, app_name) File.write(File.join(root, "ruflet.yaml"), <<~YAML) app: name: #{app_name} - # Optional production client endpoint used by `ruflet build`. + display_name: #{humanize_name(app_name)} + package_name: #{app_name.gsub(/[^a-zA-Z0-9_]+/, "_").downcase} + organization: com.example + version: 1.0.0+1 + description: A new Ruflet app. + # Required for server-driven builds: `ruflet build ios`, `apk`, `web`, etc. without `--self`. # Example: https://api.example.com - ruflet_client_url: "" + backend_url: "" # Source of truth for Flutter client extensions/plugins. # Examples: camera, video, audio, flashlight, webview, map @@ -117,102 +181,6 @@ def write_default_ruflet_config(root, app_name) YAML end - def configure_ruflet_client(root) - config_path = File.join(root, "ruflet.yaml") - return unless File.file?(config_path) - - config = YAML.safe_load(File.read(config_path), aliases: true) || {} - extension_keys = extract_extension_keys(config) - extension_packages = extension_keys.filter_map { |key| CLIENT_EXTENSION_MAP[key]&.fetch(:package) }.uniq - extension_aliases = extension_keys.filter_map { |key| CLIENT_EXTENSION_MAP[key]&.fetch(:alias) }.uniq - - client_dir = File.join(root, "ruflet_client") - apply_client_manifest!(client_dir, extension_packages, extension_aliases) - rescue StandardError => e - warn "Failed to configure ruflet_client from ruflet.yaml: #{e.class}: #{e.message}" - end - - def extract_extension_keys(config) - from_services = Array(config["services"]) - - from_services - .map { |v| normalize_extension_key(v) } - .compact - .uniq - end - - def normalize_extension_key(value) - key = value.to_s.strip.downcase - return nil if key.empty? - - key.tr!("-", "_") - key.gsub!(/\A(flet_)+/, "") - key.gsub!(/\Aservice_/, "") - key.gsub!(/\Acontrol_/, "") - key = "file_picker" if key == "filepicker" - key - end - - def apply_client_manifest!(client_dir, extension_packages, extension_aliases) - return unless Dir.exist?(client_dir) - - pubspec_path = File.join(client_dir, "pubspec.yaml") - main_path = File.join(client_dir, "lib", "main.dart") - prune_client_pubspec(pubspec_path, extension_packages) if File.file?(pubspec_path) - prune_client_main(main_path, extension_aliases) if File.file?(main_path) - end - - def prune_client_pubspec(path, selected_packages) - data = YAML.safe_load(File.read(path), aliases: true) || {} - deps = (data["dependencies"] || {}).dup - - deps.keys.each do |name| - next unless name.start_with?("flet_") - next if name == "flet" - next if selected_packages.include?(name) - - deps.delete(name) - end - - data["dependencies"] = deps - File.write(path, YAML.dump(data)) - end - - def prune_client_main(path, selected_aliases) - lines = File.readlines(path) - alias_to_package = {} - - lines.each do |line| - match = line.match(%r{\Aimport 'package:(flet_[^/]+)/\1\.dart' as ([a-zA-Z0-9_]+);}) - next unless match - - alias_to_package[match[2]] = match[1] - end - - kept = lines.select do |line| - import_match = line.match(%r{\Aimport 'package:(flet_[^/]+)/\1\.dart' as ([a-zA-Z0-9_]+);}) - if import_match - package_name = import_match[1] - next true if package_name == "flet" - next true if selected_aliases.include?(import_match[2]) - next false - end - - extension_match = line.match(/\A\s*([a-zA-Z0-9_]+)\.Extension\(\),\s*\z/) - if extension_match - extension_alias = extension_match[1] - package_name = alias_to_package[extension_alias] - next true if package_name.nil? # non-Flet extension lines - next true if selected_aliases.include?(extension_alias) - next false - end - - true - end - - File.write(path, kept.join) - end - def humanize_name(name) name.to_s.gsub(/[_-]+/, " ").split.map(&:capitalize).join(" ") end diff --git a/packages/ruflet/lib/ruflet/cli/run_command.rb b/packages/ruflet/lib/ruflet/cli/run_command.rb index c546d39..16b4878 100644 --- a/packages/ruflet/lib/ruflet/cli/run_command.rb +++ b/packages/ruflet/lib/ruflet/cli/run_command.rb @@ -40,6 +40,7 @@ def command_run(args) "RUFLET_SUPPRESS_SERVER_BANNER" => "1", "RUFLET_PORT" => selected_port.to_s } + apply_local_ruflet_dev_overrides(env) assets_dir = File.join(File.dirname(script_path), "assets") env["RUFLET_ASSETS_DIR"] = assets_dir if File.directory?(assets_dir) @@ -105,6 +106,24 @@ def build_runtime_command(script_path, gemfile_path:, env:) [RbConfig.ruby, script_path] end + def apply_local_ruflet_dev_overrides(env) + lib_paths = local_ruflet_dev_lib_paths + return if lib_paths.empty? + + existing = env["RUBYLIB"].to_s + segments = lib_paths + existing.split(File::PATH_SEPARATOR).reject(&:empty?) + env["RUBYLIB"] = segments.uniq.join(File::PATH_SEPARATOR) + puts "Ruflet dev source: #{lib_paths.join(", ")}" + end + + def local_ruflet_dev_lib_paths + repo_root = File.expand_path("../../../../../", __dir__) + package_roots = %w[ruflet_core ruflet_server].map { |name| File.join(repo_root, "packages", name, "lib") } + return [] unless package_roots.all? { |path| Dir.exist?(path) } + + package_roots + end + def resolve_script(token) path = File.expand_path(token, Dir.pwd) return path if File.file?(path) diff --git a/packages/ruflet/test/extra_command_test.rb b/packages/ruflet/test/extra_command_test.rb index 5bed011..f688f6b 100644 --- a/packages/ruflet/test/extra_command_test.rb +++ b/packages/ruflet/test/extra_command_test.rb @@ -10,6 +10,7 @@ class DummyExtra def test_doctor_without_fix_reports_missing_flutter_without_installing runner = DummyExtra.new runner.define_singleton_method(:detect_client_dir) { nil } + runner.define_singleton_method(:resolve_ruflet_client_template_root) { nil } runner.define_singleton_method(:flutter_host) { "macos_arm64" } runner.define_singleton_method(:flutter_tools) { |client_dir: nil, auto_install: true| auto_install ? { flutter: "unexpected", env: {} } : nil } @@ -24,6 +25,7 @@ def test_doctor_without_fix_reports_missing_flutter_without_installing assert_equal 1, code assert_includes out.string, "Flutter host target: macos_arm64" + assert_includes err.string, "Template: missing" assert_includes err.string, "Run `ruflet doctor --fix`" ensure $stderr = original_stderr @@ -34,6 +36,8 @@ def test_doctor_fix_uses_host_platform_install_path runner = DummyExtra.new calls = [] runner.define_singleton_method(:detect_client_dir) { "/tmp/client" } + runner.define_singleton_method(:resolve_ruflet_client_template_root) { nil } + runner.define_singleton_method(:ensure_cached_ruflet_client_template!) { |verbose: false| "/tmp/ruflet_flutter_template" } runner.define_singleton_method(:flutter_host) { "macos_arm64" } runner.define_singleton_method(:ensure_flutter!) do |command_name, client_dir: nil, auto_install: true| calls << { command_name: command_name, client_dir: client_dir, auto_install: auto_install } @@ -51,6 +55,7 @@ def test_doctor_fix_uses_host_platform_install_path assert_equal 0, code assert_equal [{ command_name: "doctor", client_dir: "/tmp/client", auto_install: true }], calls assert_includes out.string, "Flutter host target: macos_arm64" + assert_includes out.string, "Template: /tmp/ruflet_flutter_template" assert_includes out.string, "Flutter: 3.41.4 stable" refute_includes out.string, "/tmp/client/.fvm/flutter_sdk/bin/flutter" ensure diff --git a/packages/ruflet/test/flutter_sdk_test.rb b/packages/ruflet/test/flutter_sdk_test.rb index 735eb97..21dde73 100644 --- a/packages/ruflet/test/flutter_sdk_test.rb +++ b/packages/ruflet/test/flutter_sdk_test.rb @@ -77,4 +77,68 @@ def test_pick_release_matches_by_revision assert_equal "3.32.5", release["version"] end + + def test_ensure_fvm_available_returns_pub_cache_binary_after_activation + sdk = DummySdk.new + + Dir.mktmpdir do |dir| + pub_cache_dir = File.join(dir, ".pub-cache", "bin") + FileUtils.mkdir_p(pub_cache_dir) + fvm_path = File.join(pub_cache_dir, "fvm") + File.write(fvm_path, "#!/bin/sh\n") + FileUtils.chmod("+x", fvm_path) + + sdk.define_singleton_method(:which_command) do |name| + return nil if name == "fvm" + "/tmp/dart" if name == "dart" + end + sdk.define_singleton_method(:system) { |_dart, *_args, **_kwargs| true } + + Dir.stub(:home, dir) do + assert_equal fvm_path, sdk.send(:ensure_fvm_available) + end + end + end + + def test_tools_from_flutter_bin_exposes_fvm_sdk_environment + sdk = DummySdk.new + + Dir.mktmpdir do |dir| + bin_dir = File.join(dir, "bin") + FileUtils.mkdir_p(bin_dir) + flutter = File.join(bin_dir, "flutter") + dart = File.join(bin_dir, "dart") + File.write(flutter, "#!/bin/sh\n") + File.write(dart, "#!/bin/sh\n") + FileUtils.chmod("+x", flutter) + FileUtils.chmod("+x", dart) + + Dir.stub(:home, dir) do + tools = sdk.send(:tools_from_flutter_bin, flutter) + + assert_equal flutter, tools[:flutter] + assert_equal dart, tools[:dart] + assert_equal dir, tools[:env]["FLUTTER_ROOT"] + assert_equal dir, tools[:env]["FVM_FLUTTER_SDK"] + assert_includes tools[:env]["PATH"], bin_dir + assert_includes tools[:env]["PATH"], File.join(dir, ".pub-cache", "bin") + end + end + end + + def test_flutter_host_detects_linux + sdk = DummySdk.new + + RbConfig::CONFIG.stub(:[], "linux-gnu") do + assert_equal "linux", sdk.send(:flutter_host) + end + end + + def test_flutter_host_detects_windows + sdk = DummySdk.new + + RbConfig::CONFIG.stub(:[], "mingw32") do + assert_equal "windows", sdk.send(:flutter_host) + end + end end diff --git a/packages/ruflet/test/new_command_test.rb b/packages/ruflet/test/new_command_test.rb index 1f88b18..f999c86 100644 --- a/packages/ruflet/test/new_command_test.rb +++ b/packages/ruflet/test/new_command_test.rb @@ -10,13 +10,6 @@ def test_command_new_creates_project_scaffold original_stdout = $stdout $stdout = out - cli_singleton = Ruflet::CLI.singleton_class - had_method = cli_singleton.private_method_defined?(:copy_ruflet_client_template) || cli_singleton.method_defined?(:copy_ruflet_client_template) - original_method = Ruflet::CLI.method(:copy_ruflet_client_template) if had_method - - cli_singleton.send(:define_method, :copy_ruflet_client_template) { |_root| nil } - cli_singleton.send(:private, :copy_ruflet_client_template) - result = Ruflet::CLI.command_new(["demo_app"]) assert_equal 0, result @@ -24,66 +17,56 @@ def test_command_new_creates_project_scaffold assert File.exist?(File.join(dir, "demo_app", "Gemfile")) assert File.exist?(File.join(dir, "demo_app", "README.md")) assert File.exist?(File.join(dir, "demo_app", "ruflet.yaml")) + refute File.exist?(File.join(dir, "demo_app", "ruflet_client")) refute File.exist?(File.join(dir, "demo_app", ".bundle", "config")) ensure $stdout = original_stdout - - if had_method - cli_singleton.send(:define_method, :copy_ruflet_client_template, original_method) - cli_singleton.send(:private, :copy_ruflet_client_template) - else - cli_singleton.send(:remove_method, :copy_ruflet_client_template) - end end end end - def test_prune_client_manifest_keeps_only_selected_extensions + def test_copy_ruflet_client_template_prefers_flutter_template Dir.mktmpdir do |dir| - client_dir = File.join(dir, "ruflet_client") - FileUtils.mkdir_p(File.join(client_dir, "lib")) + target_root = File.join(dir, "demo") + FileUtils.mkdir_p(target_root) - File.write( - File.join(client_dir, "pubspec.yaml"), - <<~YAML - dependencies: - flutter: - sdk: flutter - flet: - git: - url: https://github.com/flet-dev/flet.git - flet_camera: - git: - url: https://github.com/flet-dev/flet.git - flet_video: - git: - url: https://github.com/flet-dev/flet.git - YAML - ) + Ruflet::CLI.send(:copy_ruflet_client_template, target_root) - File.write( - File.join(client_dir, "lib", "main.dart"), - <<~DART - import 'package:flet/flet.dart'; - import 'package:flet_camera/flet_camera.dart' as ruflet_camera; - import 'package:flet_video/flet_video.dart' as ruflet_video; + client_dir = File.join(target_root, "build", ".ruflet", "client") + assert File.directory?(client_dir) + assert File.file?(File.join(client_dir, "assets", "main.rb")) + assert File.file?(File.join(client_dir, "lib", "main.dart")) + assert File.file?(File.join(client_dir, "lib", "main.self.dart")) + assert File.file?(File.join(client_dir, "lib", "main.server.dart")) + end + end - final extensions = [ - ruflet_camera.Extension(), - ruflet_video.Extension(), - ]; - DART - ) + def test_copy_ruflet_client_template_uses_cached_template_when_repo_template_missing + Dir.mktmpdir do |dir| + target_root = File.join(dir, "demo") + cached_template = File.join(dir, "cached_template") + FileUtils.mkdir_p(File.join(cached_template, "lib")) + FileUtils.mkdir_p(File.join(cached_template, "assets")) + File.write(File.join(cached_template, "assets", "main.rb"), "puts 'hi'\n") + File.write(File.join(cached_template, "lib", "main.dart"), "void main() {}\n") + File.write(File.join(cached_template, "lib", "main.self.dart"), "void main() {}\n") + File.write(File.join(cached_template, "lib", "main.server.dart"), "void main() {}\n") + FileUtils.mkdir_p(target_root) - Ruflet::CLI.send(:apply_client_manifest!, client_dir, ["flet_camera"], ["ruflet_camera"]) + cli_singleton = Ruflet::CLI.singleton_class + original_method = Ruflet::CLI.method(:resolve_ruflet_client_template_root) + cli_singleton.send(:define_method, :resolve_ruflet_client_template_root) { cached_template } + cli_singleton.send(:private, :resolve_ruflet_client_template_root) - pruned_pubspec = File.read(File.join(client_dir, "pubspec.yaml")) - pruned_main = File.read(File.join(client_dir, "lib", "main.dart")) + Ruflet::CLI.send(:copy_ruflet_client_template, target_root) - assert_includes pruned_pubspec, "flet_camera:" - refute_includes pruned_pubspec, "flet_video:" - assert_includes pruned_main, "ruflet_camera.Extension()" - refute_includes pruned_main, "ruflet_video.Extension()" + client_dir = File.join(target_root, "build", ".ruflet", "client") + assert File.directory?(client_dir) + assert File.file?(File.join(client_dir, "assets", "main.rb")) + assert File.file?(File.join(client_dir, "lib", "main.server.dart")) + ensure + cli_singleton.send(:define_method, :resolve_ruflet_client_template_root, original_method) + cli_singleton.send(:private, :resolve_ruflet_client_template_root) end end end diff --git a/packages/ruflet/test/update_command_test.rb b/packages/ruflet/test/update_command_test.rb index 7414ce7..92ede12 100644 --- a/packages/ruflet/test/update_command_test.rb +++ b/packages/ruflet/test/update_command_test.rb @@ -1,12 +1,17 @@ # frozen_string_literal: true require_relative "test_helper" +require "fileutils" class RufletCliUpdateCommandTest < Minitest::Test class DummyUpdater include Ruflet::CLI::UpdateCommand end + class DummyBuilder + include Ruflet::CLI::BuildCommand + end + def test_command_update_check_reports_manifest_status updater = DummyUpdater.new Dir.mktmpdir do |dir| @@ -59,4 +64,766 @@ def test_command_update_downloads_requested_target_for_platform ensure $stdout = original_stdout end + + def test_prepare_flutter_client_writes_local_ruby_runtime_override_before_pub_get + builder = DummyBuilder.new + + Dir.mktmpdir do |dir| + client_dir = File.join(dir, "ruflet_client") + runtime_dir = File.join(dir, "ruby_runtime") + FileUtils.mkdir_p(client_dir) + FileUtils.mkdir_p(runtime_dir) + File.write(File.join(runtime_dir, "pubspec.yaml"), "name: ruby_runtime\n") + File.write( + File.join(client_dir, "pubspec.yaml"), + <<~YAML + dependencies: + flutter: + sdk: flutter + ruby_runtime: ^0.0.1 + YAML + ) + + calls = [] + builder.define_singleton_method(:apply_service_extension_config) { |_client_dir, _config| nil } + builder.define_singleton_method(:apply_build_config) { |_client_dir, _config| { has_icon: false, has_splash: false, error: nil } } + builder.define_singleton_method(:system) do |_env, *_args, chdir: nil| + calls << chdir + true + end + + builder.send( + :prepare_flutter_client, + client_dir, + platform: "apk", + tools: { env: {}, flutter: "flutter", dart: "dart" }, + config: {}, + self_contained: true, + verbose: false + ) + + overrides = File.read(File.join(client_dir, "pubspec_overrides.yaml")) + assert_includes overrides, "ruby_runtime:" + assert_includes overrides, runtime_dir + assert_includes calls, client_dir + end + end + + def test_prepare_flutter_client_server_mode_removes_ruby_runtime_dependency_and_override + builder = DummyBuilder.new + + Dir.mktmpdir do |dir| + client_dir = File.join(dir, "ruflet_client") + FileUtils.mkdir_p(client_dir) + File.write( + File.join(client_dir, "pubspec.yaml"), + <<~YAML + dependencies: + flutter: + sdk: flutter + ruby_runtime: ^0.0.1 + flutter: + assets: + - assets/main.rb + YAML + ) + File.write( + File.join(client_dir, "pubspec_overrides.yaml"), + <<~YAML + dependency_overrides: + ruby_runtime: + path: ../ruby_runtime + YAML + ) + + builder.define_singleton_method(:apply_service_extension_config) { |_client_dir, _config| nil } + builder.define_singleton_method(:apply_build_config) { |_client_dir, _config| { has_icon: false, has_splash: false, error: nil } } + builder.define_singleton_method(:system) { |_env, *_args, chdir: nil| true } + + builder.send( + :prepare_flutter_client, + client_dir, + platform: "ios", + tools: { env: {}, flutter: "flutter", dart: "dart" }, + config: {}, + self_contained: false, + verbose: false + ) + + pubspec = File.read(File.join(client_dir, "pubspec.yaml")) + refute_includes pubspec, "ruby_runtime" + refute_includes pubspec, "assets/main.rb" + refute File.exist?(File.join(client_dir, "pubspec_overrides.yaml")) + end + end + + def test_command_build_verbose_logs_bootstrap_and_passes_v_to_flutter + builder = DummyBuilder.new + + Dir.mktmpdir do |dir| + client_dir = File.join(dir, "ruflet_client") + FileUtils.mkdir_p(File.join(client_dir, "lib")) + File.write(File.join(client_dir, "lib", "main.self.dart"), "void main() {}\n") + File.write(File.join(client_dir, "lib", "main.server.dart"), "void main() {}\n") + + builder.define_singleton_method(:detect_flutter_client_dir) { client_dir } + builder.define_singleton_method(:load_ruflet_config) { { "app" => { "backend_url" => "https://api.example.com" } } } + builder.define_singleton_method(:ensure_flutter!) do |_command_name, client_dir: nil, auto_install: true| + { flutter: "flutter", dart: "dart", env: { "PATH" => "/tmp/bin" } } + end + builder.define_singleton_method(:prepare_flutter_client) do |_client_dir, tools:, config:, self_contained: false, verbose: false| + puts "[ruflet build] ruby_runtime override=/tmp/ruby_runtime" if verbose + puts "[ruflet build] running flutter pub get" if verbose + true + end + + calls = [] + builder.define_singleton_method(:system) do |_env, *_args, chdir: nil| + calls << { args: _args, chdir: chdir } + true + end + + out = StringIO.new + original_stdout = $stdout + $stdout = out + + code = builder.command_build(["apk", "--self", "--verbose"]) + + assert_equal 0, code + assert_includes out.string, "[ruflet build] ruby_runtime override=/tmp/ruby_runtime" + assert_includes out.string, "[ruflet build] running flutter pub get" + assert_includes out.string, "[ruflet build] mode=self" + assert_includes out.string, "[ruflet build] target=lib/main.self.dart" + assert_includes out.string, "[ruflet build] command=flutter build apk --target lib/main.self.dart -v" + assert_equal ["flutter", "build", "apk", "--target", "lib/main.self.dart", "--dart-define", "RUFLET_BACKEND_URL=https://api.example.com", "-v"], calls.first[:args] + assert_equal client_dir, calls.first[:chdir] + ensure + $stdout = original_stdout + end + end + + def test_command_build_bootstraps_missing_flutter_client_from_template + builder = DummyBuilder.new + + Dir.mktmpdir do |dir| + previous_dir = Dir.pwd + Dir.chdir(dir) + + copied = [] + Ruflet::CLI.define_singleton_method(:copy_ruflet_client_template) do |root| + copied << root + client_dir = File.join(root, "build", ".ruflet", "client") + FileUtils.mkdir_p(File.join(client_dir, "lib")) + File.write(File.join(client_dir, "pubspec.yaml"), "name: ruflet_client\n") + File.write(File.join(client_dir, "lib", "main.server.dart"), "void main() {}\n") + end + Ruflet::CLI.singleton_class.send(:private, :copy_ruflet_client_template) + + builder.define_singleton_method(:load_ruflet_config) { { "app" => { "backend_url" => "https://api.example.com" } } } + 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) do |_client_dir, tools:, config:, self_contained: false, verbose: false| + true + end + + calls = [] + builder.define_singleton_method(:system) do |_env, *_args, chdir: nil| + calls << { args: _args, chdir: chdir } + true + end + + code = builder.command_build(["ios"]) + + assert_equal 0, code + assert_equal [dir], copied + assert_equal File.join(dir, "build", ".ruflet", "client"), calls.first[:chdir] + assert_equal ["flutter", "build", "ios", "--no-codesign", "--target", "lib/main.server.dart", "--dart-define", "RUFLET_BACKEND_URL=https://api.example.com"], calls.first[:args] + ensure + Dir.chdir(previous_dir) + end + end + + def test_export_platform_build_outputs_copies_hidden_android_outputs_to_user_build_dir + builder = DummyBuilder.new + + Dir.mktmpdir do |dir| + previous_dir = Dir.pwd + Dir.chdir(dir) + + client_dir = File.join(dir, "build", ".ruflet", "client") + source_dir = File.join(client_dir, "build", "app", "outputs", "flutter-apk") + FileUtils.mkdir_p(source_dir) + File.write(File.join(source_dir, "app-release.apk"), "apk") + + builder.send(:export_platform_build_outputs, client_dir, "android", verbose: false) + + assert File.exist?(File.join(dir, "build", "android", "flutter-apk", "app-release.apk")) + ensure + Dir.chdir(previous_dir) + end + end + + def test_command_build_requires_backend_url_for_server_driven_mode + builder = DummyBuilder.new + + Dir.mktmpdir do |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") + + 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, tools:, config:, self_contained: false, verbose: false| true } + builder.define_singleton_method(:system) { |_env, *_args, chdir: nil| flunk("system should not be called without backend_url") } + + err = StringIO.new + original_stderr = $stderr + $stderr = err + + code = builder.command_build(["ios"]) + + assert_equal 1, code + assert_includes err.string, "build config error: backend_url is required for server-driven builds" + ensure + $stderr = original_stderr + end + end + + def test_command_build_ios_uses_unbundled_env_for_flutter + builder = DummyBuilder.new + + Dir.mktmpdir do |dir| + client_dir = File.join(dir, "ruflet_client") + FileUtils.mkdir_p(File.join(client_dir, "lib")) + File.write(File.join(client_dir, "lib", "main.self.dart"), "void main() {}\n") + + 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: { + "BUNDLE_GEMFILE" => "/tmp/example/Gemfile", + "PATH" => "/Users/macbookpro/.gem/ruby/3.4.0/bin:/tmp/bin", + "GEM_HOME" => "/Users/macbookpro/.gem/ruby/3.4.0", + "GEM_PATH" => "/Users/macbookpro/.gem/ruby/3.4.0:/opt/homebrew/lib/ruby/gems/3.4.0" + } + } + end + builder.define_singleton_method(:prepare_flutter_client) do |_client_dir, platform:, tools:, config:, self_contained: false, verbose: false| + refute tools[:env].key?("BUNDLE_GEMFILE") + refute_includes tools[:env]["PATH"], "/Users/macbookpro/.gem/ruby/3.4.0/bin" + assert_includes tools[:env]["PATH"], File.join(client_dir, ".ruflet", "bin") + assert_nil tools[:env]["GEM_HOME"] + assert_nil tools[:env]["GEM_PATH"] + true + end + + calls = [] + builder.define_singleton_method(:system) do |_env, *_args, chdir: nil| + calls << { env: _env, args: _args, chdir: chdir } + true + end + + code = builder.command_build(["ios", "--self"]) + + assert_equal 0, code + refute calls.first[:env].key?("BUNDLE_GEMFILE") + 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")) + assert_nil calls.first[:env]["GEM_HOME"] + assert_nil calls.first[:env]["GEM_PATH"] + end + end + + def test_command_install_syncs_android_build_outputs_and_runs_flutter_install + builder = DummyBuilder.new + + Dir.mktmpdir do |dir| + previous_dir = Dir.pwd + Dir.chdir(dir) + client_dir = File.join(dir, "build", ".ruflet", "client") + FileUtils.mkdir_p(File.join(client_dir, "lib")) + File.write(File.join(client_dir, "lib", "main.self.dart"), "void main() {}\n") + apk_dir = File.join(dir, "build", "android", "flutter-apk") + FileUtils.mkdir_p(apk_dir) + File.write(File.join(apk_dir, "app-release.apk"), "apk") + + 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 } + true + end + + code = builder.command_install(["--device", "emulator-5554"]) + + assert_equal 0, code + assert_equal ["flutter", "install", "-d", "emulator-5554"], calls.first[:args] + assert_equal client_dir, calls.first[:chdir] + assert File.exist?(File.join(client_dir, "build", "app", "outputs", "flutter-apk", "app-release.apk")) + ensure + Dir.chdir(previous_dir) + end + end + + def test_command_install_syncs_ios_build_outputs_and_runs_flutter_install + builder = DummyBuilder.new + + Dir.mktmpdir do |dir| + previous_dir = Dir.pwd + Dir.chdir(dir) + client_dir = File.join(dir, "build", ".ruflet", "client") + FileUtils.mkdir_p(File.join(client_dir, "lib")) + File.write(File.join(client_dir, "lib", "main.self.dart"), "void main() {}\n") + 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") + + 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 } + true + end + + code = builder.command_install([]) + + assert_equal 0, code + assert_equal ["flutter", "install"], calls.first[:args] + assert_equal client_dir, calls.first[:chdir] + assert File.exist?(File.join(client_dir, "build", "ios", "iphonesimulator", "Runner.app", "Info.plist")) + ensure + Dir.chdir(previous_dir) + end + end + + def test_command_install_fails_when_no_built_outputs_exist + builder = DummyBuilder.new + + Dir.mktmpdir do |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") + + 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") } + builder.define_singleton_method(:system) { |_env, *_args, chdir: nil| flunk("install should not run without built outputs") } + + err = StringIO.new + original_stderr = $stderr + $stderr = err + + code = builder.command_install([]) + + assert_equal 1, code + assert_includes err.string, "Could not find built app outputs under ./build" + ensure + $stderr = original_stderr + end + end + + def test_apply_build_config_falls_back_to_template_assets_when_custom_files_are_missing + builder = DummyBuilder.new + + Dir.mktmpdir do |dir| + client_dir = File.join(dir, "ruflet_client") + FileUtils.mkdir_p(File.join(client_dir, "assets")) + File.write(File.join(client_dir, "assets", "splash.png"), "png") + File.write(File.join(client_dir, "assets", "icon.png"), "png") + File.write( + File.join(client_dir, "pubspec.yaml"), + <<~YAML + flutter_native_splash: + image: assets/splash.png + flutter_launcher_icons: + image_path: "assets/icon.png" + YAML + ) + + out = StringIO.new + original_stdout = $stdout + $stdout = out + + result = builder.send( + :apply_build_config, + client_dir, + { + "assets" => { + "splash_screen" => "missing/splash.png", + "icon_launcher" => "missing/icon.png" + } + } + ) + + assert_nil result[:error] + assert_equal true, result[:has_splash] + assert_equal true, result[:has_icon] + assert_equal true, result[:using_default_splash] + assert_equal true, result[:using_default_icon] + assert_includes out.string, "Configured splash_screen was not found; using default template asset" + assert_includes out.string, "Configured icon_launcher was not found; using default template asset" + ensure + $stdout = original_stdout + end + end + + def test_prepare_flutter_client_announces_and_runs_asset_generators + builder = DummyBuilder.new + + Dir.mktmpdir do |dir| + client_dir = File.join(dir, "ruflet_client") + FileUtils.mkdir_p(client_dir) + + builder.define_singleton_method(:apply_service_extension_config) { |_client_dir, _config| nil } + builder.define_singleton_method(:configure_client_runtime_mode) { |_client_dir, self_contained:, verbose: false| nil } + builder.define_singleton_method(:apply_build_config) do |_client_dir, _config| + { + has_icon: true, + has_splash: true, + using_default_icon: true, + using_default_splash: false, + error: nil + } + end + + calls = [] + builder.define_singleton_method(:system) do |_env, *args, chdir: nil| + calls << { args: args, chdir: chdir } + true + end + + out = StringIO.new + original_stdout = $stdout + $stdout = out + + result = builder.send( + :prepare_flutter_client, + client_dir, + platform: "android", + tools: { env: {}, flutter: "flutter", dart: "dart" }, + config: {}, + self_contained: false, + verbose: false + ) + + assert_equal true, result + assert_includes out.string, "Splash screen is configured" + assert_includes out.string, "Launcher icons will use the default template asset" + assert_includes out.string, "Generating splash screen with flutter_native_splash" + assert_includes out.string, "Generating launcher icons with flutter_launcher_icons" + assert_equal ["flutter", "pub", "get"], calls[0][:args] + assert_equal ["dart", "run", "flutter_native_splash:create"], calls[1][:args] + assert_equal ["dart", "run", "flutter_launcher_icons"], calls[2][:args] + ensure + $stdout = original_stdout + end + end + + def test_sync_client_metadata_updates_platform_files_from_ruflet_config + builder = DummyBuilder.new + + Dir.mktmpdir do |dir| + client_dir = File.join(dir, "ruflet_client") + FileUtils.mkdir_p(File.join(client_dir, "android", "app", "src", "main")) + FileUtils.mkdir_p(File.join(client_dir, "ios", "Runner")) + FileUtils.mkdir_p(File.join(client_dir, "ios", "Runner.xcodeproj")) + FileUtils.mkdir_p(File.join(client_dir, "macos", "Runner", "Configs")) + FileUtils.mkdir_p(File.join(client_dir, "web")) + FileUtils.mkdir_p(File.join(client_dir, "windows", "runner")) + FileUtils.mkdir_p(File.join(client_dir, "linux")) + + File.write( + File.join(client_dir, "pubspec.yaml"), + <<~YAML + name: ruflet_client + description: "A new Flutter project." + version: 1.0.0+1 + YAML + ) + File.write( + File.join(client_dir, "android", "app", "build.gradle.kts"), + <<~KTS + android { + namespace = "com.example.ruflet_client" + defaultConfig { + applicationId = "com.example.ruflet_client" + } + } + KTS + ) + File.write( + File.join(client_dir, "android", "app", "src", "main", "AndroidManifest.xml"), + <<~XML + + XML + ) + File.write( + File.join(client_dir, "ios", "Runner", "Info.plist"), + <<~PLIST + + CFBundleDisplayNameRuflet Demo + CFBundleNameRuflet Demo + + PLIST + ) + File.write( + File.join(client_dir, "ios", "Runner.xcodeproj", "project.pbxproj"), + <<~PBX + INFOPLIST_KEY_CFBundleDisplayName = "Ruflet Demo"; + PRODUCT_BUNDLE_IDENTIFIER = com.example.ruflet_client; + PRODUCT_BUNDLE_IDENTIFIER = com.example.ruflet_client.RunnerTests; + PBX + ) + File.write( + File.join(client_dir, "macos", "Runner", "Configs", "AppInfo.xcconfig"), + <<~XCCONFIG + PRODUCT_NAME = ruflet_client + PRODUCT_BUNDLE_IDENTIFIER = com.example.rubyNativeClient + PRODUCT_COPYRIGHT = Copyright © 2026 com.example. All rights reserved. + XCCONFIG + ) + File.write( + File.join(client_dir, "web", "manifest.json"), + <<~JSON + {"name":"ruflet_client","short_name":"ruflet_client","description":"A new Flutter project."} + JSON + ) + File.write( + File.join(client_dir, "web", "index.html"), + <<~HTML + + + ruflet_client + HTML + ) + File.write( + File.join(client_dir, "windows", "CMakeLists.txt"), + <<~CMAKE + project(ruflet_client LANGUAGES CXX) + set(BINARY_NAME "ruflet_client") + CMAKE + ) + File.write( + File.join(client_dir, "windows", "runner", "Runner.rc"), + <<~RC + VALUE "CompanyName", "com.example" "\\0" + VALUE "FileDescription", "ruflet_client" "\\0" + VALUE "InternalName", "ruflet_client" "\\0" + VALUE "LegalCopyright", "Copyright (C) 2026 com.example. All rights reserved." "\\0" + VALUE "OriginalFilename", "ruflet_client.exe" "\\0" + VALUE "ProductName", "ruflet_client" "\\0" + RC + ) + File.write( + File.join(client_dir, "linux", "CMakeLists.txt"), + <<~CMAKE + set(BINARY_NAME "ruflet_client") + set(APPLICATION_ID "com.example.ruflet_client") + CMAKE + ) + + builder.send( + :sync_client_metadata, + client_dir, + { + "app" => { + "name" => "Test App", + "package_name" => "test_app", + "organization" => "com.acme", + "description" => "Configured by ruflet", + "version" => "2.3.4+5" + } + }, + verbose: false + ) + + pubspec = File.read(File.join(client_dir, "pubspec.yaml")) + assert_includes pubspec, "name: test_app" + assert_includes pubspec, "description: Configured by ruflet" + assert_includes pubspec, "version: 2.3.4+5" + + android_gradle = File.read(File.join(client_dir, "android", "app", "build.gradle.kts")) + assert_includes android_gradle, 'namespace = "com.acme.test_app"' + assert_includes android_gradle, 'applicationId = "com.acme.test_app"' + assert_includes File.read(File.join(client_dir, "android", "app", "src", "main", "AndroidManifest.xml")), 'android:label="Test App"' + + ios_info = File.read(File.join(client_dir, "ios", "Runner", "Info.plist")) + assert_includes ios_info, "Test App" + ios_project = File.read(File.join(client_dir, "ios", "Runner.xcodeproj", "project.pbxproj")) + assert_includes ios_project, 'INFOPLIST_KEY_CFBundleDisplayName = "Test App";' + assert_includes ios_project, "PRODUCT_BUNDLE_IDENTIFIER = com.acme.test_app;" + assert_includes ios_project, "PRODUCT_BUNDLE_IDENTIFIER = com.example.ruflet_client.RunnerTests;" + + macos_info = File.read(File.join(client_dir, "macos", "Runner", "Configs", "AppInfo.xcconfig")) + assert_includes macos_info, "PRODUCT_NAME = Test App" + assert_includes macos_info, "PRODUCT_BUNDLE_IDENTIFIER = com.acme.test_app" + + web_manifest = File.read(File.join(client_dir, "web", "manifest.json")) + assert_includes web_manifest, '"name": "Test App"' + assert_includes web_manifest, '"short_name": "Test App"' + + web_index = File.read(File.join(client_dir, "web", "index.html")) + assert_includes web_index, 'Test App' + assert_includes web_index, 'content="Configured by ruflet"' + + windows_cmake = File.read(File.join(client_dir, "windows", "CMakeLists.txt")) + assert_includes windows_cmake, "project(test_app LANGUAGES CXX)" + assert_includes windows_cmake, 'set(BINARY_NAME "test_app")' + + windows_rc = File.read(File.join(client_dir, "windows", "runner", "Runner.rc")) + assert_includes windows_rc, 'VALUE "CompanyName", "com.acme" "\\0"' + assert_includes windows_rc, 'VALUE "ProductName", "Test App" "\\0"' + + linux_cmake = File.read(File.join(client_dir, "linux", "CMakeLists.txt")) + assert_includes linux_cmake, 'set(BINARY_NAME "test_app")' + assert_includes linux_cmake, 'set(APPLICATION_ID "com.acme.test_app")' + end + end + + def test_prepare_flutter_client_runs_pod_install_for_ios + builder = DummyBuilder.new + + Dir.mktmpdir do |dir| + client_dir = File.join(dir, "ruflet_client") + ios_dir = File.join(client_dir, "ios") + FileUtils.mkdir_p(ios_dir) + File.write(File.join(ios_dir, "Podfile"), "platform :ios, '13.0'\n") + + builder.define_singleton_method(:apply_service_extension_config) { |_client_dir, _config| nil } + builder.define_singleton_method(:configure_client_runtime_mode) { |_client_dir, self_contained:, verbose: false| nil } + builder.define_singleton_method(:sync_client_metadata) { |_client_dir, _config, verbose: false| nil } + builder.define_singleton_method(:apply_build_config) { |_client_dir, _config| { has_icon: false, has_splash: false, error: nil } } + + calls = [] + builder.define_singleton_method(:system) do |_env, *args, chdir: nil| + calls << { args: args, chdir: chdir, env: _env } + true + end + + original_bundle_gemfile = ENV["BUNDLE_GEMFILE"] + ENV["BUNDLE_GEMFILE"] = "/tmp/example/Gemfile" + + result = builder.send( + :prepare_flutter_client, + client_dir, + platform: "ios", + tools: { env: { "BUNDLE_GEMFILE" => "/tmp/example/Gemfile", "PATH" => "/tmp/bin" }, flutter: "flutter", dart: "dart" }, + config: {}, + self_contained: false, + verbose: false + ) + + assert_equal true, result + assert_equal ["flutter", "pub", "get"], calls[0][:args] + assert_equal client_dir, calls[0][:chdir] + assert_equal ["pod", "install"], calls[1][:args] + assert_equal ios_dir, calls[1][:chdir] + refute calls[1][:env].key?("BUNDLE_GEMFILE") + assert_equal "/tmp/bin", calls[1][:env]["PATH"] + ensure + ENV["BUNDLE_GEMFILE"] = original_bundle_gemfile + end + end + + def test_prepare_flutter_client_stops_when_pod_install_fails + builder = DummyBuilder.new + + Dir.mktmpdir do |dir| + client_dir = File.join(dir, "ruflet_client") + ios_dir = File.join(client_dir, "ios") + FileUtils.mkdir_p(ios_dir) + File.write(File.join(ios_dir, "Podfile"), "platform :ios, '13.0'\n") + + builder.define_singleton_method(:apply_service_extension_config) { |_client_dir, _config| nil } + builder.define_singleton_method(:configure_client_runtime_mode) { |_client_dir, self_contained:, verbose: false| nil } + builder.define_singleton_method(:sync_client_metadata) { |_client_dir, _config, verbose: false| nil } + builder.define_singleton_method(:apply_build_config) { |_client_dir, _config| { has_icon: false, has_splash: false, error: nil } } + + calls = [] + builder.define_singleton_method(:system) do |_env, *args, chdir: nil| + calls << { args: args, chdir: chdir } + args != ["pod", "install"] + end + + err = StringIO.new + original_stderr = $stderr + $stderr = err + + result = builder.send( + :prepare_flutter_client, + client_dir, + platform: "ios", + tools: { env: {}, flutter: "flutter", dart: "dart" }, + config: {}, + self_contained: false, + verbose: false + ) + + assert_equal false, result + assert_equal ["pod", "install"], calls[1][:args] + assert_includes err.string, "CocoaPods install failed for ios" + ensure + $stderr = original_stderr + end + end + + def test_prune_client_main_removes_multiline_optional_service_imports_and_extensions + builder = DummyBuilder.new + + Dir.mktmpdir do |dir| + main_path = File.join(dir, "main.self.dart") + File.write( + main_path, + <<~DART + import 'package:flet/flet.dart'; + import 'package:flet_audio_recorder/flet_audio_recorder.dart' + as ruflet_audio_recorder; + import 'package:flet_color_pickers/flet_color_pickers.dart' + as ruflet_color_picker; + import 'package:flet_secure_storage/flet_secure_storage.dart' + as ruflet_secure_storage; + + final extensions = [ + ruflet_audio_recorder.Extension(), + ruflet_color_picker.Extension(), + ruflet_secure_storage.Extension(), + ]; + DART + ) + + builder.send(:prune_client_main, main_path, []) + + content = File.read(main_path) + refute_includes content, "flet_audio_recorder" + refute_includes content, "flet_color_pickers" + refute_includes content, "flet_secure_storage" + refute_includes content, "ruflet_audio_recorder.Extension()" + refute_includes content, "ruflet_color_picker.Extension()" + refute_includes content, "ruflet_secure_storage.Extension()" + assert_includes content, "import 'package:flet/flet.dart';" + end + end end diff --git a/packages/ruflet_core/lib/ruflet_ui/ruflet/control.rb b/packages/ruflet_core/lib/ruflet_ui/ruflet/control.rb index 9374299..9c5552b 100644 --- a/packages/ruflet_core/lib/ruflet_ui/ruflet/control.rb +++ b/packages/ruflet_core/lib/ruflet_ui/ruflet/control.rb @@ -66,6 +66,9 @@ def to_patch props.each { |k, v| patch[k] = serialize_value(v) } patch["controls"] = children.map(&:to_patch) unless children.empty? + if ENV["RUFLET_DEBUG"] == "1" && type == "floatingactionbutton" + Kernel.warn("[to_patch] #{patch.inspect}") + end patch end @@ -145,12 +148,11 @@ def normalize_props(hash) value = if v.is_a?(Symbol) v.to_s - elsif v.is_a?(Ruflet::IconData) - v.value else v end value = normalize_icon_prop(mapped_key, value) + value = value.value if value.is_a?(Ruflet::IconData) value = normalize_color_prop(mapped_key, value) result[mapped_key] = value @@ -174,25 +176,17 @@ def color_prop_key?(key) def normalize_icon_prop(key, value) return value unless icon_prop_key?(key) - codepoint = resolve_icon_codepoint(value) - codepoint.nil? ? value : codepoint + return value if value.nil? + return value if value.is_a?(Integer) + return value if value.is_a?(Ruflet::IconData) + + raise ArgumentError, "#{type} #{key} must use Ruflet::MaterialIcons (or another Ruflet::IconData), not #{value.inspect}" end def icon_prop_key?(key) key == "icon" || key.end_with?("_icon") end - def resolve_icon_codepoint(value) - return nil unless value.is_a?(Integer) || value.is_a?(Symbol) || value.is_a?(String) - - codepoint = Ruflet::MaterialIconLookup.codepoint_for(value) - if codepoint.nil? || (value.is_a?(Integer) && codepoint == value) - cupertino = Ruflet::CupertinoIconLookup.codepoint_for(value) - codepoint = cupertino unless cupertino.nil? - end - codepoint - end - def normalized_event_name(event_name) event_name.to_s.sub(/\Aon_/, "") end diff --git a/packages/ruflet_core/lib/ruflet_ui/ruflet/page.rb b/packages/ruflet_core/lib/ruflet_ui/ruflet/page.rb index 5e229d6..923ca6a 100644 --- a/packages/ruflet_core/lib/ruflet_ui/ruflet/page.rb +++ b/packages/ruflet_core/lib/ruflet_ui/ruflet/page.rb @@ -865,9 +865,12 @@ def normalize_props(hash) end def normalize_value(key, value) - if icon_prop_key?(key) && (value.is_a?(String) || value.is_a?(Symbol) || value.is_a?(Integer)) - codepoint = resolve_icon_codepoint(value) - return codepoint unless codepoint.nil? + if icon_prop_key?(key) + return value if value.is_a?(Integer) + return value.value if value.is_a?(Ruflet::IconData) + return value if value.nil? + + raise ArgumentError, "page #{key} must use Ruflet::MaterialIcons (or another Ruflet::IconData), not #{value.inspect}" end return value.value if value.is_a?(Ruflet::IconData) @@ -1019,14 +1022,6 @@ def build_page_patch_ops end end - def resolve_icon_codepoint(value) - codepoint = Ruflet::MaterialIconLookup.codepoint_for(value) - if codepoint.nil? || codepoint == value - codepoint = Ruflet::CupertinoIconLookup.codepoint_for(value) - end - codepoint - end - def ensure_clipboard_service clipboard = services.find { |service| service.is_a?(Control) && service.type == "clipboard" } return [clipboard, false] if clipboard diff --git a/packages/ruflet_core/lib/ruflet_ui/ruflet/ui/control_factory.rb b/packages/ruflet_core/lib/ruflet_ui/ruflet/ui/control_factory.rb index 8ff43ef..74d0496 100644 --- a/packages/ruflet_core/lib/ruflet_ui/ruflet/ui/control_factory.rb +++ b/packages/ruflet_core/lib/ruflet_ui/ruflet/ui/control_factory.rb @@ -16,9 +16,15 @@ module ControlFactory def build(type, id: nil, **props) normalized_type = type.to_s.downcase + if ENV["RUFLET_DEBUG"] == "1" && normalized_type == "floatingactionbutton" + Kernel.warn("[factory] type=#{normalized_type} id=#{id.inspect} props=#{props.inspect}") + end klass = CLASS_MAP[normalized_type] if klass normalized_props = normalize_constructor_props(klass, props) + if ENV["RUFLET_DEBUG"] == "1" && normalized_type == "floatingactionbutton" + Kernel.warn("[factory] normalized_props=#{normalized_props.inspect}") + end return klass.new(id: id, **normalized_props) end diff --git a/packages/ruflet_core/lib/ruflet_ui/ruflet/ui/material_control_methods.rb b/packages/ruflet_core/lib/ruflet_ui/ruflet/ui/material_control_methods.rb index 9be08d4..656cf76 100644 --- a/packages/ruflet_core/lib/ruflet_ui/ruflet/ui/material_control_methods.rb +++ b/packages/ruflet_core/lib/ruflet_ui/ruflet/ui/material_control_methods.rb @@ -140,13 +140,39 @@ def web_view(**props) = build_widget(:webview, **props) def webview(**props) = web_view(**props) def fab(content = nil, **props) - mapped = props.dup - mapped[:content] = content unless content.nil? + mapped = normalize_fab_props(props.dup, content) build_widget(:floatingactionbutton, **mapped) end private + def normalize_fab_props(props, content) + mapped = props.dup + + explicit_icon = mapped[:icon] || mapped["icon"] + if explicit_icon.is_a?(Ruflet::Control) && content.nil? + mapped.delete(:icon) + mapped.delete("icon") + content = explicit_icon + elsif !explicit_icon.nil? && !explicit_icon.is_a?(Ruflet::IconData) + raise ArgumentError, "fab icon must use Ruflet::MaterialIcons (or another Ruflet::IconData) or an icon(...) control" + end + + unless content.nil? + mapped[:content] = + case content + when Ruflet::Control + content + when Ruflet::IconData + icon(icon: content) + else + raise ArgumentError, "fab content must be an icon(...) control or Ruflet::MaterialIcons value" + end + end + + mapped + end + def normalize_image_source(value) return value unless value.is_a?(Array) return value.pack("C*") if value.all? { |v| v.is_a?(Integer) } diff --git a/packages/ruflet_core/test/floating_action_button_test.rb b/packages/ruflet_core/test/floating_action_button_test.rb new file mode 100644 index 0000000..7993706 --- /dev/null +++ b/packages/ruflet_core/test/floating_action_button_test.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require_relative "test_helper" + +class RufletFloatingActionButtonTest < Minitest::Test + def test_fab_with_icon_control_moves_icon_into_content + app = Ruflet::DSL.app + + fab = app.fab(icon: app.icon(icon: Ruflet::MaterialIcons::ADD)) + + assert_nil fab.props["icon"] + assert_instance_of Ruflet::Control, fab.props["content"] + assert_equal "icon", fab.props["content"].type + end + + def test_fab_with_material_icon_content_wraps_icon_control + app = Ruflet::DSL.app + + fab = app.fab(Ruflet::MaterialIcons::ADD, on_click: ->(_event) {}) + + assert_instance_of Ruflet::Control, fab.props["content"] + assert_equal "icon", fab.props["content"].type + assert_equal Ruflet::MaterialIcons::ADD, fab.props["content"].props["icon"] + end + + def test_fab_with_string_content_raises_clear_error + app = Ruflet::DSL.app + + error = assert_raises(ArgumentError) { app.fab("+", on_click: ->(_event) {}) } + assert_includes error.message, "Ruflet::MaterialIcons" + end + + def test_fab_with_string_icon_prop_raises_clear_error + app = Ruflet::DSL.app + + error = assert_raises(ArgumentError) { app.fab(icon: "+") } + assert_includes error.message, "Ruflet::MaterialIcons" + end + + def test_fab_with_icon_codepoint_keeps_icon_prop + app = Ruflet::DSL.app + + fab = app.fab(icon: Ruflet::MaterialIcons::ADD) + + refute_nil fab.props["icon"] + assert_nil fab.props["content"] + end +end diff --git a/packages/ruflet_core/test/icon_prop_validation_test.rb b/packages/ruflet_core/test/icon_prop_validation_test.rb new file mode 100644 index 0000000..ac63ed9 --- /dev/null +++ b/packages/ruflet_core/test/icon_prop_validation_test.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require_relative "test_helper" + +class RufletIconPropValidationTest < Minitest::Test + def test_icon_control_requires_ruflet_icon_data + app = Ruflet::DSL.app + + error = assert_raises(ArgumentError) { app.icon(icon: "add") } + + assert_includes error.message, "Ruflet::MaterialIcons" + end + + def test_icon_button_requires_ruflet_icon_data + app = Ruflet::DSL.app + + error = assert_raises(ArgumentError) { app.icon_button(icon: :add) } + + assert_includes error.message, "Ruflet::MaterialIcons" + end + + def test_selected_icon_requires_ruflet_icon_data + app = Ruflet::DSL.app + + error = assert_raises(ArgumentError) { app.navigation_bar_destination(icon: Ruflet::MaterialIcons::HOME, selected_icon: "settings") } + + assert_includes error.message, "Ruflet::MaterialIcons" + end + + def test_material_icon_data_is_accepted + app = Ruflet::DSL.app + + control = app.icon(icon: Ruflet::MaterialIcons::ADD) + + assert_equal Ruflet::MaterialIcons::ADD.value, control.props["icon"] + end + + def test_page_updates_require_ruflet_icon_data + page = Ruflet::Page.new + + error = assert_raises(ArgumentError) { page.update(nil, icon: "add") } + + assert_includes error.message, "Ruflet::MaterialIcons" + end +end diff --git a/packages/ruflet_core/test/page_update_serialization_test.rb b/packages/ruflet_core/test/page_update_serialization_test.rb index a951430..fea0e8b 100644 --- a/packages/ruflet_core/test/page_update_serialization_test.rb +++ b/packages/ruflet_core/test/page_update_serialization_test.rb @@ -131,4 +131,51 @@ def test_update_controls_survive_index_refresh_after_service_add assert_equal [:ok], clicked end + def test_page_add_serializes_floating_action_button_icon_for_dev_mode + sent = [] + page = Ruflet::Page.new( + session_id: "s1", + client_details: { "route" => "/" }, + sender: ->(action, payload) { sent << [action, payload] } + ) + + count = 0 + count_text = Ruflet.text(count.to_s, style: { size: 40 }) + + page.add( + Ruflet.container( + expand: true, + alignment: Ruflet::MainAxisAlignment::CENTER, + content: Ruflet.column( + alignment: Ruflet::MainAxisAlignment::CENTER, + horizontal_alignment: Ruflet::CrossAxisAlignment::CENTER, + children: [ + Ruflet.text("A self-contained ruflet app up and running!"), + count_text + ] + ) + ), + appbar: Ruflet.app_bar(title: Ruflet.text("Ruflet demo", style: { size: 18 })), + floating_action_button: Ruflet.fab( + icon: Ruflet::MaterialIcons::ADD, + on_click: ->(_e) do + count += 1 + page.update(count_text, value: count.to_s) + end + ) + ) + + payload = sent.last[1] + views_patch = payload["patch"].find { |op| op[2] == "views" } + refute_nil views_patch + + view = views_patch[3].first + fab = view["floating_action_button"] + + refute_nil fab + assert_equal "FloatingActionButton", fab["_c"] + assert_equal Ruflet::MaterialIcons::ADD.value, fab["icon"] + assert_equal true, fab["on_click"] + end + end diff --git a/packages/ruflet_prod/README.md b/packages/ruflet_prod/README.md deleted file mode 100644 index 3b3de7e..0000000 --- a/packages/ruflet_prod/README.md +++ /dev/null @@ -1,25 +0,0 @@ -# RufletProd - -RufletProd is a lightweight runtime that serves prebuilt Ruflet manifest JSON to the client. - -## Includes - -- RufletProd protocol (`RufletProd::Protocol`) -- RufletProd server (`RufletProd::Server`) -- Manifest runtime entry (`RufletProd.run`) - -## Usage - -```ruby -require "ruflet_prod" - -manifest_path = File.expand_path("manifest.json", __dir__) -RufletProd.run(manifest_file: manifest_path) -``` - -`manifest.json` should be generated ahead of time by `ruflet build manifest`. - -## Notes - -- No `ruflet_ui` dependency in runtime. -- No dynamic Ruby UI DSL execution in `ruflet_prod`. diff --git a/packages/ruflet_prod/lib/ruflet_prod.rb b/packages/ruflet_prod/lib/ruflet_prod.rb deleted file mode 100644 index 5a84b8e..0000000 --- a/packages/ruflet_prod/lib/ruflet_prod.rb +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true - -require_relative "ruflet_prod/version" -require_relative "ruflet_prod/json_parser" -require_relative "ruflet_prod/protocol" -require_relative "ruflet_prod/wire_codec" -require_relative "ruflet_prod/web_socket_connection" -require_relative "ruflet_prod/server" - -module RufletProd - module_function - - def run(host: "0.0.0.0", port: 8550, manifest: nil, manifest_file: nil) - loaded_manifest = manifest || load_manifest_from_file(manifest_file || ENV["RUFLET_MANIFEST_FILE"]) - raise ArgumentError, "RufletProd.run requires :manifest or :manifest_file" unless loaded_manifest.is_a?(Hash) - - Server.new(host: host, port: port, manifest: loaded_manifest).start - end - - def load_manifest_from_file(path) - manifest_path = path.to_s - return nil if manifest_path.empty? - raise ArgumentError, "Manifest file not found: #{manifest_path}" unless File.file?(manifest_path) - - RufletProd::JsonParser.parse(File.read(manifest_path)) - end -end diff --git a/packages/ruflet_prod/lib/ruflet_prod/json_parser.rb b/packages/ruflet_prod/lib/ruflet_prod/json_parser.rb deleted file mode 100644 index a76af2c..0000000 --- a/packages/ruflet_prod/lib/ruflet_prod/json_parser.rb +++ /dev/null @@ -1,221 +0,0 @@ -# frozen_string_literal: true - -module RufletProd - module JsonParser - def self.parse(source) - parser = Parser.new(source) - value = parser.parse_value - parser.skip_ws - raise "Trailing JSON content" unless parser.eof? - - value - end - - class Parser - def initialize(source) - @s = source.to_s - @i = 0 - end - - def eof? - @i >= @s.length - end - - def skip_ws - @i += 1 while !eof? && @s.getbyte(@i) <= 0x20 - end - - def parse_value - skip_ws - raise "Unexpected EOF" if eof? - - ch = @s.getbyte(@i) - case ch - when 0x7B then parse_object - when 0x5B then parse_array - when 0x22 then parse_string - when 0x74 then parse_true - when 0x66 then parse_false - when 0x6E then parse_null - else - parse_number - end - end - - private - - def parse_object - expect_byte(0x7B) - skip_ws - out = {} - if peek_byte == 0x7D - @i += 1 - return out - end - - loop do - key = parse_string - skip_ws - expect_byte(0x3A) - out[key] = parse_value - skip_ws - break if consume_if(0x7D) - - expect_byte(0x2C) - end - out - end - - def parse_array - expect_byte(0x5B) - skip_ws - out = [] - if peek_byte == 0x5D - @i += 1 - return out - end - - loop do - out << parse_value - skip_ws - break if consume_if(0x5D) - - expect_byte(0x2C) - end - out - end - - def parse_string - expect_byte(0x22) - out = "".dup - - until eof? - ch = @s.getbyte(@i) - @i += 1 - case ch - when 0x22 - return out - when 0x5C - esc = @s.getbyte(@i) - @i += 1 - case esc - when 0x22 then out += "\"" - when 0x5C then out += "\\" - when 0x2F then out += "/" - when 0x62 then out += "\b" - when 0x66 then out += "\f" - when 0x6E then out += "\n" - when 0x72 then out += "\r" - when 0x74 then out += "\t" - when 0x75 - hex = @s[@i, 4] - raise "Invalid unicode escape" unless valid_hex4?(hex) - - # Keep escaped form to avoid runtime dependency on Array#pack in mruby. - out += "\\u" + hex - @i += 4 - else - raise "Invalid escape sequence" - end - else - out += @s[@i - 1, 1] - end - end - - raise "Unterminated string" - end - - def parse_true - expect_literal("true") - true - end - - def parse_false - expect_literal("false") - false - end - - def parse_null - expect_literal("null") - nil - end - - def parse_number - start = @i - @i += 1 if peek_byte == 0x2D - - if peek_byte == 0x30 - @i += 1 - else - raise "Invalid number" unless digit?(peek_byte) - - @i += 1 while digit?(peek_byte) - end - - if peek_byte == 0x2E - @i += 1 - raise "Invalid number" unless digit?(peek_byte) - @i += 1 while digit?(peek_byte) - end - - if peek_byte == 0x65 || peek_byte == 0x45 - @i += 1 - @i += 1 if peek_byte == 0x2B || peek_byte == 0x2D - raise "Invalid number" unless digit?(peek_byte) - @i += 1 while digit?(peek_byte) - end - - token = @s[start...@i] - token.include?(".") || token.include?("e") || token.include?("E") ? token.to_f : token.to_i - end - - def expect_literal(str) - raise "Invalid token" unless @s[@i, str.length] == str - - @i += str.length - end - - def expect_byte(byte) - skip_ws - raise "Unexpected EOF" if eof? - raise "Unexpected token" unless @s.getbyte(@i) == byte - - @i += 1 - end - - def consume_if(byte) - skip_ws - return false if eof? || @s.getbyte(@i) != byte - - @i += 1 - true - end - - def peek_byte - eof? ? nil : @s.getbyte(@i) - end - - def digit?(byte) - !byte.nil? && byte >= 0x30 && byte <= 0x39 - end - - def hex_digit?(byte) - return false if byte.nil? - - (byte >= 0x30 && byte <= 0x39) || (byte >= 0x41 && byte <= 0x46) || (byte >= 0x61 && byte <= 0x66) - end - - def valid_hex4?(str) - return false unless str && str.length == 4 - - i = 0 - while i < 4 - return false unless hex_digit?(str.getbyte(i)) - - i += 1 - end - true - end - end - end -end diff --git a/packages/ruflet_prod/lib/ruflet_prod/protocol.rb b/packages/ruflet_prod/lib/ruflet_prod/protocol.rb deleted file mode 100644 index 55774df..0000000 --- a/packages/ruflet_prod/lib/ruflet_prod/protocol.rb +++ /dev/null @@ -1,46 +0,0 @@ -# frozen_string_literal: true - -module RufletProd - module Protocol - ACTIONS = { - register_client: 1, - patch_control: 2, - control_event: 3, - update_control: 4, - invoke_control_method: 5, - session_crashed: 6, - register_web_client: "registerWebClient", - page_event_from_web: "pageEventFromWeb", - update_control_props: "updateControlProps" - }.freeze - - def self.normalize_register_payload(payload) - page = payload["page"] || {} - { - "session_id" => payload["session_id"], - "page_name" => payload["page_name"] || "", - "route" => page["route"] || "/", - "width" => page["width"], - "height" => page["height"], - "platform" => page["platform"], - "platform_brightness" => page["platform_brightness"], - "media" => page["media"] || {} - } - end - - def self.normalize_control_event_payload(payload) - { - "target" => payload["target"] || payload["eventTarget"], - "name" => payload["name"] || payload["eventName"], - "data" => payload["data"] || payload["eventData"] - } - end - - def self.normalize_update_control_payload(payload) - { - "id" => payload["id"] || payload["target"] || payload["eventTarget"], - "props" => payload["props"].is_a?(Hash) ? payload["props"] : {} - } - end - end -end diff --git a/packages/ruflet_prod/lib/ruflet_prod/server.rb b/packages/ruflet_prod/lib/ruflet_prod/server.rb deleted file mode 100644 index 1f6d15b..0000000 --- a/packages/ruflet_prod/lib/ruflet_prod/server.rb +++ /dev/null @@ -1,390 +0,0 @@ -# frozen_string_literal: true - -module RufletProd - class Server - WEBSOCKET_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" - - attr_reader :port - - def initialize(host: "0.0.0.0", port: 8550, manifest: nil, &app_block) - @host = host - @port = port - @app_block = app_block - @manifest = manifest - @sessions = {} - @running = false - @server_socket = nil - end - - def start - bind_server_socket! - @running = true - print_server_banner - - while @running - check_stop_signal! - socket = accept_client_socket - break unless socket - - handle_socket(socket) - end - rescue Interrupt - nil - ensure - stop - end - - def stop - @running = false - - begin - @server_socket&.close - rescue IOError - nil - end - @server_socket = nil - end - - private - - def bind_server_socket! - requested = @port.to_i - @server_socket = TCPServer.new(@host, requested) - @port = requested - rescue Errno::EADDRINUSE - raise Errno::EADDRINUSE, "RufletProd failed to bind #{@host}:#{requested}; port is in use" - end - - def print_server_banner - return if ENV["RUFLET_SUPPRESS_SERVER_BANNER"] == "1" - - warn "RufletProd server listening on ws://#{@host}:#{@port}/ws" - end - - def accept_client_socket - accepted = @server_socket.accept - accepted.is_a?(Array) ? accepted.first : accepted - rescue IOError, Errno::EBADF - nil - end - - def check_stop_signal! - stop_file = ENV["RUFLET_PROD_STOP_FILE"].to_s - return if stop_file.empty? - - @running = false if File.exist?(stop_file) - end - - def handle_socket(socket) - ws = nil - begin - path, headers = read_http_upgrade_request(socket) - return unless websocket_upgrade_request?(path, headers) - - send_handshake_response(socket, headers["sec-websocket-key"]) - ws = RufletProd::WebSocketConnection.new(socket) - run_connection(ws) - rescue StandardError => e - send_message(ws, Protocol::ACTIONS[:session_crashed], { "message" => e.message.to_s }) if ws - ensure - close_connection(ws) - begin - socket.close unless socket.closed? - rescue StandardError - nil - end - end - end - - def run_connection(ws) - while (raw = ws.read_message) - handle_message(ws, raw) - end - rescue StandardError => e - send_message(ws, Protocol::ACTIONS[:session_crashed], { "message" => e.message.to_s }) - ensure - close_connection(ws) - end - - def close_connection(ws) - return unless ws - - @sessions.delete(ws.session_key) - ws.close - rescue StandardError - nil - end - - def read_http_upgrade_request(socket) - request_line = socket.gets("\r\n") - raise "Invalid HTTP request" if request_line.nil? - - method, path, = request_line.strip.split(" ", 3) - raise "Unsupported HTTP method: #{method}" unless method == "GET" - - headers = {} - loop do - line = socket.gets("\r\n") - break if line.nil? || line == "\r\n" - - key, value = line.split(":", 2) - next if key.nil? || value.nil? - - headers[key.strip.downcase] = value.strip - end - - [path, headers] - end - - def websocket_upgrade_request?(path, headers) - return false unless path == "/ws" - return false unless headers["upgrade"]&.downcase == "websocket" - return false unless headers["connection"]&.downcase&.include?("upgrade") - return false if headers["sec-websocket-key"].to_s.empty? - - true - end - - def send_handshake_response(socket, key) - accept = [sha1_digest("#{key}#{WEBSOCKET_GUID}")].pack("m0") - - socket.write("HTTP/1.1 101 Switching Protocols\r\n") - socket.write("Upgrade: websocket\r\n") - socket.write("Connection: Upgrade\r\n") - socket.write("Sec-WebSocket-Accept: #{accept}\r\n") - socket.write("\r\n") - end - - def handle_message(ws, raw) - action, payload = decode_incoming(raw) - payload ||= {} - - case action - when Protocol::ACTIONS[:register_client], Protocol::ACTIONS[:register_web_client] - on_register_client(ws, payload) - when Protocol::ACTIONS[:control_event], Protocol::ACTIONS[:page_event_from_web] - on_control_event(ws, payload) - when Protocol::ACTIONS[:update_control], Protocol::ACTIONS[:update_control_props] - on_update_control(ws, payload) - when Protocol::ACTIONS[:invoke_control_method] - on_invoke_control_method(ws, payload) - else - raise "Unknown action: #{action.inspect}" - end - end - - def decode_incoming(raw) - parsed = normalize_incoming(RufletProd::WireCodec.unpack(raw.to_s)) - - if parsed.is_a?(Array) && parsed.length >= 2 - return [parsed[0], parsed[1]] - end - - if parsed.is_a?(Hash) - action = parsed["action"] || parsed[:action] - payload = parsed["payload"] || parsed[:payload] - return [action, payload] unless action.nil? - - if (parsed.key?("target") || parsed.key?(:target)) && (parsed.key?("name") || parsed.key?(:name)) - return [Protocol::ACTIONS[:control_event], parsed] - end - end - - raise "Unsupported payload format" - end - - def normalize_incoming(value) - case value - when String - out = value.dup - out.force_encoding("UTF-8") if out.respond_to?(:force_encoding) - out - when Integer, Float, TrueClass, FalseClass, NilClass - value - when Symbol - value.to_s - when Array - value.map { |v| normalize_incoming(v) } - when Hash - value.each_with_object({}) do |(k, v), out| - out[k.to_s] = normalize_incoming(v) - end - else - value.to_s - end - end - - def on_register_client(ws, payload) - normalized = Protocol.normalize_register_payload(payload) - session_id = normalized["session_id"].to_s.empty? ? pseudo_uuid : normalized["session_id"] - - unless @manifest - send_message(ws, Protocol::ACTIONS[:session_crashed], { - "message" => "RufletProd runtime requires a prebuilt manifest." - }) - return - end - - initial_response = [ - Protocol::ACTIONS[:register_client], - { - "session_id" => session_id, - "page_patch" => {}, - "error" => nil - } - ] - ws.send_binary(RufletProd::WireCodec.pack(initial_response)) - replay_manifest(ws) - end - - def on_control_event(ws, payload) - return if @manifest - - event = Protocol.normalize_control_event_payload(payload) - page = fetch_page(ws) - return if event["target"].nil? || event["name"].to_s.empty? - - page.dispatch_event( - target: event["target"], - name: event["name"], - data: normalize_event_data(event["data"]) - ) - end - - def on_update_control(ws, payload) - return if @manifest - - update = Protocol.normalize_update_control_payload(payload) - page = fetch_page(ws) - return if update["id"].nil? - - page.apply_client_update(update["id"], update["props"] || {}) - end - - def on_invoke_control_method(ws, payload) - return if @manifest - - page = fetch_page(ws) - page.handle_invoke_method_result(payload) - end - - def fetch_page(ws) - page = @sessions[ws.session_key] - raise "Session not found" unless page - - page - end - - def normalize_event_data(value) - case value - when Hash - value.each_with_object({}) { |(k, v), out| out[k.to_sym] = normalize_event_data(v) } - when Array - value.map { |entry| normalize_event_data(entry) } - else - value - end - end - - def send_message(ws, action, payload) - return unless ws - - message = [action, payload] - ws.send_binary(RufletProd::WireCodec.pack(message)) - rescue StandardError - nil - end - - def pseudo_uuid - now = (Time.now.to_f * 1_000_000).to_i - rnd = rand(0..0xffff_ffff) - "%08x-%04x-%04x-%04x-%012x" % [ - rnd, - now & 0xffff, - (now >> 16) & 0xffff, - (now >> 32) & 0xffff, - (now >> 48) & 0xffff_ffff_ffff - ] - end - - # Minimal SHA-1 implementation to avoid depending on digest stdlib in mruby. - def sha1_digest(data) - bytes = data.to_s - bit_len = bytes.bytesize * 8 - bytes += "\x80" - bytes += "\x00" while (bytes.bytesize % 64) != 56 - - hi = (bit_len >> 32) & 0xffffffff - lo = bit_len & 0xffffffff - bytes += [hi, lo].pack("N2") - - h0 = 0x67452301 - h1 = 0xEFCDAB89 - h2 = 0x98BADCFE - h3 = 0x10325476 - h4 = 0xC3D2E1F0 - - bytes.bytes.each_slice(64) do |chunk| - w = Array.new(80, 0) - 16.times do |i| - j = i * 4 - w[i] = ((chunk[j] << 24) | (chunk[j + 1] << 16) | (chunk[j + 2] << 8) | chunk[j + 3]) & 0xffffffff - end - (16..79).each do |i| - v = w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16] - w[i] = ((v << 1) | (v >> 31)) & 0xffffffff - end - - a = h0 - b = h1 - c = h2 - d = h3 - e = h4 - - 80.times do |i| - if i < 20 - f = ((b & c) | ((~b) & d)) & 0xffffffff - k = 0x5A827999 - elsif i < 40 - f = (b ^ c ^ d) & 0xffffffff - k = 0x6ED9EBA1 - elsif i < 60 - f = ((b & c) | (b & d) | (c & d)) & 0xffffffff - k = 0x8F1BBCDC - else - f = (b ^ c ^ d) & 0xffffffff - k = 0xCA62C1D6 - end - - temp = ((((a << 5) | (a >> 27)) & 0xffffffff) + f + e + k + w[i]) & 0xffffffff - e = d - d = c - c = ((b << 30) | (b >> 2)) & 0xffffffff - b = a - a = temp - end - - h0 = (h0 + a) & 0xffffffff - h1 = (h1 + b) & 0xffffffff - h2 = (h2 + c) & 0xffffffff - h3 = (h3 + d) & 0xffffffff - h4 = (h4 + e) & 0xffffffff - end - - [h0, h1, h2, h3, h4].pack("N5") - end - - def replay_manifest(ws) - return unless @manifest.is_a?(Hash) - - manifest_messages = @manifest["messages"] - return unless manifest_messages.is_a?(Array) - - manifest_messages.each do |message| - action = message["action"] || message[:action] - payload = message["payload"] || message[:payload] - send_message(ws, action, payload || {}) - end - end - end -end diff --git a/packages/ruflet_prod/lib/ruflet_prod/version.rb b/packages/ruflet_prod/lib/ruflet_prod/version.rb deleted file mode 100644 index 0666f46..0000000 --- a/packages/ruflet_prod/lib/ruflet_prod/version.rb +++ /dev/null @@ -1,5 +0,0 @@ -# frozen_string_literal: true - -module RufletProd - VERSION = "0.0.4" -end diff --git a/packages/ruflet_prod/lib/ruflet_prod/web_socket_connection.rb b/packages/ruflet_prod/lib/ruflet_prod/web_socket_connection.rb deleted file mode 100644 index 482afd8..0000000 --- a/packages/ruflet_prod/lib/ruflet_prod/web_socket_connection.rb +++ /dev/null @@ -1,111 +0,0 @@ -# frozen_string_literal: true - -module RufletProd - class WebSocketConnection - def initialize(socket) - @socket = socket - end - - def session_key - @socket.object_id - end - - def closed? - @socket.closed? - rescue IOError - true - end - - def send_binary(payload) - send_frame(0x2, payload.to_s) - end - - def read_message - frame = read_frame - return nil if frame.nil? - - case frame[:opcode] - when 0x8 - close - nil - when 0x9 - send_frame(0xA, frame[:payload]) - read_message - when 0xA - read_message - when 0x1, 0x2 - frame[:payload] - else - read_message - end - end - - def close - return if closed? - - @socket.close - rescue IOError - nil - end - - private - - def read_frame - header = read_exact(2) - return nil if header.nil? - - b1 = header.getbyte(0) - b2 = header.getbyte(1) - masked = (b2 & 0x80) != 0 - payload_len = b2 & 0x7f - - payload_len = read_exact(2).unpack("n").first if payload_len == 126 - payload_len = read_exact(8).unpack("Q>").first if payload_len == 127 - - masking_key = masked ? read_exact(4) : nil - payload = payload_len.zero? ? "" : read_exact(payload_len) - return nil if payload.nil? - - payload = unmask(payload, masking_key) if masked - { opcode: b1 & 0x0f, payload: payload } - end - - def send_frame(opcode, payload) - bytes = payload.to_s - len = bytes.bytesize - header = [0x80 | (opcode & 0x0f)].pack("C") - - header += if len <= 125 - [len].pack("C") - elsif len <= 0xffff - [126].pack("C") + [len].pack("n") - else - [127].pack("C") + [len].pack("Q>") - end - - @socket.write(header) - @socket.write(bytes) unless bytes.empty? - end - - def unmask(payload, mask) - out = "".dup - payload.bytes.each_with_index do |byte, idx| - out += [(byte ^ mask.getbyte(idx % 4))].pack("C") - end - out - end - - def read_exact(length) - chunk = "".dup - - while chunk.bytesize < length - part = @socket.read(length - chunk.bytesize) - return nil if part.nil? || part.empty? - - chunk += part - end - - chunk - end - end -end diff --git a/packages/ruflet_prod/lib/ruflet_prod/wire_codec.rb b/packages/ruflet_prod/lib/ruflet_prod/wire_codec.rb deleted file mode 100644 index 4adedfd..0000000 --- a/packages/ruflet_prod/lib/ruflet_prod/wire_codec.rb +++ /dev/null @@ -1,244 +0,0 @@ -# frozen_string_literal: true - -module RufletProd - class WireCodec - class << self - def pack(value) - case value - when NilClass - "\xc0" - when TrueClass - "\xc3" - when FalseClass - "\xc2" - when Integer - pack_integer(value) - when Float - "\xcb" + [value].pack("G") - when String - pack_string(value) - when Symbol - pack_string(value.to_s) - when Array - pack_array(value) - when Hash - pack_map(value) - else - pack_string(value.to_s) - end - end - - def unpack(bytes) - reader = ByteReader.new(bytes) - read_value(reader) - end - - private - - def pack_integer(value) - if value >= 0 - return [value].pack("C") if value <= 0x7f - return "\xcc" + [value].pack("C") if value <= 0xff - return "\xcd" + [value].pack("n") if value <= 0xffff - return "\xce" + [value].pack("N") if value <= 0xffff_ffff - - "\xcf" + [value].pack("Q>") - else - return [value & 0xff].pack("C") if value >= -32 - return "\xd0" + [value].pack("c") if value >= -128 - return "\xd1" + [value].pack("s>") if value >= -32_768 - return "\xd2" + [value].pack("l>") if value >= -2_147_483_648 - - "\xd3" + [value].pack("q>") - end - end - - def pack_string(value) - str = value.to_s.dup - bytes = str - len = bytes.bytesize - - if len <= 31 - [0xA0 | len].pack("C") + bytes - elsif len <= 0xff - "\xd9" + [len].pack("C") + bytes - elsif len <= 0xffff - "\xda" + [len].pack("n") + bytes - else - "\xdb" + [len].pack("N") + bytes - end - end - - def pack_array(value) - len = value.length - head = - if len <= 15 - [0x90 | len].pack("C") - elsif len <= 0xffff - "\xdc" + [len].pack("n") - else - "\xdd" + [len].pack("N") - end - - body = "".dup - value.each { |item| body += pack(item) } - head + body - end - - def pack_map(value) - pairs = value.each_with_object({}) { |(k, v), out| out[k.to_s] = v } - len = pairs.length - head = - if len <= 15 - [0x80 | len].pack("C") - elsif len <= 0xffff - "\xde" + [len].pack("n") - else - "\xdf" + [len].pack("N") - end - - body = "".dup - pairs.each do |k, v| - body += pack(k) - body += pack(v) - end - head + body - end - - def read_value(reader) - marker = reader.read_u8 - - return marker if marker <= 0x7f - return marker - 256 if marker >= 0xe0 - - case marker - when 0xc0 then nil - when 0xc2 then false - when 0xc3 then true - when 0xcc then reader.read_u8 - when 0xcd then reader.read_u16 - when 0xce then reader.read_u32 - when 0xcf then reader.read_u64 - when 0xd0 then reader.read_i8 - when 0xd1 then reader.read_i16 - when 0xd2 then reader.read_i32 - when 0xd3 then reader.read_i64 - when 0xca then reader.read_f32 - when 0xcb then reader.read_f64 - when 0xd9 then reader.read_string(reader.read_u8) - when 0xda then reader.read_string(reader.read_u16) - when 0xdb then reader.read_string(reader.read_u32) - when 0xc4 then reader.read_binary(reader.read_u8) - when 0xc5 then reader.read_binary(reader.read_u16) - when 0xc6 then reader.read_binary(reader.read_u32) - when 0xdc then read_array(reader, reader.read_u16) - when 0xdd then read_array(reader, reader.read_u32) - when 0xde then read_map(reader, reader.read_u16) - when 0xdf then read_map(reader, reader.read_u32) - when 0xd4 - read_ext(reader, 1) - when 0xd5 - read_ext(reader, 2) - when 0xd6 - read_ext(reader, 4) - when 0xd7 - read_ext(reader, 8) - when 0xd8 - read_ext(reader, 16) - else - if (marker & 0xf0) == 0x90 - read_array(reader, marker & 0x0f) - elsif (marker & 0xf0) == 0x80 - read_map(reader, marker & 0x0f) - elsif (marker & 0xe0) == 0xa0 - reader.read_string(marker & 0x1f) - else - raise "Unsupported MessagePack marker: 0x#{marker.to_s(16)}" - end - end - end - - def read_array(reader, size) - Array.new(size) { read_value(reader) } - end - - def read_map(reader, size) - out = {} - size.times do - key = read_value(reader) - out[key.to_s] = read_value(reader) - end - out - end - end - - class ByteReader - def initialize(bytes) - @data = bytes.to_s - @offset = 0 - end - - def read_u8 - value = @data.getbyte(@offset) - raise "Unexpected EOF" if value.nil? - - @offset += 1 - value - end - - def read_exact(size) - chunk = @data.byteslice(@offset, size) - raise "Unexpected EOF" if chunk.nil? || chunk.bytesize != size - - @offset += size - chunk - end - - def read_u16 - read_exact(2).unpack("n").first - end - - def read_u32 - read_exact(4).unpack("N").first - end - - def read_u64 - read_exact(8).unpack("Q>").first - end - - def read_i8 - read_exact(1).unpack("c").first - end - - def read_i16 - read_exact(2).unpack("s>").first - end - - def read_i32 - read_exact(4).unpack("l>").first - end - - def read_i64 - read_exact(8).unpack("q>").first - end - - def read_f32 - read_exact(4).unpack("g").first - end - - def read_f64 - read_exact(8).unpack("G").first - end - - def read_string(size) - out = read_exact(size) - out.force_encoding("UTF-8") if out.respond_to?(:force_encoding) - out - end - - def read_binary(size) - read_exact(size) - end - end - end -end diff --git a/packages/ruflet_prod/lib/ruflet_prod_protocol.rb b/packages/ruflet_prod/lib/ruflet_prod_protocol.rb deleted file mode 100644 index 534603b..0000000 --- a/packages/ruflet_prod/lib/ruflet_prod_protocol.rb +++ /dev/null @@ -1,3 +0,0 @@ -# frozen_string_literal: true - -require "ruflet_prod" diff --git a/packages/ruflet_prod/lib/ruflet_prod_server.rb b/packages/ruflet_prod/lib/ruflet_prod_server.rb deleted file mode 100644 index 534603b..0000000 --- a/packages/ruflet_prod/lib/ruflet_prod_server.rb +++ /dev/null @@ -1,3 +0,0 @@ -# frozen_string_literal: true - -require "ruflet_prod" diff --git a/packages/ruflet_prod/ruflet_prod-0.0.3.gem b/packages/ruflet_prod/ruflet_prod-0.0.3.gem deleted file mode 100644 index 48f40e5..0000000 Binary files a/packages/ruflet_prod/ruflet_prod-0.0.3.gem and /dev/null differ diff --git a/packages/ruflet_prod/ruflet_prod-0.0.4.gem b/packages/ruflet_prod/ruflet_prod-0.0.4.gem deleted file mode 100644 index f746ea3..0000000 Binary files a/packages/ruflet_prod/ruflet_prod-0.0.4.gem and /dev/null differ diff --git a/packages/ruflet_prod/ruflet_prod.gemspec b/packages/ruflet_prod/ruflet_prod.gemspec deleted file mode 100644 index 2dcd637..0000000 --- a/packages/ruflet_prod/ruflet_prod.gemspec +++ /dev/null @@ -1,20 +0,0 @@ -# frozen_string_literal: true - -require_relative "lib/ruflet_prod/version" - -Gem::Specification.new do |spec| - spec.name = "ruflet_prod" - spec.version = RufletProd::VERSION - spec.authors = ["Izeesoft"] - spec.email = ["dev@izeesoft.com"] - - spec.summary = "RufletProd lightweight manifest runtime." - spec.description = "RufletProd serves prebuilt Ruflet manifest JSON over a minimal protocol/server." - spec.homepage = "https://github.com/AdamMusa/Ruflet" - spec.license = "MIT" - spec.required_ruby_version = ">= 3.1" - - spec.files = Dir.glob("lib/**/*.rb") + ["README.md"] - spec.require_paths = ["lib"] - -end diff --git a/ruby_runtime/CHANGELOG.md b/ruby_runtime/CHANGELOG.md index 88a977e..c8b61f7 100644 --- a/ruby_runtime/CHANGELOG.md +++ b/ruby_runtime/CHANGELOG.md @@ -1 +1,12 @@ +## 0.0.2 + +- Align Android plugin packaging and publish metadata for Ruflet self-contained builds. +- Document supported runtime behavior and developer usage more clearly. +- Prepare the package for pub.dev publication from the standalone `ruby_runtime` package. + ## 0.0.1 + +- Initial `ruby_runtime` Flutter plugin release. +- Added embedded mruby execution APIs for Ruflet. +- Added embedded file server support used by self-contained Ruflet apps. +- Added Android, iOS, and macOS platform implementations. diff --git a/ruby_runtime/README.md b/ruby_runtime/README.md index c038fee..d8b54df 100644 --- a/ruby_runtime/README.md +++ b/ruby_runtime/README.md @@ -1,35 +1,103 @@ # ruby_runtime -Embedded mruby runtime plugin for Flutter. +`ruby_runtime` is the Flutter plugin that embeds Ruflet's mruby runtime inside your app. + +It is designed for self-contained Ruflet apps that ship a Ruby entry file with the client and start the backend locally on the device, instead of requiring an external Ruby server. + +## Platforms + +- Android +- iOS +- macOS + +## What It Supports + +The embedded runtime includes the pieces Ruflet needs for local execution: + +- Ruby script evaluation with mruby +- Running Ruby files from local storage +- Basic file and IO support +- Socket support +- Ruflet's embedded HTTP and WebSocket server flow +- JSON support + +In practice, this means Ruflet apps can: + +- boot a local Ruby entry file such as `main.rb` +- open a local TCP server +- serve the Ruflet page endpoint over HTTP/WebSocket +- communicate with the Flutter client without an external backend + +## What It Does Not Guarantee + +This package uses `mruby`, not full CRuby. + +Do not assume the full Ruby standard library or arbitrary Ruby gems are available. In particular, you should not rely on things like: + +- full `net/http` +- `webrick` +- `openssl` +- arbitrary native Ruby gems +- Bundler-based gem loading inside the embedded runtime + +If your app needs the broader CRuby ecosystem, use a separate backend instead of the embedded runtime. + +## Dart API + +`ruby_runtime` exposes these methods: -`ruby_runtime` exposes a simple Dart API: - `RubyRuntime.initialize()` - `RubyRuntime.eval(String code)` - `RubyRuntime.runFile(String path)` - `RubyRuntime.reset()` +- `RubyRuntime.startFileServer(String path, {String? stopSignalPath})` +- `RubyRuntime.stopFileServer()` +- `RubyRuntime.isFileServerRunning()` +- `RubyRuntime.lastFileServerError()` + +## Recommended Ruflet Flow + +For Ruflet apps, the normal embedded flow is: + +1. Bundle a Ruby file in Flutter assets, usually `assets/main.rb`. +2. Copy that asset to a writable app directory at runtime. +3. Start the embedded file server with `RubyRuntime.startFileServer(...)`. +4. Point the Flutter Ruflet/Flet client to the local server URL. + +This is the model used by the Ruflet Flutter template. -## Usage +## Quick Start -### 1. Initialize runtime +### 1. Add the dependency + +```yaml +dependencies: + ruby_runtime: ^0.0.2 +``` + +### 2. Import the package ```dart import 'package:ruby_runtime/ruby_runtime.dart'; +``` + +### 3. Initialize the runtime +```dart Future setupRuby() async { await RubyRuntime.initialize(); } ``` -### 2. Execute Ruby code +### 4. Evaluate a small Ruby expression ```dart Future runCode() async { - final result = await RubyRuntime.eval('"Hello, " + "mruby"'); - return result; // => "Hello, mruby" + return RubyRuntime.eval('"Hello from mruby"'); } ``` -### 3. Run a Ruby file +### 5. Run a Ruby file ```dart import 'dart:io'; @@ -39,22 +107,52 @@ import 'package:ruby_runtime/ruby_runtime.dart'; Future runRubyFile() async { final dir = await getTemporaryDirectory(); final file = File('${dir.path}/sample.rb'); - await file.writeAsString('class Calc\n def add(a, b)\n a + b\n end\nend\nCalc.new.add(10, 20)'); - - return RubyRuntime.runFile(file.path); // => "30" + await file.writeAsString('puts "hello"'); + return RubyRuntime.runFile(file.path); } ``` -### 4. Reset runtime (optional) +## Running Ruflet Embedded + +This is the simplest pattern for Ruflet developers: ```dart -Future resetRuby() async { - await RubyRuntime.reset(); -} +final appDir = await getApplicationSupportDirectory(); +final rubyFile = File('${appDir.path}/main.rb'); + +await rubyFile.writeAsString(rubySource); +await RubyRuntime.initialize(); +await RubyRuntime.startFileServer(rubyFile.path); + +final running = await RubyRuntime.isFileServerRunning(); +final lastError = await RubyRuntime.lastFileServerError(); ``` -## Notes +Notes: + +- `startFileServer()` starts the embedded Ruflet runtime server in native code. +- `lastFileServerError()` is the first place to check when local startup fails. +- `reset()` is useful during development when you want a clean embedded runtime state. + +## Developer Notes + +If you are building on top of `ruby_runtime`, keep these constraints in mind: + +- Prefer plain Ruby files over complex gem-based boot flows. +- Keep runtime code focused on Ruflet app logic and lightweight server behavior. +- Treat the embedded runtime as a targeted app runtime, not a full replacement for desktop/server Ruby. +- Test any socket or file behavior on each supported platform you care about. + +## When To Use It + +Use `ruby_runtime` when: + +- you want a self-contained Ruflet mobile or desktop app +- your embedded Ruby code is controlled by your app +- you want the Flutter client and Ruby backend to ship together + +Do not use it when: -- This runtime is mruby, not CRuby. -- Not all Ruby stdlib APIs are available by default. -- Socket/IO support is provided via mruby gems included in the plugin. +- you need the full CRuby ecosystem +- you depend on gems that require MRI features or native extensions +- you need a general-purpose Ruby application server environment diff --git a/ruby_runtime/android/build.gradle.kts b/ruby_runtime/android/build.gradle.kts index 33fbbd7..d8f2c9e 100644 --- a/ruby_runtime/android/build.gradle.kts +++ b/ruby_runtime/android/build.gradle.kts @@ -1,4 +1,4 @@ -group = "com.example.ruby_runtime" +group = "com.izeesoft.ruby_runtime" version = "1.0-SNAPSHOT" buildscript { @@ -27,7 +27,7 @@ plugins { } android { - namespace = "com.example.ruby_runtime" + namespace = "com.izeesoft.ruby_runtime" compileSdk = 36 diff --git a/ruby_runtime/android/src/main/AndroidManifest.xml b/ruby_runtime/android/src/main/AndroidManifest.xml index 5fe66e1..33e425f 100644 --- a/ruby_runtime/android/src/main/AndroidManifest.xml +++ b/ruby_runtime/android/src/main/AndroidManifest.xml @@ -1,3 +1,3 @@ + package="com.izeesoft.ruby_runtime"> diff --git a/ruby_runtime/android/src/main/cpp/ruby_runtime_jni.cpp b/ruby_runtime/android/src/main/cpp/ruby_runtime_jni.cpp index 57e1926..745f771 100644 --- a/ruby_runtime/android/src/main/cpp/ruby_runtime_jni.cpp +++ b/ruby_runtime/android/src/main/cpp/ruby_runtime_jni.cpp @@ -183,7 +183,7 @@ void request_stop_server_locked() { } // namespace extern "C" JNIEXPORT jstring JNICALL -Java_com_example_ruby_1runtime_MrubyRuntimePlugin_nativeEval( +Java_com_izeesoft_ruby_1runtime_MrubyRuntimePlugin_nativeEval( JNIEnv* env, jobject /* this */, jstring code) { @@ -212,7 +212,7 @@ Java_com_example_ruby_1runtime_MrubyRuntimePlugin_nativeEval( } extern "C" JNIEXPORT jstring JNICALL -Java_com_example_ruby_1runtime_MrubyRuntimePlugin_nativeRunFile( +Java_com_izeesoft_ruby_1runtime_MrubyRuntimePlugin_nativeRunFile( JNIEnv* env, jobject /* this */, jstring path) { @@ -241,7 +241,7 @@ Java_com_example_ruby_1runtime_MrubyRuntimePlugin_nativeRunFile( } extern "C" JNIEXPORT void JNICALL -Java_com_example_ruby_1runtime_MrubyRuntimePlugin_nativeReset( +Java_com_izeesoft_ruby_1runtime_MrubyRuntimePlugin_nativeReset( JNIEnv* env, jobject /* this */) { std::lock_guard lock(g_mutex); @@ -260,7 +260,7 @@ Java_com_example_ruby_1runtime_MrubyRuntimePlugin_nativeReset( } extern "C" JNIEXPORT jstring JNICALL -Java_com_example_ruby_1runtime_MrubyRuntimePlugin_nativeStartFileServer( +Java_com_izeesoft_ruby_1runtime_MrubyRuntimePlugin_nativeStartFileServer( JNIEnv* env, jobject /* this */, jstring path, @@ -338,7 +338,7 @@ Java_com_example_ruby_1runtime_MrubyRuntimePlugin_nativeStartFileServer( } extern "C" JNIEXPORT void JNICALL -Java_com_example_ruby_1runtime_MrubyRuntimePlugin_nativeStopFileServer( +Java_com_izeesoft_ruby_1runtime_MrubyRuntimePlugin_nativeStopFileServer( JNIEnv* env, jobject /* this */) { std::lock_guard lock(g_mutex); @@ -347,7 +347,7 @@ Java_com_example_ruby_1runtime_MrubyRuntimePlugin_nativeStopFileServer( } extern "C" JNIEXPORT jboolean JNICALL -Java_com_example_ruby_1runtime_MrubyRuntimePlugin_nativeIsFileServerRunning( +Java_com_izeesoft_ruby_1runtime_MrubyRuntimePlugin_nativeIsFileServerRunning( JNIEnv* env, jobject /* this */) { std::lock_guard lock(g_mutex); @@ -356,7 +356,7 @@ Java_com_example_ruby_1runtime_MrubyRuntimePlugin_nativeIsFileServerRunning( } extern "C" JNIEXPORT jstring JNICALL -Java_com_example_ruby_1runtime_MrubyRuntimePlugin_nativeLastFileServerError( +Java_com_izeesoft_ruby_1runtime_MrubyRuntimePlugin_nativeLastFileServerError( JNIEnv* env, jobject /* this */) { std::lock_guard lock(g_mutex); diff --git a/ruby_runtime/android/src/main/kotlin/com/example/ruby_runtime/MrubyRuntimePlugin.kt b/ruby_runtime/android/src/main/kotlin/com/example/ruby_runtime/MrubyRuntimePlugin.kt deleted file mode 100644 index 94fccbc..0000000 --- a/ruby_runtime/android/src/main/kotlin/com/example/ruby_runtime/MrubyRuntimePlugin.kt +++ /dev/null @@ -1,77 +0,0 @@ -package com.example.ruby_runtime - -import io.flutter.embedding.engine.plugins.FlutterPlugin -import io.flutter.plugin.common.MethodCall -import io.flutter.plugin.common.MethodChannel -import io.flutter.plugin.common.MethodChannel.MethodCallHandler -import io.flutter.plugin.common.MethodChannel.Result - -class MrubyRuntimePlugin : FlutterPlugin, MethodCallHandler { - private lateinit var channel: MethodChannel - - external fun nativeEval(code: String): String - external fun nativeRunFile(path: String): String - external fun nativeReset() - external fun nativeStartFileServer(path: String, stopSignalPath: String): String - external fun nativeStopFileServer() - external fun nativeIsFileServerRunning(): Boolean - external fun nativeLastFileServerError(): String - - override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { - System.loadLibrary("ruby_runtime") - channel = MethodChannel(binding.binaryMessenger, "ruby_runtime") - channel.setMethodCallHandler(this) - } - - override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { - channel.setMethodCallHandler(null) - } - - override fun onMethodCall(call: MethodCall, result: Result) { - try { - when (call.method) { - "eval" -> { - val code = call.argument("code") - if (code.isNullOrEmpty()) { - result.error("invalid_args", "Missing 'code' argument.", null) - } else { - result.success(nativeEval(code)) - } - } - "runFile" -> { - val path = call.argument("path") - if (path.isNullOrEmpty()) { - result.error("invalid_args", "Missing 'path' argument.", null) - } else { - result.success(nativeRunFile(path)) - } - } - "reset" -> { - nativeReset() - result.success(null) - } - "startFileServer" -> { - val path = call.argument("path") - if (path.isNullOrEmpty()) { - result.error("invalid_args", "Missing 'path' argument.", null) - } else { - val stopSignalPath = - call.argument("stopSignalPath")?.takeIf { it.isNotBlank() } - ?: "$path.stop" - nativeStartFileServer(path, stopSignalPath) - result.success(null) - } - } - "stopFileServer" -> { - nativeStopFileServer() - result.success(null) - } - "isFileServerRunning" -> result.success(nativeIsFileServerRunning()) - "lastFileServerError" -> result.success(nativeLastFileServerError()) - else -> result.notImplemented() - } - } catch (error: RuntimeException) { - result.error("mruby_error", error.message, null) - } - } -} diff --git a/ruby_runtime/ios/ruby_runtime.podspec b/ruby_runtime/ios/ruby_runtime.podspec index 997cd95..e31d112 100644 --- a/ruby_runtime/ios/ruby_runtime.podspec +++ b/ruby_runtime/ios/ruby_runtime.podspec @@ -5,7 +5,7 @@ Pod::Spec.new do |s| s.description = <<-DESC Embeds mruby in native iOS code and exposes eval/runFile/reset over a Flutter method channel. DESC - s.homepage = 'https://example.com/ruby_runtime' + s.homepage = 'https://github.com/AdamMusa/ruflet' s.license = { :file => '../LICENSE' } s.author = { 'Izeesoft' => 'dev@izeesoft.com' } s.source = { :path => '.' } diff --git a/ruby_runtime/macos/ruby_runtime.podspec b/ruby_runtime/macos/ruby_runtime.podspec index 409ab48..7e7fd16 100644 --- a/ruby_runtime/macos/ruby_runtime.podspec +++ b/ruby_runtime/macos/ruby_runtime.podspec @@ -5,7 +5,7 @@ Pod::Spec.new do |s| s.description = <<-DESC Embeds mruby in native macOS code and exposes eval/runFile/reset over a Flutter method channel. DESC - s.homepage = 'https://example.com/ruby_runtime' + s.homepage = 'https://github.com/AdamMusa/ruflet' s.license = { :file => '../LICENSE' } s.author = { 'Izeesoft' => 'dev@izeesoft.com' } diff --git a/ruby_runtime/pubspec.yaml b/ruby_runtime/pubspec.yaml index 99756f8..c215495 100644 --- a/ruby_runtime/pubspec.yaml +++ b/ruby_runtime/pubspec.yaml @@ -1,7 +1,16 @@ name: ruby_runtime -description: Embedded mruby runtime plugin for Flutter (Android/iOS). -version: 0.0.1 +description: Embed Ruflet's mruby runtime in Flutter apps for Android, iOS, and macOS. +version: 0.0.2 homepage: https://github.com/AdamMusa/ruflet +repository: https://github.com/AdamMusa/ruflet +issue_tracker: https://github.com/AdamMusa/ruflet/issues +documentation: https://github.com/AdamMusa/ruflet/tree/main/ruby_runtime + +topics: + - ruby + - mruby + - flutter-plugin + - desktop environment: sdk: ^3.11.0 @@ -21,7 +30,7 @@ flutter: plugin: platforms: android: - package: com.example.ruby_runtime + package: com.izeesoft.ruby_runtime pluginClass: MrubyRuntimePlugin ios: pluginClass: MrubyRuntimePlugin diff --git a/ruby_runtime/test/mruby_runtime_plugin_method_channel_test.dart b/ruby_runtime/test/mruby_runtime_plugin_method_channel_test.dart index 679c8df..19d9e12 100644 --- a/ruby_runtime/test/mruby_runtime_plugin_method_channel_test.dart +++ b/ruby_runtime/test/mruby_runtime_plugin_method_channel_test.dart @@ -24,6 +24,8 @@ void main() { return null; case 'isFileServerRunning': return true; + case 'lastFileServerError': + return ''; default: return null; } @@ -58,4 +60,8 @@ void main() { test('stopFileServer', () async { await platform.stopFileServer(); }); + + test('lastFileServerError', () async { + expect(await platform.lastFileServerError(), ''); + }); } diff --git a/ruby_runtime/test/mruby_runtime_plugin_test.dart b/ruby_runtime/test/mruby_runtime_plugin_test.dart index 6806edd..4da5bfe 100644 --- a/ruby_runtime/test/mruby_runtime_plugin_test.dart +++ b/ruby_runtime/test/mruby_runtime_plugin_test.dart @@ -24,6 +24,9 @@ class MockRubyRuntimePlatform @override Future isFileServerRunning() async => true; + + @override + Future lastFileServerError() async => ''; } void main() { @@ -40,6 +43,7 @@ void main() { expect(await RubyRuntime.runFile('/tmp/demo.rb'), 'file:/tmp/demo.rb'); await RubyRuntime.startFileServer('/tmp/demo.rb'); expect(await RubyRuntime.isFileServerRunning(), true); + expect(await RubyRuntime.lastFileServerError(), ''); await RubyRuntime.stopFileServer(); await RubyRuntime.reset(); }); diff --git a/ruflet_client/ios/Podfile.lock b/ruflet_client/ios/Podfile.lock new file mode 100644 index 0000000..fee3ae2 --- /dev/null +++ b/ruflet_client/ios/Podfile.lock @@ -0,0 +1,235 @@ +PODS: + - audioplayers_darwin (0.0.1): + - Flutter + - FlutterMacOS + - battery_plus (1.0.0): + - Flutter + - camera_avfoundation (0.0.1): + - Flutter + - connectivity_plus (0.0.1): + - Flutter + - device_info_plus (0.0.1): + - Flutter + - DKImagePickerController/Core (4.3.9): + - DKImagePickerController/ImageDataManager + - DKImagePickerController/Resource + - DKImagePickerController/ImageDataManager (4.3.9) + - DKImagePickerController/PhotoGallery (4.3.9): + - DKImagePickerController/Core + - DKPhotoGallery + - DKImagePickerController/Resource (4.3.9) + - DKPhotoGallery (0.0.19): + - DKPhotoGallery/Core (= 0.0.19) + - DKPhotoGallery/Model (= 0.0.19) + - DKPhotoGallery/Preview (= 0.0.19) + - DKPhotoGallery/Resource (= 0.0.19) + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Core (0.0.19): + - DKPhotoGallery/Model + - DKPhotoGallery/Preview + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Model (0.0.19): + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Preview (0.0.19): + - DKPhotoGallery/Model + - DKPhotoGallery/Resource + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Resource (0.0.19): + - SDWebImage + - SwiftyGif + - file_picker (0.0.1): + - DKImagePickerController/PhotoGallery + - Flutter + - Flutter (1.0.0) + - flutter_native_splash (2.4.3): + - Flutter + - flutter_secure_storage_darwin (10.0.0): + - Flutter + - FlutterMacOS + - geolocator_apple (1.2.0): + - Flutter + - FlutterMacOS + - Google-Mobile-Ads-SDK (12.14.0): + - GoogleUserMessagingPlatform (>= 1.1) + - google_mobile_ads (7.0.0): + - Flutter + - Google-Mobile-Ads-SDK (~> 12.14.0) + - webview_flutter_wkwebview + - GoogleUserMessagingPlatform (3.1.0) + - media_kit_libs_ios_video (1.0.4): + - Flutter + - media_kit_video (0.0.1): + - Flutter + - mobile_scanner (7.0.0): + - Flutter + - FlutterMacOS + - package_info_plus (0.4.5): + - Flutter + - pasteboard (0.0.1): + - Flutter + - path_provider_foundation (0.0.1): + - Flutter + - FlutterMacOS + - permission_handler_apple (9.3.0): + - Flutter + - record_ios (1.2.0): + - Flutter + - screen_brightness_ios (0.1.0): + - Flutter + - SDWebImage (5.21.6): + - SDWebImage/Core (= 5.21.6) + - SDWebImage/Core (5.21.6) + - sensors_plus (0.0.1): + - Flutter + - share_plus (0.0.1): + - Flutter + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + - SwiftyGif (5.4.5) + - torch_light (0.0.1): + - Flutter + - url_launcher_ios (0.0.1): + - Flutter + - wakelock_plus (0.0.1): + - Flutter + - webview_flutter_wkwebview (0.0.1): + - Flutter + - FlutterMacOS + +DEPENDENCIES: + - audioplayers_darwin (from `.symlinks/plugins/audioplayers_darwin/darwin`) + - battery_plus (from `.symlinks/plugins/battery_plus/ios`) + - camera_avfoundation (from `.symlinks/plugins/camera_avfoundation/ios`) + - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) + - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) + - file_picker (from `.symlinks/plugins/file_picker/ios`) + - Flutter (from `Flutter`) + - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`) + - flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`) + - geolocator_apple (from `.symlinks/plugins/geolocator_apple/darwin`) + - google_mobile_ads (from `.symlinks/plugins/google_mobile_ads/ios`) + - media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`) + - media_kit_video (from `.symlinks/plugins/media_kit_video/ios`) + - mobile_scanner (from `.symlinks/plugins/mobile_scanner/darwin`) + - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) + - pasteboard (from `.symlinks/plugins/pasteboard/ios`) + - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) + - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) + - record_ios (from `.symlinks/plugins/record_ios/ios`) + - screen_brightness_ios (from `.symlinks/plugins/screen_brightness_ios/ios`) + - sensors_plus (from `.symlinks/plugins/sensors_plus/ios`) + - share_plus (from `.symlinks/plugins/share_plus/ios`) + - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) + - torch_light (from `.symlinks/plugins/torch_light/ios`) + - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) + - wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`) + - webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`) + +SPEC REPOS: + trunk: + - DKImagePickerController + - DKPhotoGallery + - Google-Mobile-Ads-SDK + - GoogleUserMessagingPlatform + - SDWebImage + - SwiftyGif + +EXTERNAL SOURCES: + audioplayers_darwin: + :path: ".symlinks/plugins/audioplayers_darwin/darwin" + battery_plus: + :path: ".symlinks/plugins/battery_plus/ios" + camera_avfoundation: + :path: ".symlinks/plugins/camera_avfoundation/ios" + connectivity_plus: + :path: ".symlinks/plugins/connectivity_plus/ios" + device_info_plus: + :path: ".symlinks/plugins/device_info_plus/ios" + file_picker: + :path: ".symlinks/plugins/file_picker/ios" + Flutter: + :path: Flutter + flutter_native_splash: + :path: ".symlinks/plugins/flutter_native_splash/ios" + flutter_secure_storage_darwin: + :path: ".symlinks/plugins/flutter_secure_storage_darwin/darwin" + geolocator_apple: + :path: ".symlinks/plugins/geolocator_apple/darwin" + google_mobile_ads: + :path: ".symlinks/plugins/google_mobile_ads/ios" + media_kit_libs_ios_video: + :path: ".symlinks/plugins/media_kit_libs_ios_video/ios" + media_kit_video: + :path: ".symlinks/plugins/media_kit_video/ios" + mobile_scanner: + :path: ".symlinks/plugins/mobile_scanner/darwin" + package_info_plus: + :path: ".symlinks/plugins/package_info_plus/ios" + pasteboard: + :path: ".symlinks/plugins/pasteboard/ios" + path_provider_foundation: + :path: ".symlinks/plugins/path_provider_foundation/darwin" + permission_handler_apple: + :path: ".symlinks/plugins/permission_handler_apple/ios" + record_ios: + :path: ".symlinks/plugins/record_ios/ios" + screen_brightness_ios: + :path: ".symlinks/plugins/screen_brightness_ios/ios" + sensors_plus: + :path: ".symlinks/plugins/sensors_plus/ios" + share_plus: + :path: ".symlinks/plugins/share_plus/ios" + shared_preferences_foundation: + :path: ".symlinks/plugins/shared_preferences_foundation/darwin" + torch_light: + :path: ".symlinks/plugins/torch_light/ios" + url_launcher_ios: + :path: ".symlinks/plugins/url_launcher_ios/ios" + wakelock_plus: + :path: ".symlinks/plugins/wakelock_plus/ios" + webview_flutter_wkwebview: + :path: ".symlinks/plugins/webview_flutter_wkwebview/darwin" + +SPEC CHECKSUMS: + audioplayers_darwin: 835ced6edd4c9fc8ebb0a7cc9e294a91d99917d5 + battery_plus: b42253f6d2dde71712f8c36fef456d99121c5977 + camera_avfoundation: 5675ca25298b6f81fa0a325188e7df62cc217741 + connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd + device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe + DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c + DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 + file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf + flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23 + geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e + Google-Mobile-Ads-SDK: 4534fd2dfcd3f705c5485a6633c5188d03d4eed2 + google_mobile_ads: df3008bafbe1f2ad6862f87334e560d2f047f902 + GoogleUserMessagingPlatform: befe603da6501006420c206222acd449bba45a9c + media_kit_libs_ios_video: 5a18affdb97d1f5d466dc79988b13eff6c5e2854 + media_kit_video: 1746e198cb697d1ffb734b1d05ec429d1fcd1474 + mobile_scanner: 9157936403f5a0644ca3779a38ff8404c5434a93 + package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 + pasteboard: 49088aeb6119d51f976a421db60d8e1ab079b63c + path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 + permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d + record_ios: 412daca2350b228e698fffcd08f1f94ceb1e3844 + screen_brightness_ios: 9953fd7da5bd480f1a93990daeec2eb42d4f3b52 + SDWebImage: 1bb6a1b84b6fe87b972a102bdc77dd589df33477 + sensors_plus: 6a11ed0c2e1d0bd0b20b4029d3bad27d96e0c65b + share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a + shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb + SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 + torch_light: d093d579a221a59ef8a6b8c0eca20d52f7178087 + url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b + wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556 + webview_flutter_wkwebview: 8ebf4fded22593026f7dbff1fbff31ea98573c8d + +PODFILE CHECKSUM: f815cc60fef984739aa0c9ecd0312d6c04e3fc2c + +COCOAPODS: 1.16.2 diff --git a/ruflet_client/pubspec.lock b/ruflet_client/pubspec.lock index 220fe19..e77e89f 100644 --- a/ruflet_client/pubspec.lock +++ b/ruflet_client/pubspec.lock @@ -1302,6 +1302,13 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.7" + ruby_runtime: + dependency: "direct overridden" + description: + path: "../ruby_runtime" + relative: true + source: path + version: "0.0.1" safe_local_storage: dependency: transitive description: diff --git a/ruflet_client/pubspec_overrides.yaml b/ruflet_client/pubspec_overrides.yaml new file mode 100644 index 0000000..a17faac --- /dev/null +++ b/ruflet_client/pubspec_overrides.yaml @@ -0,0 +1,3 @@ +dependency_overrides: + ruby_runtime: + path: ../ruby_runtime diff --git a/templates/ruflet_flutter_template/.DS_Store b/templates/ruflet_flutter_template/.DS_Store index b7a7e46..2685b3a 100644 Binary files a/templates/ruflet_flutter_template/.DS_Store and b/templates/ruflet_flutter_template/.DS_Store differ diff --git a/templates/ruflet_flutter_template/README.md b/templates/ruflet_flutter_template/README.md index 987a7ec..d904f06 100644 --- a/templates/ruflet_flutter_template/README.md +++ b/templates/ruflet_flutter_template/README.md @@ -1,15 +1,16 @@ # ruflet_flutter_template -Ruflet Flutter template for a self-contained Ruby-driven app or an external Ruflet backend. +Ruflet Flutter template for either a self-contained Ruby-driven app or a server-driven client. ## What is included - Ruflet/Flet client bootstrap with fixed local port auto-connect (`8550`). -- Self-contained startup via `ruby_runtime` when `RUFLET_CLIENT_URL` is not set. +- Self-contained startup via `ruby_runtime` in `lib/main.self.dart`. +- Server-driven startup in `lib/main.server.dart`. - Developer-editable Ruby entry file at: - `assets/main.rb` - External backend override via: - - `--dart-define=RUFLET_CLIENT_URL=http://host:8550` + - `--dart-define=RUFLET_BACKEND_URL=http://host:8550` ## Run client template @@ -19,14 +20,26 @@ flutter pub get flutter run ``` -The template runs `assets/main.rb` inside the app by default, so developers can replace that file with their own Ruflet implementation. +The default `flutter run` entrypoint uses `lib/main.self.dart`, so developers can replace `assets/main.rb` with their own Ruflet implementation. To connect to an external backend instead: ```bash -flutter run --dart-define=RUFLET_CLIENT_URL=http://127.0.0.1:8550 +flutter run --dart-define=RUFLET_BACKEND_URL=http://127.0.0.1:8550 ``` +For Ruflet CLI builds: + +```bash +ruflet build apk --self +ruflet build ios --self +ruflet build apk +ruflet build ios +``` + +- `ruflet build ... --self` builds the self-contained client with `ruby_runtime`. +- `ruflet build ...` without `--self` builds the server-driven client without `ruby_runtime`. + For desktop or web testing: ```bash diff --git a/templates/ruflet_flutter_template/assets/main.rb b/templates/ruflet_flutter_template/assets/main.rb index e695596..df20ce0 100644 --- a/templates/ruflet_flutter_template/assets/main.rb +++ b/templates/ruflet_flutter_template/assets/main.rb @@ -17,7 +17,7 @@ ) ), appbar: app_bar(title: text("Ruflet demo", style: { size: 18 })), - floating_action_button: fab( + floating_action_button: FloatingActionButton( icon: Ruflet::MaterialIcons::ADD, on_click: ->(_e) do count += 1 diff --git a/templates/ruflet_flutter_template/lib/main.dart b/templates/ruflet_flutter_template/lib/main.dart index 42ad089..e0a83b3 100644 --- a/templates/ruflet_flutter_template/lib/main.dart +++ b/templates/ruflet_flutter_template/lib/main.dart @@ -1,341 +1,3 @@ -import 'dart:async'; -import 'package:flet/flet.dart'; -import 'package:flet_ads/flet_ads.dart' as ruflet_ads; -// --FAT_CLIENT_START-- -import 'package:flet_audio/flet_audio.dart' as ruflet_audio; -// --FAT_CLIENT_END-- -import 'package:flet_audio_recorder/flet_audio_recorder.dart' - as ruflet_audio_recorder; -import 'package:flet_camera/flet_camera.dart' as ruflet_camera; -import 'package:flet_charts/flet_charts.dart' as ruflet_charts; -import 'package:flet_code_editor/flet_code_editor.dart' as ruflet_code_editor; -import 'package:flet_color_pickers/flet_color_pickers.dart' - as ruflet_color_picker; -import 'package:flet_datatable2/flet_datatable2.dart' as ruflet_datatable2; -import 'package:flet_flashlight/flet_flashlight.dart' as ruflet_flashlight; -import 'package:flet_geolocator/flet_geolocator.dart' as ruflet_geolocator; -import 'package:flet_lottie/flet_lottie.dart' as ruflet_lottie; -import 'package:flet_map/flet_map.dart' as ruflet_map; -import 'package:flet_permission_handler/flet_permission_handler.dart' - as ruflet_permission_handler; -// --FAT_CLIENT_START-- -// --FAT_CLIENT_END-- -import 'package:flet_secure_storage/flet_secure_storage.dart' - as ruflet_secure_storage; -// --FAT_CLIENT_START-- -import 'package:flet_video/flet_video.dart' as ruflet_video; -// --FAT_CLIENT_END-- -import 'package:flet_webview/flet_webview.dart' as ruflet_webview; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_web_plugins/url_strategy.dart'; -import 'package:ruby_runtime/ruby_runtime.dart'; +import 'main.self.dart' as self_app; -import 'connection_probe.dart'; - -const bool isProduction = bool.fromEnvironment('dart.vm.product'); -const int kRufletPort = 8550; -const String kConfiguredClientUrl = - String.fromEnvironment('RUFLET_CLIENT_URL', defaultValue: ''); -const String kEmbeddedRubyAsset = 'assets/main.rb'; -Tester? tester; - -String normalizePageUrlForPlatform(String rawUrl) { - final uri = Uri.tryParse(rawUrl); - if (uri == null || uri.host.isEmpty) return rawUrl; - - final localHosts = { - '0.0.0.0', - '::', - '[::]', - '127.0.0.1', - 'localhost', - '::1', - '[::1]', - }; - if (!localHosts.contains(uri.host)) { - return rawUrl; - } - - String host; - switch (defaultTargetPlatform) { - case TargetPlatform.android: - host = '10.0.2.2'; - break; - case TargetPlatform.macOS: - case TargetPlatform.windows: - case TargetPlatform.linux: - case TargetPlatform.iOS: - case TargetPlatform.fuchsia: - host = 'localhost'; - break; - } - - return uri.replace(host: host).toString(); -} - -String fallbackBackendUrl() => - normalizePageUrlForPlatform('http://0.0.0.0:$kRufletPort'); - -String resolveBackendUrl() { - final configured = parseBackendUrl(kConfiguredClientUrl); - if (configured != null) return configured; - return fallbackBackendUrl(); -} - -Future main() async { - if (isProduction) { - // ignore: avoid_returning_null_for_void - debugPrint = (String? message, {int? wrapWidth}) => null; - } - - await setupDesktop(); - WidgetsFlutterBinding.ensureInitialized(); - - if (kIsWeb) { - final routeUrlStrategy = getFletRouteUrlStrategy(); - if (routeUrlStrategy == 'path') { - usePathUrlStrategy(); - } - } - - final extensions = [ - ruflet_ads.Extension(), - ruflet_audio_recorder.Extension(), - ruflet_camera.Extension(), - ruflet_charts.Extension(), - ruflet_code_editor.Extension(), - ruflet_color_picker.Extension(), - ruflet_datatable2.Extension(), - ruflet_flashlight.Extension(), - ruflet_geolocator.Extension(), - ruflet_lottie.Extension(), - ruflet_map.Extension(), - ruflet_permission_handler.Extension(), - ruflet_secure_storage.Extension(), - ruflet_webview.Extension(), - - // --FAT_CLIENT_START-- - ruflet_audio.Extension(), - ruflet_video.Extension(), - // --FAT_CLIENT_END-- - ]; - - for (final extension in extensions) { - extension.ensureInitialized(); - } - - EmbeddedRufletRuntime? embeddedRuntime; - var pageUrl = resolveBackendUrl(); - if (!kIsWeb && kConfiguredClientUrl.trim().isEmpty) { - embeddedRuntime = await EmbeddedRufletRuntime.start(); - pageUrl = embeddedRuntime.pageUrl; - } - - if (embeddedRuntime == null) { - await waitForBackend(pageUrl); - } else { - await Future.delayed(const Duration(milliseconds: 250)); - } - - runApp( - TemplateApp( - pageUrl: pageUrl, - embeddedRuntime: embeddedRuntime, - extensions: extensions, - ), - ); -} - -class TemplateApp extends StatefulWidget { - const TemplateApp({ - super.key, - required this.pageUrl, - required this.extensions, - this.embeddedRuntime, - }); - - final String pageUrl; - final List extensions; - final EmbeddedRufletRuntime? embeddedRuntime; - - @override - State createState() => _TemplateAppState(); -} - -class _TemplateAppState extends State { - Timer? _serverErrorPoller; - String? _lastEmbeddedServerError; - - @override - void initState() { - super.initState(); - if (widget.embeddedRuntime != null) { - _serverErrorPoller = Timer.periodic(const Duration(seconds: 1), (_) async { - final serverError = await RubyRuntime.lastFileServerError(); - if (!mounted || serverError.isEmpty || serverError == _lastEmbeddedServerError) { - return; - } - _lastEmbeddedServerError = serverError; - debugPrint('Embedded server error: $serverError'); - }); - } - } - - @override - void dispose() { - _serverErrorPoller?.cancel(); - unawaited(widget.embeddedRuntime?.dispose()); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final error = widget.embeddedRuntime?.error; - if (error != null) { - return MaterialApp( - debugShowCheckedModeBanner: false, - home: Scaffold( - appBar: AppBar(title: const Text('Ruflet')), - body: Padding( - padding: const EdgeInsets.all(16), - child: SelectableText(error), - ), - ), - ); - } - - return FletApp( - title: 'Ruflet', - pageUrl: widget.pageUrl, - assetsDir: '', - errorsHandler: FletAppErrorsHandler(), - showAppStartupScreen: true, - appStartupScreenMessage: 'Working...', - appErrorMessage: 'The application encountered an error: {message}', - extensions: widget.extensions, - multiView: isMultiView(), - tester: tester, - ); - } -} - -Future waitForBackend(String pageUrl) async { - if (kIsWeb) return; - - final deadline = DateTime.now().add(const Duration(seconds: 20)); - while (DateTime.now().isBefore(deadline)) { - if (await canConnectToPageUrl(pageUrl)) return; - await Future.delayed(const Duration(milliseconds: 300)); - } - debugPrint('Backend not reachable yet at $pageUrl. Flet client will retry.'); -} - -String? parseBackendUrl(String value) { - if (value.isEmpty) return null; - final raw = value.trim(); - final uri = Uri.tryParse(raw); - if (uri != null && - (uri.scheme == 'http' || - uri.scheme == 'https' || - uri.scheme == 'ws' || - uri.scheme == 'wss') && - uri.host.isNotEmpty) { - return normalizePageUrlForPlatform(raw); - } - final match = RegExp(r'(https?:\/\/[^\s]+|wss?:\/\/[^\s]+)').firstMatch(raw); - if (match == null) return null; - return normalizePageUrlForPlatform(match.group(0)!); -} - -class EmbeddedRufletRuntime { - EmbeddedRufletRuntime._({ - required this.pageUrl, - required this.workDir, - this.error, - }); - - final String pageUrl; - final Directory workDir; - final String? error; - - static Future start() async { - await _deleteStaleTempWorkDirs(); - final workDir = await Directory.systemTemp.createTemp('ruflet_template_'); - final serverPath = '${workDir.path}/main.rb'; - final stopPath = '${workDir.path}/server.stop'; - const pageUrl = 'http://127.0.0.1:$kRufletPort'; - - try { - await RubyRuntime.initialize(); - await RubyRuntime.eval("ENV['RUFLET_DEBUG'] ||= '1'; 'debug enabled'"); - final digestLength = await RubyRuntime.eval( - "require 'digest/sha1'; Digest::SHA1.digest('abc').bytesize.to_s", - ); - debugPrint('Embedded Digest::SHA1 bytesize: $digestLength'); - final source = await rootBundle.loadString(kEmbeddedRubyAsset); - debugPrint(_describeEmbeddedAsset(source, serverPath)); - await File(serverPath).writeAsString(source); - await RubyRuntime.startFileServer(serverPath, stopSignalPath: stopPath); - final startupDeadline = DateTime.now().add(const Duration(seconds: 5)); - while (DateTime.now().isBefore(startupDeadline)) { - if (await RubyRuntime.isFileServerRunning()) { - return EmbeddedRufletRuntime._(pageUrl: pageUrl, workDir: workDir); - } - final serverError = await RubyRuntime.lastFileServerError(); - if (serverError.isNotEmpty) { - throw Exception(serverError); - } - await Future.delayed(const Duration(milliseconds: 100)); - } - return EmbeddedRufletRuntime._(pageUrl: pageUrl, workDir: workDir); - } catch (error, stackTrace) { - return EmbeddedRufletRuntime._( - pageUrl: pageUrl, - workDir: workDir, - error: 'Failed to start embedded Ruflet.\n$error\n$stackTrace', - ); - } - } - - Future dispose() async { - try { - await RubyRuntime.stopFileServer(); - } catch (_) {} - try { - await RubyRuntime.reset(); - } catch (_) {} - try { - if (await workDir.exists()) { - await workDir.delete(recursive: true); - } - } catch (_) {} - } - - static Future _deleteStaleTempWorkDirs() async { - try { - await for (final entity in Directory.systemTemp.list()) { - if (entity is! Directory) continue; - final name = entity.uri.pathSegments.isEmpty - ? '' - : entity.uri.pathSegments[entity.uri.pathSegments.length - 2]; - if (!name.startsWith('ruflet_template_')) continue; - try { - await entity.delete(recursive: true); - } catch (_) {} - } - } catch (_) {} - } - - static String _describeEmbeddedAsset(String source, String serverPath) { - final lines = source.split('\n'); - final preview = lines - .map((line) => line.trim()) - .where((line) => line.isNotEmpty) - .take(3) - .join(' | '); - return 'Embedded Ruby asset $kEmbeddedRubyAsset -> $serverPath ' - '(${source.length} chars) preview: $preview'; - } -} +Future main() => self_app.main(); diff --git a/templates/ruflet_flutter_template/lib/main.self.dart b/templates/ruflet_flutter_template/lib/main.self.dart new file mode 100644 index 0000000..266a05b --- /dev/null +++ b/templates/ruflet_flutter_template/lib/main.self.dart @@ -0,0 +1,380 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flet/flet.dart'; +import 'package:flet_ads/flet_ads.dart' as ruflet_ads; +// --FAT_CLIENT_START-- +import 'package:flet_audio/flet_audio.dart' as ruflet_audio; +// --FAT_CLIENT_END-- +import 'package:flet_audio_recorder/flet_audio_recorder.dart' + as ruflet_audio_recorder; +import 'package:flet_camera/flet_camera.dart' as ruflet_camera; +import 'package:flet_charts/flet_charts.dart' as ruflet_charts; +import 'package:flet_code_editor/flet_code_editor.dart' as ruflet_code_editor; +import 'package:flet_color_pickers/flet_color_pickers.dart' + as ruflet_color_picker; +import 'package:flet_datatable2/flet_datatable2.dart' as ruflet_datatable2; +import 'package:flet_flashlight/flet_flashlight.dart' as ruflet_flashlight; +import 'package:flet_geolocator/flet_geolocator.dart' as ruflet_geolocator; +import 'package:flet_lottie/flet_lottie.dart' as ruflet_lottie; +import 'package:flet_map/flet_map.dart' as ruflet_map; +import 'package:flet_permission_handler/flet_permission_handler.dart' + as ruflet_permission_handler; +// --FAT_CLIENT_START-- +// --FAT_CLIENT_END-- +import 'package:flet_secure_storage/flet_secure_storage.dart' + as ruflet_secure_storage; +// --FAT_CLIENT_START-- +import 'package:flet_video/flet_video.dart' as ruflet_video; +// --FAT_CLIENT_END-- +import 'package:flet_webview/flet_webview.dart' as ruflet_webview; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_web_plugins/url_strategy.dart'; +import 'package:ruby_runtime/ruby_runtime.dart'; + +import 'connection_probe.dart'; + +const bool isProduction = bool.fromEnvironment('dart.vm.product'); +const int kRufletPort = 8550; +const String kConfiguredClientUrl = String.fromEnvironment( + 'RUFLET_BACKEND_URL', + defaultValue: String.fromEnvironment('RUFLET_CLIENT_URL', defaultValue: ''), +); +const String kEmbeddedRubyAsset = 'assets/main.rb'; +const String kEmbeddedProjectPrefix = 'assets/ruby_project/'; +Tester? tester; + +String normalizePageUrlForPlatform(String rawUrl) { + final uri = Uri.tryParse(rawUrl); + if (uri == null || uri.host.isEmpty) return rawUrl; + + final localHosts = { + '0.0.0.0', + '::', + '[::]', + '127.0.0.1', + 'localhost', + '::1', + '[::1]', + }; + if (!localHosts.contains(uri.host)) { + return rawUrl; + } + + String host; + switch (defaultTargetPlatform) { + case TargetPlatform.android: + host = '10.0.2.2'; + break; + case TargetPlatform.macOS: + case TargetPlatform.windows: + case TargetPlatform.linux: + case TargetPlatform.iOS: + case TargetPlatform.fuchsia: + host = 'localhost'; + break; + } + + return uri.replace(host: host).toString(); +} + +String fallbackBackendUrl() => + normalizePageUrlForPlatform('http://0.0.0.0:$kRufletPort'); + +String resolveBackendUrl() { + final configured = parseBackendUrl(kConfiguredClientUrl); + if (configured != null) return configured; + return fallbackBackendUrl(); +} + +Future main() async { + if (isProduction) { + // ignore: avoid_returning_null_for_void + debugPrint = (String? message, {int? wrapWidth}) => null; + } + + await setupDesktop(); + WidgetsFlutterBinding.ensureInitialized(); + + if (kIsWeb) { + final routeUrlStrategy = getFletRouteUrlStrategy(); + if (routeUrlStrategy == 'path') { + usePathUrlStrategy(); + } + } + + final extensions = [ + ruflet_ads.Extension(), + ruflet_audio_recorder.Extension(), + ruflet_camera.Extension(), + ruflet_charts.Extension(), + ruflet_code_editor.Extension(), + ruflet_color_picker.Extension(), + ruflet_datatable2.Extension(), + ruflet_flashlight.Extension(), + ruflet_geolocator.Extension(), + ruflet_lottie.Extension(), + ruflet_map.Extension(), + ruflet_permission_handler.Extension(), + ruflet_secure_storage.Extension(), + ruflet_webview.Extension(), + + // --FAT_CLIENT_START-- + ruflet_audio.Extension(), + ruflet_video.Extension(), + // --FAT_CLIENT_END-- + ]; + + for (final extension in extensions) { + extension.ensureInitialized(); + } + + EmbeddedRufletRuntime? embeddedRuntime; + var pageUrl = resolveBackendUrl(); + if (!kIsWeb && kConfiguredClientUrl.trim().isEmpty) { + embeddedRuntime = await EmbeddedRufletRuntime.start(); + pageUrl = embeddedRuntime.pageUrl; + } + + if (embeddedRuntime == null) { + await waitForBackend(pageUrl); + } else { + await Future.delayed(const Duration(milliseconds: 250)); + } + + runApp( + TemplateApp( + pageUrl: pageUrl, + embeddedRuntime: embeddedRuntime, + extensions: extensions, + ), + ); +} + +class TemplateApp extends StatefulWidget { + const TemplateApp({ + super.key, + required this.pageUrl, + required this.extensions, + this.embeddedRuntime, + }); + + final String pageUrl; + final List extensions; + final EmbeddedRufletRuntime? embeddedRuntime; + + @override + State createState() => _TemplateAppState(); +} + +class _TemplateAppState extends State { + Timer? _serverErrorPoller; + String? _lastEmbeddedServerError; + + @override + void initState() { + super.initState(); + if (widget.embeddedRuntime != null) { + _serverErrorPoller = Timer.periodic(const Duration(seconds: 1), (_) async { + final serverError = await RubyRuntime.lastFileServerError(); + if (!mounted || serverError.isEmpty || serverError == _lastEmbeddedServerError) { + return; + } + _lastEmbeddedServerError = serverError; + debugPrint('Embedded server error: $serverError'); + }); + } + } + + @override + void dispose() { + _serverErrorPoller?.cancel(); + unawaited(widget.embeddedRuntime?.dispose()); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final error = widget.embeddedRuntime?.error; + if (error != null) { + return MaterialApp( + debugShowCheckedModeBanner: false, + home: Scaffold( + appBar: AppBar(title: const Text('Ruflet')), + body: Padding( + padding: const EdgeInsets.all(16), + child: SelectableText(error), + ), + ), + ); + } + + return FletApp( + title: 'Ruflet', + pageUrl: widget.pageUrl, + assetsDir: '', + errorsHandler: FletAppErrorsHandler(), + showAppStartupScreen: true, + appStartupScreenMessage: 'Working...', + appErrorMessage: 'The application encountered an error: {message}', + extensions: widget.extensions, + multiView: isMultiView(), + tester: tester, + ); + } +} + +Future waitForBackend(String pageUrl) async { + if (kIsWeb) return; + + final deadline = DateTime.now().add(const Duration(seconds: 20)); + while (DateTime.now().isBefore(deadline)) { + if (await canConnectToPageUrl(pageUrl)) return; + await Future.delayed(const Duration(milliseconds: 300)); + } + debugPrint('Backend not reachable yet at $pageUrl. Flet client will retry.'); +} + +String? parseBackendUrl(String value) { + if (value.isEmpty) return null; + final raw = value.trim(); + final uri = Uri.tryParse(raw); + if (uri != null && + (uri.scheme == 'http' || + uri.scheme == 'https' || + uri.scheme == 'ws' || + uri.scheme == 'wss') && + uri.host.isNotEmpty) { + return normalizePageUrlForPlatform(raw); + } + final match = RegExp(r'(https?:\/\/[^\s]+|wss?:\/\/[^\s]+)').firstMatch(raw); + if (match == null) return null; + return normalizePageUrlForPlatform(match.group(0)!); +} + +class EmbeddedRufletRuntime { + EmbeddedRufletRuntime._({ + required this.pageUrl, + required this.workDir, + this.error, + }); + + final String pageUrl; + final Directory workDir; + final String? error; + + static Future start() async { + await _deleteStaleTempWorkDirs(); + final workDir = await Directory.systemTemp.createTemp('ruflet_template_'); + final stopPath = '${workDir.path}/server.stop'; + const pageUrl = 'http://127.0.0.1:$kRufletPort'; + + try { + await RubyRuntime.initialize(); + await RubyRuntime.eval("ENV['RUFLET_DEBUG'] ||= '1'; 'debug enabled'"); + final digestLength = await RubyRuntime.eval( + "require 'digest/sha1'; Digest::SHA1.digest('abc').bytesize.to_s", + ); + debugPrint('Embedded Digest::SHA1 bytesize: $digestLength'); + final serverPath = await _prepareProjectFiles(workDir); + await RubyRuntime.startFileServer(serverPath, stopSignalPath: stopPath); + final startupDeadline = DateTime.now().add(const Duration(seconds: 5)); + while (DateTime.now().isBefore(startupDeadline)) { + if (await RubyRuntime.isFileServerRunning()) { + return EmbeddedRufletRuntime._(pageUrl: pageUrl, workDir: workDir); + } + final serverError = await RubyRuntime.lastFileServerError(); + if (serverError.isNotEmpty) { + throw Exception(serverError); + } + await Future.delayed(const Duration(milliseconds: 100)); + } + return EmbeddedRufletRuntime._(pageUrl: pageUrl, workDir: workDir); + } catch (error, stackTrace) { + return EmbeddedRufletRuntime._( + pageUrl: pageUrl, + workDir: workDir, + error: 'Failed to start embedded Ruflet.\n$error\n$stackTrace', + ); + } + } + + Future dispose() async { + try { + await RubyRuntime.stopFileServer(); + } catch (_) {} + try { + await RubyRuntime.reset(); + } catch (_) {} + try { + if (await workDir.exists()) { + await workDir.delete(recursive: true); + } + } catch (_) {} + } + + static Future _deleteStaleTempWorkDirs() async { + try { + await for (final entity in Directory.systemTemp.list()) { + if (entity is! Directory) continue; + final name = entity.uri.pathSegments.isEmpty + ? '' + : entity.uri.pathSegments[entity.uri.pathSegments.length - 2]; + if (!name.startsWith('ruflet_template_')) continue; + try { + await entity.delete(recursive: true); + } catch (_) {} + } + } catch (_) {} + } + + static Future _prepareProjectFiles(Directory workDir) async { + final manifest = await _loadAssetManifest(); + final projectAssets = manifest.where((asset) => asset.startsWith(kEmbeddedProjectPrefix)).toList(); + + if (projectAssets.isNotEmpty) { + for (final asset in projectAssets) { + final relative = asset.substring(kEmbeddedProjectPrefix.length); + if (relative.isEmpty) continue; + final destination = File('${workDir.path}/$relative'); + await destination.parent.create(recursive: true); + final data = await rootBundle.load(asset); + await destination.writeAsBytes( + data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes), + ); + } + return '${workDir.path}/main.rb'; + } + + final serverPath = '${workDir.path}/main.rb'; + final source = await rootBundle.loadString(kEmbeddedRubyAsset); + debugPrint(_describeEmbeddedAsset(source, serverPath)); + await File(serverPath).writeAsString(source); + return serverPath; + } + + static Future> _loadAssetManifest() async { + try { + final raw = await rootBundle.loadString('AssetManifest.json'); + final decoded = jsonDecode(raw); + if (decoded is Map) { + return decoded.keys.toList(); + } + } catch (_) {} + return const []; + } + + static String _describeEmbeddedAsset(String source, String serverPath) { + final lines = source.split('\n'); + final preview = lines + .map((line) => line.trim()) + .where((line) => line.isNotEmpty) + .take(3) + .join(' | '); + return 'Embedded Ruby asset $kEmbeddedRubyAsset -> $serverPath ' + '(${source.length} chars) preview: $preview'; + } +} diff --git a/templates/ruflet_flutter_template/lib/main.server.dart b/templates/ruflet_flutter_template/lib/main.server.dart new file mode 100644 index 0000000..21804ed --- /dev/null +++ b/templates/ruflet_flutter_template/lib/main.server.dart @@ -0,0 +1,188 @@ +import 'dart:async'; + +import 'package:flet/flet.dart'; +import 'package:flet_ads/flet_ads.dart' as ruflet_ads; +// --FAT_CLIENT_START-- +import 'package:flet_audio/flet_audio.dart' as ruflet_audio; +// --FAT_CLIENT_END-- +import 'package:flet_audio_recorder/flet_audio_recorder.dart' + as ruflet_audio_recorder; +import 'package:flet_camera/flet_camera.dart' as ruflet_camera; +import 'package:flet_charts/flet_charts.dart' as ruflet_charts; +import 'package:flet_code_editor/flet_code_editor.dart' as ruflet_code_editor; +import 'package:flet_color_pickers/flet_color_pickers.dart' + as ruflet_color_picker; +import 'package:flet_datatable2/flet_datatable2.dart' as ruflet_datatable2; +import 'package:flet_flashlight/flet_flashlight.dart' as ruflet_flashlight; +import 'package:flet_geolocator/flet_geolocator.dart' as ruflet_geolocator; +import 'package:flet_lottie/flet_lottie.dart' as ruflet_lottie; +import 'package:flet_map/flet_map.dart' as ruflet_map; +import 'package:flet_permission_handler/flet_permission_handler.dart' + as ruflet_permission_handler; +// --FAT_CLIENT_START-- +// --FAT_CLIENT_END-- +import 'package:flet_secure_storage/flet_secure_storage.dart' + as ruflet_secure_storage; +// --FAT_CLIENT_START-- +import 'package:flet_video/flet_video.dart' as ruflet_video; +// --FAT_CLIENT_END-- +import 'package:flet_webview/flet_webview.dart' as ruflet_webview; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_web_plugins/url_strategy.dart'; + +import 'connection_probe.dart'; + +const bool isProduction = bool.fromEnvironment('dart.vm.product'); +const int kRufletPort = 8550; +const String kConfiguredBackendUrl = String.fromEnvironment( + 'RUFLET_BACKEND_URL', + defaultValue: String.fromEnvironment('RUFLET_CLIENT_URL', defaultValue: ''), +); +Tester? tester; + +String normalizePageUrlForPlatform(String rawUrl) { + final uri = Uri.tryParse(rawUrl); + if (uri == null || uri.host.isEmpty) return rawUrl; + + final localHosts = { + '0.0.0.0', + '::', + '[::]', + '127.0.0.1', + 'localhost', + '::1', + '[::1]', + }; + if (!localHosts.contains(uri.host)) { + return rawUrl; + } + + String host; + switch (defaultTargetPlatform) { + case TargetPlatform.android: + host = '10.0.2.2'; + break; + case TargetPlatform.macOS: + case TargetPlatform.windows: + case TargetPlatform.linux: + case TargetPlatform.iOS: + case TargetPlatform.fuchsia: + host = 'localhost'; + break; + } + + return uri.replace(host: host).toString(); +} + +String fallbackBackendUrl() => + normalizePageUrlForPlatform('http://0.0.0.0:$kRufletPort'); + +String resolveBackendUrl() { + final configured = parseBackendUrl(kConfiguredBackendUrl); + if (configured != null) return configured; + return fallbackBackendUrl(); +} + +Future main() async { + if (isProduction) { + // ignore: avoid_returning_null_for_void + debugPrint = (String? message, {int? wrapWidth}) => null; + } + + await setupDesktop(); + WidgetsFlutterBinding.ensureInitialized(); + + if (kIsWeb) { + final routeUrlStrategy = getFletRouteUrlStrategy(); + if (routeUrlStrategy == 'path') { + usePathUrlStrategy(); + } + } + + final extensions = [ + ruflet_ads.Extension(), + ruflet_audio_recorder.Extension(), + ruflet_camera.Extension(), + ruflet_charts.Extension(), + ruflet_code_editor.Extension(), + ruflet_color_picker.Extension(), + ruflet_datatable2.Extension(), + ruflet_flashlight.Extension(), + ruflet_geolocator.Extension(), + ruflet_lottie.Extension(), + ruflet_map.Extension(), + ruflet_permission_handler.Extension(), + ruflet_secure_storage.Extension(), + ruflet_webview.Extension(), + + // --FAT_CLIENT_START-- + ruflet_audio.Extension(), + ruflet_video.Extension(), + // --FAT_CLIENT_END-- + ]; + + for (final extension in extensions) { + extension.ensureInitialized(); + } + + final pageUrl = resolveBackendUrl(); + await waitForBackend(pageUrl); + + runApp(TemplateApp(pageUrl: pageUrl, extensions: extensions)); +} + +class TemplateApp extends StatelessWidget { + const TemplateApp({ + super.key, + required this.pageUrl, + required this.extensions, + }); + + final String pageUrl; + final List extensions; + + @override + Widget build(BuildContext context) { + return FletApp( + title: 'Ruflet', + pageUrl: pageUrl, + assetsDir: '', + errorsHandler: FletAppErrorsHandler(), + showAppStartupScreen: true, + appStartupScreenMessage: 'Working...', + appErrorMessage: 'The application encountered an error: {message}', + extensions: extensions, + multiView: isMultiView(), + tester: tester, + ); + } +} + +Future waitForBackend(String pageUrl) async { + if (kIsWeb) return; + + final deadline = DateTime.now().add(const Duration(seconds: 20)); + while (DateTime.now().isBefore(deadline)) { + if (await canConnectToPageUrl(pageUrl)) return; + await Future.delayed(const Duration(milliseconds: 300)); + } + debugPrint('Backend not reachable yet at $pageUrl. Flet client will retry.'); +} + +String? parseBackendUrl(String value) { + if (value.isEmpty) return null; + final raw = value.trim(); + final uri = Uri.tryParse(raw); + if (uri != null && + (uri.scheme == 'http' || + uri.scheme == 'https' || + uri.scheme == 'ws' || + uri.scheme == 'wss') && + uri.host.isNotEmpty) { + return normalizePageUrlForPlatform(raw); + } + final match = RegExp(r'(https?:\/\/[^\s]+|wss?:\/\/[^\s]+)').firstMatch(raw); + if (match == null) return null; + return normalizePageUrlForPlatform(match.group(0)!); +} diff --git a/templates/ruflet_flutter_template/pubspec.yaml b/templates/ruflet_flutter_template/pubspec.yaml index 30dfa01..8790e2f 100644 --- a/templates/ruflet_flutter_template/pubspec.yaml +++ b/templates/ruflet_flutter_template/pubspec.yaml @@ -118,10 +118,7 @@ dependencies: url: https://github.com/flet-dev/flet.git path: sdk/python/packages/flet-webview/src/flutter/flet_webview ref: 67a9763da3bd2611bbb7626c3a1ec5e9d30fc965 - archive: ^4.0.9 - path_provider: ^2.1.5 - ruby_runtime: - path: ../../ruby_runtime + ruby_runtime: ^0.0.2 flutter_native_splash: ^2.4.7 dev_dependencies: flutter_launcher_icons: "^0.14.4"