Module: Plushie::Widget::NativeBuild

Defined in:
lib/plushie/widget/native_build.rb

Overview

Native widget build pipeline.

Writes a virtual app crate under +_build/plushie-renderer-spec/+, lists the configured native widget crates as path dependencies, and delegates the actual workspace generation and cargo build to +cargo plushie build+. The built binary is then copied into +_build/plushie/bin/+ where the renderer discovery chain expects to find it.

All of the heavy lifting (workspace generation, [patch.crates-io] forwarding, version-skew detection, collision checks, constructor validation) lives in cargo-plushie so every host SDK shares one implementation.

Constant Summary collapse

SCRATCH_DIR =

Directory holding the generated virtual app crate that cargo-plushie reads via cargo_metadata.

File.join("_build", "plushie-renderer-spec")

Class Method Summary collapse

Class Method Details

.build_with_widgets(widgets, release: false, verbose: false, bin_name: nil, **_) ⇒ String

Build the renderer binary. Works for stock builds (no native widgets) and custom builds (one or more native widgets).

Parameters:

  • widgets (Array<Class>)

    native widget classes (may be empty)

  • release (Boolean) (defaults to: false)

    build with optimizations

  • verbose (Boolean) (defaults to: false)

    stream cargo-plushie output

  • bin_name (String, nil) (defaults to: nil)

    override binary name

Returns:

  • (String)

    path to the installed binary

Raises:



105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
# File 'lib/plushie/widget/native_build.rb', line 105

def build_with_widgets(widgets, release: false, verbose: false, bin_name: nil, **_)
  bin_name ||= if widgets.empty?
    "plushie-renderer"
  else
    ENV["PLUSHIE_BUILD_NAME"] || Plushie.configuration.build_name
  end

  crate_paths = resolve_crate_paths(widgets)
  verify_widget_metadata!(crate_paths)

  scratch = File.expand_path(SCRATCH_DIR)
  FileUtils.mkdir_p(scratch)
  write_virtual_manifest(scratch, bin_name, crate_paths)

  puts "Widgets: #{widgets.map(&:name).join(", ")}" if widgets.any?

  invoke_cargo_plushie(scratch, release: release, verbose: verbose)

  binary_src = locate_built_binary(scratch, bin_name, release)
  unless File.exist?(binary_src)
    raise Error, "Build succeeded but binary not found at #{binary_src}"
  end

  install_binary(binary_src)
end

.clean!Object

Remove the build workspace and compiled artifacts.



321
322
323
324
325
326
327
328
329
330
# File 'lib/plushie/widget/native_build.rb', line 321

def clean!
  removed = false
  [File.join("_build", "plushie"), SCRATCH_DIR].each do |dir|
    next unless File.directory?(dir)
    FileUtils.rm_rf(dir)
    puts "Removed #{dir}"
    removed = true
  end
  puts "Nothing to clean" unless removed
end

.configured_widgetsArray<Class>

Returns native widget classes from configuration.

Reads from (in priority order):

  1. Plushie.configuration.widgets (set via Plushie.configure block)
  2. PLUSHIE_WIDGETS env var (comma-separated class names, for CI)

Non-native widgets in the list are skipped with a warning.

Returns:

  • (Array<Class>)

    native widget classes



38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
# File 'lib/plushie/widget/native_build.rb', line 38

def configured_widgets
  from_config = Plushie.configuration.widgets
  return filter_native(from_config) if from_config.is_a?(Array) && from_config.any?

  env = ENV["PLUSHIE_WIDGETS"] || ENV["PLUSHIE_EXTENSIONS"]
  return [] unless env && !env.strip.empty?

  names = env.split(",").map(&:strip).reject(&:empty?)
  classes = names.map do |name|
    Object.const_get(name)
  rescue NameError
    raise Error, "Widget class '#{name}' specified in PLUSHIE_WIDGETS could not be found. " \
      "Ensure the class is defined and the file is required before running the build."
  end
  filter_native(classes)
end

.filter_native(classes) ⇒ Array<Class>

Filter to native-only widgets, skipping non-native with a warning.

Parameters:

  • classes (Array<Class>)

Returns:

  • (Array<Class>)


59
60
61
62
63
64
65
66
67
68
69
# File 'lib/plushie/widget/native_build.rb', line 59

