Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 94 additions & 34 deletions crates/perry-codegen-swiftui/src/emit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -650,71 +650,70 @@ pub fn emit_widget_bundle(widget: &WidgetDecl, name: &str) -> String {
out
}

/// Emit native provider bridge (glue code for calling LLVM-compiled provider)
pub fn emit_glue(widget: &WidgetDecl, name: &str) -> String {
/// Emit the shared per-bundle runtime FFI block (`PerryWidgetRuntime.swift`).
///
/// This block — the `@_silgen_name` perry-runtime imports, the two
/// `perry_nanbox_string` overloads, the `perry_get_string` wrapper, and the
/// `@_cdecl("perry_widget_shared_storage_get")` bridge — must be emitted
/// **once per bundle**, `internal`. A widget bundle is compiled as a single
/// Swift module (`swiftc *.swift`), so:
///
/// - `private` (the #1294 fix) scopes each declaration to its own glue file,
/// but the helpers are *called* from a different generated file
/// (`{Name}.swift`), so the call site can't see them → "inaccessible due to
/// 'private'" (#5069).
/// - non-`private` per-widget emits N copies across N glue files →
/// "redeclaration" (the original #1294 failure).
///
/// Emitting the shared block once, `internal`, satisfies both: a single
/// definition, visible module-wide. The `@_cdecl` shared-storage bridge
/// exports a fixed C symbol, so it likewise must appear exactly once per
/// bundle (≥2 widgets sharing an app group would otherwise duplicate it).
pub fn emit_shared_runtime(app_group: Option<&str>) -> String {
let mut out = String::new();

writeln!(out, "// Auto-generated native bridge — do not edit").unwrap();
writeln!(out, "import Foundation").unwrap();
writeln!(out).unwrap();

// Extern declarations for perry-runtime functions.
//
// #1294: `private` on the @_silgen_name FFI imports + the
// String-overload helper / get_string wrapper scopes each
// declaration to its enclosing file, so the bundle's `swiftc
// *.swift` invocation doesn't see N copies of the same symbol
// across N widget glue files. The C symbol name (`js_nanbox_string`
// / `js_get_string_pointer_unified`) is still shared at the
// linker level — `@_silgen_name` just maps a Swift name onto it.
writeln!(out, "// Perry runtime FFI").unwrap();
writeln!(out, "@_silgen_name(\"perry_runtime_widget_init\")").unwrap();
writeln!(out, "private func perry_runtime_widget_init()").unwrap();
writeln!(out).unwrap();
writeln!(out, "@_silgen_name(\"js_nanbox_string\")").unwrap();
writeln!(
out,
"private func perry_nanbox_string(_ s: UnsafePointer<CChar>) -> Int64"
"// Perry runtime FFI (shared across every widget in this bundle)"
)
.unwrap();
writeln!(out, "@_silgen_name(\"perry_runtime_widget_init\")").unwrap();
writeln!(out, "func perry_runtime_widget_init()").unwrap();
writeln!(out).unwrap();
writeln!(out, "@_silgen_name(\"js_get_string_pointer_unified\")").unwrap();
writeln!(out, "@_silgen_name(\"js_nanbox_string\")").unwrap();
writeln!(
out,
"private func perry_get_string_ptr(_ val: Int64) -> UnsafePointer<CChar>"
"func perry_nanbox_string(_ s: UnsafePointer<CChar>) -> Int64"
)
.unwrap();
writeln!(out).unwrap();
writeln!(out, "@_silgen_name(\"js_get_string_pointer_unified\")").unwrap();
writeln!(
out,
"private func perry_nanbox_string(_ s: String) -> Int64 {{"
"func perry_get_string_ptr(_ val: Int64) -> UnsafePointer<CChar>"
)
.unwrap();
writeln!(out).unwrap();
writeln!(out, "func perry_nanbox_string(_ s: String) -> Int64 {{").unwrap();
writeln!(
out,
" return s.withCString {{ perry_nanbox_string($0) }}"
)
.unwrap();
writeln!(out, "}}").unwrap();
writeln!(out).unwrap();
writeln!(
out,
"private func perry_get_string(_ val: Int64) -> String {{"
)
.unwrap();
writeln!(out, "func perry_get_string(_ val: Int64) -> String {{").unwrap();
writeln!(out, " return String(cString: perry_get_string_ptr(val))").unwrap();
writeln!(out, "}}").unwrap();
writeln!(out).unwrap();

// Provider function extern
if let Some(ref func_name) = widget.provider_func_name {
writeln!(out, "@_silgen_name(\"{}\")", func_name).unwrap();
writeln!(out, "func {}(_ configJson: Int64) -> Int64", func_name).unwrap();
// sharedStorage bridge — emitted once per bundle because @_cdecl exports a
// fixed C symbol (`perry_widget_shared_storage_get`).
if let Some(app_group) = app_group {
writeln!(out).unwrap();
}

// sharedStorage bridge
if let Some(ref app_group) = widget.app_group {
writeln!(
out,
"// Shared storage bridge — called from native provider code"
Expand All @@ -737,6 +736,29 @@ pub fn emit_glue(widget: &WidgetDecl, name: &str) -> String {
writeln!(out, "}}").unwrap();
}

out
}

/// Emit the per-widget native bridge: just the widget-unique provider extern.
///
/// The shared runtime FFI (`perry_runtime_widget_init`, `perry_nanbox_string`,
/// `perry_get_string`, …) and the shared-storage `@_cdecl` bridge live in
/// `PerryWidgetRuntime.swift` (see [`emit_shared_runtime`]); this file declares
/// only the `@_silgen_name` import of *this* widget's LLVM-compiled provider,
/// which is uniquely named per widget and so is safe to emit per-file.
pub fn emit_glue(widget: &WidgetDecl, name: &str) -> String {
let mut out = String::new();

writeln!(out, "// Auto-generated native bridge — do not edit").unwrap();
writeln!(out, "import Foundation").unwrap();
writeln!(out).unwrap();

// Provider function extern (unique per widget).
if let Some(ref func_name) = widget.provider_func_name {
writeln!(out, "@_silgen_name(\"{}\")", func_name).unwrap();
writeln!(out, "func {}(_ configJson: Int64) -> Int64", func_name).unwrap();
}

let _ = name; // suppress unused warning
out
}
Expand Down Expand Up @@ -1518,6 +1540,44 @@ mod tests {
assert!(!view.contains("Text(\"\")"));
}

#[test]
fn shared_runtime_emits_internal_ffi_once() {
// #5069: the perry-runtime FFI helpers must be `internal` (not
// `private`) so they're callable from the sibling `{Name}.swift`
// files in the same swiftc module.
let runtime = emit_shared_runtime(None);
assert!(runtime.contains("func perry_runtime_widget_init()"));
assert!(runtime.contains("func perry_nanbox_string(_ s: UnsafePointer<CChar>) -> Int64"));
assert!(runtime.contains("func perry_get_string(_ val: Int64) -> String"));
// No `private` — that's exactly the inaccessibility bug.
assert!(!runtime.contains("private func"));
// The shared-storage @_cdecl bridge is gated on an app group.
assert!(!runtime.contains("@_cdecl"));
}

#[test]
fn shared_runtime_includes_storage_bridge_with_app_group() {
let runtime = emit_shared_runtime(Some("group.com.example.app"));
assert!(runtime.contains("@_cdecl(\"perry_widget_shared_storage_get\")"));
assert!(runtime.contains("UserDefaults(suiteName: \"group.com.example.app\")"));
}

#[test]
fn per_widget_glue_only_declares_provider_extern() {
// The per-widget glue must NOT re-emit the shared FFI helpers (that's
// what caused the duplicate-symbol / inaccessibility regression). It
// carries only this widget's unique provider import.
let mut widget = make_widget("com.test.Glue", vec![], vec![]);
widget.provider_func_name = Some("__widget_provider_quickstats".to_string());
let glue = emit_glue(&widget, "Glue");
assert!(glue.contains("@_silgen_name(\"__widget_provider_quickstats\")"));
assert!(glue.contains("func __widget_provider_quickstats(_ configJson: Int64) -> Int64"));
// Shared helpers and the @_cdecl bridge live in PerryWidgetRuntime.swift.
assert!(!glue.contains("perry_runtime_widget_init"));
assert!(!glue.contains("perry_get_string"));
assert!(!glue.contains("@_cdecl"));
}

#[test]
fn template_with_formatted_hole_interpolates_through_helper() {
// ``Total: ${entry.totalClicks.toFixed(2)}`` should land as
Expand Down
23 changes: 20 additions & 3 deletions crates/perry-codegen-swiftui/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,27 @@ pub struct WidgetBundle {
pub bundle_id: String,
}

/// Emit the shared per-bundle runtime FFI block (`PerryWidgetRuntime.swift`).
///
/// A widget bundle is compiled as one Swift module, so the perry-runtime FFI
/// helpers and the `@_cdecl` shared-storage bridge must be defined exactly once,
/// `internal`. The driver calls this a single time per bundle and writes the
/// result alongside the per-widget files (see #5069). Pass the bundle's app
/// group (if any widget configures one) to include the shared-storage bridge.
pub fn emit_shared_runtime(app_group: Option<&str>) -> String {
emit::emit_shared_runtime(app_group)
}

/// Compile a WidgetDecl to a complete WidgetKit extension bundle.
///
/// Generates:
/// - {Name}.swift: Entry struct, SwiftUI View, TimelineProvider, @main entry point
/// - {Name}Glue.swift: Native FFI bridge (if provider_func_name is set)
/// - {Name}Glue.swift: Native provider extern (if provider_func_name is set)
/// - {Name}Intent.swift: AppIntent configuration (if config_params is non-empty)
/// - Info.plist
///
/// The shared runtime FFI (`PerryWidgetRuntime.swift`) is emitted once per
/// bundle by the driver via [`emit_shared_runtime`], not per widget.
pub fn compile_widget(widget: &WidgetDecl, app_bundle_id: &str) -> Result<WidgetBundle> {
let safe_name = sanitize_kind(&widget.kind);
let bundle_id = format!("{}.widget", app_bundle_id);
Expand Down Expand Up @@ -71,8 +85,11 @@ pub fn compile_widget(widget: &WidgetDecl, app_bundle_id: &str) -> Result<Widget
swift_files.push((format!("{}Intent.swift", safe_name), intent_file));
}

// Generate native bridge glue if provider function exists
if widget.provider_func_name.is_some() || widget.app_group.is_some() {
// Generate the per-widget native bridge (the widget-unique provider extern)
// when this widget calls into an LLVM-compiled provider. The shared runtime
// FFI + shared-storage bridge are emitted once per bundle by the driver via
// `emit_shared_runtime` (see #5069), not here.
if widget.provider_func_name.is_some() {
let glue_source = emit::emit_glue(widget, &safe_name);
swift_files.push((format!("{}Glue.swift", safe_name), glue_source));
}
Expand Down
58 changes: 58 additions & 0 deletions crates/perry/src/commands/compile/targets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,9 @@ pub(super) fn compile_for_ios_widget(
bundle_info_plist = Some(bundle.info_plist.clone());
}

// Emit the shared per-bundle runtime FFI block exactly once (#5069).
write_shared_widget_runtime(&output_dir, &widgets, &mut all_swift_files)?;

// Report results
let total_size: usize = all_swift_files.iter().map(|(_, s)| s.len()).sum();
let is_simulator = args.target.as_deref() == Some("ios-widget-simulator");
Expand Down Expand Up @@ -387,6 +390,58 @@ pub(super) fn compile_for_ios_widget(
})
}

/// Emit the shared per-bundle widget runtime (`PerryWidgetRuntime.swift`) once,
/// appending it to `all_swift_files` so swiftc picks it up (#5069).
///
/// Only needed when a widget calls a native provider or uses shared storage; a
/// fully static bundle never touches the perry-runtime helpers. The `@_cdecl`
/// shared-storage bridge exports a single fixed C symbol per bundle, so it can
/// bind only one app group — the first widget that declares one wins. If other
/// widgets declare a *different* app group we warn rather than silently routing
/// them all through the first suite (validation is otherwise out of scope; the
/// common case is a single bundle-wide app group).
fn write_shared_widget_runtime(
output_dir: &Path,
widgets: &[&perry_hir::ir::WidgetDecl],
all_swift_files: &mut Vec<(String, String)>,
) -> Result<()> {
if !widgets
.iter()
.any(|w| w.provider_func_name.is_some() || w.app_group.is_some())
{
return Ok(());
}

let app_group = widgets.iter().find_map(|w| w.app_group.as_deref());

// Warn regardless of output format: this goes to stderr (eprintln!), so it
// never corrupts the JSON written to stdout, and the misconfiguration is
// worth surfacing even when scripting against `--format json`.
if let Some(group) = app_group {
let mut conflicting: Vec<&str> = widgets
.iter()
.filter_map(|w| w.app_group.as_deref())
.filter(|g| *g != group)
.collect();
if !conflicting.is_empty() {
conflicting.sort_unstable();
conflicting.dedup();
eprintln!(
"Warning: widgets in this bundle declare differing app groups; \
the shared-storage bridge will use \"{}\" (ignoring: {}).",
group,
conflicting.join(", ")
);
}
}

let runtime_source = perry_codegen_swiftui::emit_shared_runtime(app_group);
let runtime_path = output_dir.join("PerryWidgetRuntime.swift");
fs::write(&runtime_path, &runtime_source)?;
all_swift_files.push(("PerryWidgetRuntime.swift".to_string(), runtime_source));
Ok(())
}

/// Build a WidgetKit .appex extension by invoking `xcrun swiftc` on the
/// generated SwiftUI sources. Produces `<output_dir>/<extension_name>.appex/`
/// containing the compiled mach-O binary plus a resolved Info.plist (Xcode
Expand Down Expand Up @@ -544,6 +599,9 @@ pub(super) fn compile_for_watchos_widget(
bundle_info_plist = Some(bundle.info_plist.clone());
}

// Emit the shared per-bundle runtime FFI block exactly once (#5069).
write_shared_widget_runtime(&output_dir, &widgets, &mut all_swift_files)?;

let total_size: usize = all_swift_files.iter().map(|(_, s)| s.len()).sum();
let is_simulator = args.target.as_deref() == Some("watchos-widget-simulator");
let sdk = if is_simulator {
Expand Down