From 0e4f38d437e026e824ca9d051511ba48444e9e22 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 10:04:31 +0000 Subject: [PATCH 1/3] fix(widget): emit shared SwiftUI FFI helpers once per bundle (#5069) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A perry/widget bundle with ≥2 Widget({...}) declarations failed under `swiftc *.swift`: the shared perry-runtime FFI helpers and the @_cdecl("perry_widget_shared_storage_get") bridge were emitted once per widget into each {Name}Glue.swift. Since a bundle compiles as one Swift module, `private` made them inaccessible from the sibling {Name}.swift call sites, and non-private produced N redeclarations across N glue files. Emit the shared FFI block + @_cdecl bridge once per bundle, internal, into PerryWidgetRuntime.swift (emit_shared_runtime). Per-widget glue keeps only the widget-unique @_silgen_name provider import. The ios/watchos widget drivers write the shared file once after the per-widget loop, gated on any widget using a native provider or shared storage, with the bundle-wide app group resolved from the first widget that configures one. https://claude.ai/code/session_01JJfeShA3WCh9HMiEBmqq39 --- crates/perry-codegen-swiftui/src/emit.rs | 128 ++++++++++++++----- crates/perry-codegen-swiftui/src/lib.rs | 23 +++- crates/perry/src/commands/compile/targets.rs | 28 ++++ 3 files changed, 142 insertions(+), 37 deletions(-) diff --git a/crates/perry-codegen-swiftui/src/emit.rs b/crates/perry-codegen-swiftui/src/emit.rs index 0119bf4a8a..d7dc0e6cc6 100644 --- a/crates/perry-codegen-swiftui/src/emit.rs +++ b/crates/perry-codegen-swiftui/src/emit.rs @@ -650,46 +650,55 @@ 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) -> 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" + "func perry_nanbox_string(_ s: UnsafePointer) -> 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" ) .unwrap(); + writeln!(out).unwrap(); + writeln!(out, "func perry_nanbox_string(_ s: String) -> Int64 {{").unwrap(); writeln!( out, " return s.withCString {{ perry_nanbox_string($0) }}" @@ -697,24 +706,14 @@ pub fn emit_glue(widget: &WidgetDecl, name: &str) -> String { .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" @@ -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 } @@ -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) -> 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 diff --git a/crates/perry-codegen-swiftui/src/lib.rs b/crates/perry-codegen-swiftui/src/lib.rs index 52fe779967..3c972377ae 100644 --- a/crates/perry-codegen-swiftui/src/lib.rs +++ b/crates/perry-codegen-swiftui/src/lib.rs @@ -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 { let safe_name = sanitize_kind(&widget.kind); let bundle_id = format!("{}.widget", app_bundle_id); @@ -71,8 +85,11 @@ pub fn compile_widget(widget: &WidgetDecl, app_bundle_id: &str) -> Result Date: Sun, 14 Jun 2026 10:28:19 +0000 Subject: [PATCH 2/3] refactor(widget): dedupe shared-runtime emission + warn on conflicting app groups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address CodeRabbit review on #5119: the per-bundle PerryWidgetRuntime.swift emission was duplicated verbatim in the ios-widget and watchos-widget drivers. Extract it into write_shared_widget_runtime(), and — since the @_cdecl shared-storage bridge can bind only one app group per bundle — warn (non-fatal) when widgets declare differing app groups instead of silently routing them all through the first widget's suite. https://claude.ai/code/session_01JJfeShA3WCh9HMiEBmqq39 --- crates/perry/src/commands/compile/targets.rs | 82 +++++++++++++------- 1 file changed, 56 insertions(+), 26 deletions(-) diff --git a/crates/perry/src/commands/compile/targets.rs b/crates/perry/src/commands/compile/targets.rs index fa7a52007e..266de1ec25 100644 --- a/crates/perry/src/commands/compile/targets.rs +++ b/crates/perry/src/commands/compile/targets.rs @@ -290,20 +290,8 @@ 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). It's - // only needed when a widget calls a native provider or uses shared storage; - // a fully static bundle never touches the perry-runtime helpers. The app - // group is bundle-wide, so the first widget that configures one wins. - if widgets - .iter() - .any(|w| w.provider_func_name.is_some() || w.app_group.is_some()) - { - let app_group = widgets.iter().find_map(|w| w.app_group.as_deref()); - 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)); - } + // Emit the shared per-bundle runtime FFI block exactly once (#5069). + write_shared_widget_runtime(&output_dir, &widgets, &mut all_swift_files, format)?; // Report results let total_size: usize = all_swift_files.iter().map(|(_, s)| s.len()).sum(); @@ -402,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)>, + format: OutputFormat, +) -> 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()); + + if matches!(format, OutputFormat::Text) { + 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 `/.appex/` /// containing the compiled mach-O binary plus a resolved Info.plist (Xcode @@ -559,18 +599,8 @@ 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); see the - // ios-widget path for the rationale. - if widgets - .iter() - .any(|w| w.provider_func_name.is_some() || w.app_group.is_some()) - { - let app_group = widgets.iter().find_map(|w| w.app_group.as_deref()); - 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)); - } + // Emit the shared per-bundle runtime FFI block exactly once (#5069). + write_shared_widget_runtime(&output_dir, &widgets, &mut all_swift_files, format)?; let total_size: usize = all_swift_files.iter().map(|(_, s)| s.len()).sum(); let is_simulator = args.target.as_deref() == Some("watchos-widget-simulator"); From 6ee593eec0bd4d652d891a5d68ee62d54c4cd626 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 12:19:46 +0000 Subject: [PATCH 3/3] fix(widget): emit conflicting-app-group warning in JSON mode too Address CodeRabbit review on #5119: the warning used eprintln! (stderr), so the OutputFormat::Text guard only suppressed a genuine misconfiguration signal under --format json without protecting the JSON on stdout. Drop the guard (and the now -unused format parameter) so the warning surfaces regardless of output format. https://claude.ai/code/session_01JJfeShA3WCh9HMiEBmqq39 --- crates/perry/src/commands/compile/targets.rs | 40 ++++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/crates/perry/src/commands/compile/targets.rs b/crates/perry/src/commands/compile/targets.rs index 266de1ec25..1a717940ca 100644 --- a/crates/perry/src/commands/compile/targets.rs +++ b/crates/perry/src/commands/compile/targets.rs @@ -291,7 +291,7 @@ pub(super) fn compile_for_ios_widget( } // Emit the shared per-bundle runtime FFI block exactly once (#5069). - write_shared_widget_runtime(&output_dir, &widgets, &mut all_swift_files, format)?; + 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(); @@ -404,7 +404,6 @@ fn write_shared_widget_runtime( output_dir: &Path, widgets: &[&perry_hir::ir::WidgetDecl], all_swift_files: &mut Vec<(String, String)>, - format: OutputFormat, ) -> Result<()> { if !widgets .iter() @@ -415,23 +414,24 @@ fn write_shared_widget_runtime( let app_group = widgets.iter().find_map(|w| w.app_group.as_deref()); - if matches!(format, OutputFormat::Text) { - 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(", ") - ); - } + // 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(", ") + ); } } @@ -600,7 +600,7 @@ pub(super) fn compile_for_watchos_widget( } // Emit the shared per-bundle runtime FFI block exactly once (#5069). - write_shared_widget_runtime(&output_dir, &widgets, &mut all_swift_files, format)?; + 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");