def filter_native(classes)
  classes.each { |mod| mod.finalize! if mod.respond_to?(:finalize!) }
  classes.select do |mod|
    if mod.respond_to?(:native?) && mod.native?
      true
    else
      warn "plushie: skipping #{mod.name} (not a native_widget)"
      false
    end
  end
end

.find_built_binary(target_root, bin_name, profile, ext) ⇒ String?

Search only within cargo's target root for a profile-matching binary if cargo-plushie's generated workspace layout changes.

Parameters:

  • target_root (String)
  • bin_name (String)
  • profile (String)
  • ext (String)

Returns:

  • (String, nil)

Raises:



280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
# File 'lib/plushie/widget/native_build.rb', line 280

def find_built_binary(target_root, bin_name, profile, ext)
  return nil unless File.directory?(target_root)

  expected_name = "#{bin_name}#{ext}"
  matches = []
  Find.find(target_root) do |path|
    next unless File.file?(path)
    next unless File.basename(path) == expected_name
    next unless File.basename(File.dirname(path)) == profile

    matches << path
  end
  return nil if matches.empty?
  return matches.first if matches.length == 1

  raise Error, "multiple built binaries named #{expected_name.inspect} found under #{target_root.inspect}"
end

.install_binary(src) ⇒ String

Install the built binary under +_build/plushie/bin/+ using the platform-suffixed name so the renderer discovery chain finds it.

Parameters:

  • src (String)

Returns:

  • (String)


303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
# File 'lib/plushie/widget/native_build.rb', line 303

def install_binary(src)
  bin_file = ENV["PLUSHIE_BIN_FILE"] || Plushie.configuration.bin_file
  if bin_file
    dest = bin_file
    FileUtils.mkdir_p(File.dirname(dest))
  else
    dest_dir = File.join("_build", "plushie", "bin")
    FileUtils.mkdir_p(dest_dir)
    dest = File.join(dest_dir, Plushie::Binary.binary_name)
  end
  FileUtils.cp(src, dest)
  File.chmod(0o755, dest)

  puts "Installed to #{dest}"
  dest
end

.invoke_cargo_plushie(scratch, release:, verbose:)

This method returns an undefined value.

Shell out to cargo-plushie, passing the scratch manifest path.

Parameters:

  • scratch (String)
  • release (Boolean)
  • verbose (Boolean)

Raises:



238
239
240
241
242
243
244
245
246
247
248
249
# File 'lib/plushie/widget/native_build.rb', line 238

def invoke_cargo_plushie(scratch, release:, verbose:)
  program, preamble = Plushie::CargoPlushie.resolve
  args = preamble + ["build", "--manifest-path", manifest_path(scratch)]
  args << "--release" if release
  args << "--verbose" if verbose

  puts "Running: #{program} #{args.join(" ")}" if verbose
  ok = system(program, *args)
  return if ok

  raise Error, "cargo plushie build failed"
end

.locate_built_binary(scratch, bin_name, release) ⇒ String

Find the binary cargo-plushie produced. cargo-plushie writes the generated renderer workspace under +/plushie-renderer/+ and builds into that workspace's own +target/+. With the app manifest at +/Cargo.toml+, ++ is +/target/+ unless +CARGO_TARGET_DIR+ is set.

Parameters:

  • scratch (String)
  • bin_name (String)
  • release (Boolean)

Returns:

  • (String)


261
262
263
264
265
266
267
268
269
270
# File 'lib/plushie/widget/native_build.rb', line 261

def locate_built_binary(scratch, bin_name, release)
  target_root = ENV["CARGO_TARGET_DIR"] || File.join(scratch, "target")
  profile = release ? "release" : "debug"
  ext = Gem.win_platform? ? ".exe" : ""
  preferred = File.join(target_root, "plushie-renderer", "target", profile, "#{bin_name}#{ext}")
  return preferred if File.exist?(preferred)

  discovered = find_built_binary(target_root, bin_name, profile, ext)
  discovered || preferred
end

.manifest_path(scratch) ⇒ String

Absolute path to the virtual app manifest we hand cargo-plushie.

Parameters:

  • scratch (String)

Returns:

  • (String)


135
136
137
# File 'lib/plushie/widget/native_build.rb', line 135

def manifest_path(scratch)
  File.join(scratch, "Cargo.toml")
end

.resolve_crate_paths(widgets, base_dir: Dir.pwd) ⇒ Hash{Class => String}

Resolve crate paths with directory traversal security check.

Host SDKs are allowed to declare widget crates by relative path. We refuse paths that escape the project root so a malicious widget declaration can't point cargo at arbitrary filesystem locations.

Parameters:

  • widgets (Array<Class>)

    widget classes

  • base_dir (String) (defaults to: Dir.pwd)

    project root directory

Returns:

  • (Hash{Class => String})

    widget class to resolved absolute path



81
82
83
84
85
86
87
88
89
90
91
92
93
94
# File 'lib/plushie/widget/native_build.rb', line 81

def resolve_crate_paths(widgets, base_dir: Dir.pwd)
  widgets.each_with_object({}) do |mod, paths|
    rel = mod.native_crate
    resolved = File.expand_path(File.join(base_dir, rel))
    allowed = File.expand_path(base_dir)

    unless resolved.start_with?("#{allowed}/") || resolved == allowed
      raise Error, "Widget #{mod.name} native_crate path #{rel.inspect} " \
        "resolves to #{resolved}, which is outside the allowed directory #{allowed}"
    end

    paths[mod] = resolved
  end
end

.verify_widget_metadata!(crate_paths)

This method returns an undefined value.

Verify each widget crate declares the metadata table cargo-plushie looks for. cargo-plushie will also complain, but failing here produces a message that references the Ruby widget class name (far more useful than a cargo_metadata dump).

Parameters:

  • crate_paths (Hash{Class => String})


146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
# File 'lib/plushie/widget/native_build.rb', line 146

def verify_widget_metadata!(crate_paths)
  crate_paths.each do |mod, crate_path|
    unless File.directory?(crate_path)
      raise Error,
        "widget #{mod.name} crate directory not found at #{crate_path}. " \
        "Check the rust_crate path configuration."
    end

    toml_path = File.join(crate_path, "Cargo.toml")
    unless File.exist?(toml_path)
      raise Error,
        "widget #{mod.name} crate at #{crate_path} is missing Cargo.toml"
    end

    content = File.read(toml_path)
    next if widget_metadata?(content)

    raise Error,
      "widget #{mod.name} crate #{crate_path} is missing " \
      "[package.metadata.plushie.widget] { type_name, constructor }. " \
      "Add that table so cargo plushie build can discover the widget."
  end
end

.widget_metadata?(content) ⇒ Boolean

Cheap TOML sniffer that avoids pulling in a parser. Looks for the +[package.metadata.plushie.widget]+ header plus the two required keys somewhere after it.

Parameters:

  • content (String)

Returns:

  • (Boolean)


176
177
178
179
180
181
182
183
184
185
# File 'lib/plushie/widget/native_build.rb', line 176

def widget_metadata?(content)
  header = /^\[package\.metadata\.plushie\.widget\]/.match(content)
  return false unless header

  rest = content.byteslice(header.end(0), content.bytesize) || ""
  # Stop at the next section header so we only consider keys
  # inside the widget table.
  body = rest.split(/^\[/, 2).first || ""
  body.match?(/^\s*type_name\s*=/) && body.match?(/^\s*constructor\s*=/)
end

.write_virtual_manifest(scratch, bin_name, crate_paths)

This method returns an undefined value.

Write the virtual app Cargo.toml. The app package has no source code of its own; it exists so cargo-plushie's cargo metadata walk finds the widget crates as direct dependencies and the [package.metadata.plushie] table supplies the binary name.

Parameters:

  • scratch (String)
  • bin_name (String)
  • crate_paths (Hash{Class => String})


196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
# File 'lib/plushie/widget/native_build.rb', line 196

def write_virtual_manifest(scratch, bin_name, crate_paths)
  package_name = bin_name.tr("-", "_")

  dep_lines = crate_paths.values.map do |path|
    name = File.basename(path)
    %(#{name} = { path = "#{path}" })
  end

  content = <<~TOML
    # Auto-generated by rake plushie:build. Do not edit.
    #
    # This virtual manifest exists only so `cargo plushie build`
    # can discover the native widget crates via cargo_metadata.

    [package]
    name = "#{package_name}"
    version = "0.0.0"
    edition = "2024"
    publish = false

    [lib]
    path = "src/lib.rs"

    [dependencies]
    #{dep_lines.join("\n")}

    [package.metadata.plushie]
    binary_name = "#{bin_name}"
  TOML

  FileUtils.mkdir_p(File.join(scratch, "src"))
  File.write(File.join(scratch, "src", "lib.rs"), "")
  File.write(manifest_path(scratch), content)
end