From dd7921b095ccce5ddfa97b85969041cc8146b8cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 14 Jun 2026 22:17:22 +0200 Subject: [PATCH 1/2] fix(widget): decode array-of-object entryFields into Codable, not String iOS-widget SwiftUI codegen emitted the entry struct field for an array-of-objects `entryFields` member with the correct nested-Codable type (`[Item]`), but the TimelineProvider decode site cast the JSON value to `String` (`entryDict["x"] as? String ?? ""`), so the generated Swift failed to compile. Factor the per-field decode into `entry_field_decode` (shared by both the native and AppIntent provider paths) and round-trip array/object/optional fields through JSONSerialization + JSONDecoder into the nested Codable struct. Scalars and scalar arrays keep their direct `as?` casts. This also unblocks `ForEach(entry., ...)` in render bodies. Fixes #5070 --- crates/perry-codegen-swiftui/src/emit.rs | 197 +++++++++++++++++------ 1 file changed, 145 insertions(+), 52 deletions(-) diff --git a/crates/perry-codegen-swiftui/src/emit.rs b/crates/perry-codegen-swiftui/src/emit.rs index d7dc0e6cc6..f5143265e0 100644 --- a/crates/perry-codegen-swiftui/src/emit.rs +++ b/crates/perry-codegen-swiftui/src/emit.rs @@ -96,6 +96,103 @@ fn swift_type_for_field( } } +/// Emit the `, : ` fragment that pulls one entry field out of +/// the provider's JSON `entryDict` and coerces it to the Swift type used by the +/// generated `Entry` struct. +/// +/// Scalars use a direct `as?` cast. Arrays of objects (and any other JSON-backed +/// nested shape) are round-tripped through `JSONSerialization`/`JSONDecoder` into +/// the nested `Codable` struct(s) emitted by `emit_nested_structs`, so that +/// `[Item]` actually decodes instead of being cast to `String` +/// (see issue #5070). +fn entry_field_decode(parent_name: &str, field_name: &str, field_type: &WidgetFieldType) -> String { + match field_type { + WidgetFieldType::String => { + format!( + ", {f}: entryDict[\"{f}\"] as? String ?? \"\"", + f = field_name + ) + } + WidgetFieldType::Number => { + format!(", {f}: entryDict[\"{f}\"] as? Double ?? 0", f = field_name) + } + WidgetFieldType::Boolean => { + format!( + ", {f}: entryDict[\"{f}\"] as? Bool ?? false", + f = field_name + ) + } + WidgetFieldType::Array(inner) => match inner.as_ref() { + // Arrays of scalars can be cast directly. + WidgetFieldType::String => { + format!( + ", {f}: entryDict[\"{f}\"] as? [String] ?? []", + f = field_name + ) + } + WidgetFieldType::Number => { + format!( + ", {f}: entryDict[\"{f}\"] as? [Double] ?? []", + f = field_name + ) + } + WidgetFieldType::Boolean => { + format!(", {f}: entryDict[\"{f}\"] as? [Bool] ?? []", f = field_name) + } + // Arrays of objects (and nested arrays) decode through Codable. + _ => { + let swift_type = swift_type_for_field(parent_name, field_name, field_type); + format!( + ", {f}: {decode} ?? []", + f = field_name, + decode = json_decode_expr(field_name, &swift_type) + ) + } + }, + WidgetFieldType::Object(_) => { + // A bare (non-array) object entry field maps to a non-optional struct + // that has no zero-value, so we decode and force-unwrap. This mirrors + // the placeholder path, which likewise cannot synthesize a default. + let swift_type = swift_type_for_field(parent_name, field_name, field_type); + format!( + ", {f}: {decode}!", + f = field_name, + decode = json_decode_expr(field_name, &swift_type) + ) + } + WidgetFieldType::Optional(inner) => match inner.as_ref() { + WidgetFieldType::String => { + format!(", {f}: entryDict[\"{f}\"] as? String", f = field_name) + } + WidgetFieldType::Number => { + format!(", {f}: entryDict[\"{f}\"] as? Double", f = field_name) + } + WidgetFieldType::Boolean => { + format!(", {f}: entryDict[\"{f}\"] as? Bool", f = field_name) + } + _ => { + let swift_type = swift_type_for_field(parent_name, field_name, inner); + format!( + ", {f}: {decode}", + f = field_name, + decode = json_decode_expr(field_name, &swift_type) + ) + } + }, + } +} + +/// Build a Swift expression that decodes `entryDict[""]` into `swift_type` +/// via `JSONSerialization` + `JSONDecoder`. The result is an optional (`nil` on +/// any failure); callers append `?? ` for non-optional targets. +fn json_decode_expr(key: &str, swift_type: &str) -> String { + format!( + "(entryDict[\"{key}\"]).flatMap {{ try? JSONSerialization.data(withJSONObject: $0) }}.flatMap {{ try? JSONDecoder().decode({ty}.self, from: $0) }}", + key = key, + ty = swift_type + ) +} + /// Capitalize the first letter of a string fn capitalize(s: &str) -> String { let mut chars = s.chars(); @@ -256,32 +353,7 @@ fn emit_native_timeline_provider(widget: &WidgetDecl, name: &str) -> String { ) .unwrap(); for (field_name, field_type) in &widget.entry_fields { - match field_type { - WidgetFieldType::String => write!( - out, - ", {}: entryDict[\"{}\"] as? String ?? \"\"", - field_name, field_name - ) - .unwrap(), - WidgetFieldType::Number => write!( - out, - ", {}: entryDict[\"{}\"] as? Double ?? 0", - field_name, field_name - ) - .unwrap(), - WidgetFieldType::Boolean => write!( - out, - ", {}: entryDict[\"{}\"] as? Bool ?? false", - field_name, field_name - ) - .unwrap(), - _ => write!( - out, - ", {}: entryDict[\"{}\"] as? String ?? \"\"", - field_name, field_name - ) - .unwrap(), - } + write!(out, "{}", entry_field_decode(name, field_name, field_type)).unwrap(); } writeln!(out, ")").unwrap(); writeln!(out, " timelineEntries.append(entry)").unwrap(); @@ -387,32 +459,7 @@ fn emit_app_intent_timeline_provider( ) .unwrap(); for (field_name, field_type) in &widget.entry_fields { - match field_type { - WidgetFieldType::String => write!( - out, - ", {}: entryDict[\"{}\"] as? String ?? \"\"", - field_name, field_name - ) - .unwrap(), - WidgetFieldType::Number => write!( - out, - ", {}: entryDict[\"{}\"] as? Double ?? 0", - field_name, field_name - ) - .unwrap(), - WidgetFieldType::Boolean => write!( - out, - ", {}: entryDict[\"{}\"] as? Bool ?? false", - field_name, field_name - ) - .unwrap(), - _ => write!( - out, - ", {}: entryDict[\"{}\"] as? String ?? \"\"", - field_name, field_name - ) - .unwrap(), - } + write!(out, "{}", entry_field_decode(name, field_name, field_type)).unwrap(); } writeln!(out, ")").unwrap(); writeln!(out, " timelineEntries.append(entry)").unwrap(); @@ -1320,6 +1367,52 @@ mod tests { assert!(s.contains("let error: String?")); } + #[test] + fn test_timeline_decode_array_of_objects() { + // Regression test for #5070: an array-of-objects entry field must decode + // the JSON array into the nested Codable struct, not cast it to String. + let mut widget = make_widget( + "com.test.TopSites", + vec![ + ( + "sites".to_string(), + WidgetFieldType::Array(Box::new(WidgetFieldType::Object(vec![ + ("siteUrl".to_string(), WidgetFieldType::String), + ("clicks".to_string(), WidgetFieldType::String), + ]))), + ), + ("totalClicks".to_string(), WidgetFieldType::Number), + ( + "tags".to_string(), + WidgetFieldType::Array(Box::new(WidgetFieldType::String)), + ), + ], + vec![], + ); + widget.provider_func_name = Some("topSitesProvider".to_string()); + + let provider = emit_timeline_provider(&widget, "TopSites"); + + // The decode site must NOT cast the object array to String. + assert!( + !provider.contains("sites: entryDict[\"sites\"] as? String"), + "array-of-objects field must not be decoded as String:\n{provider}" + ); + // It must decode into the nested Codable struct type. + assert!( + provider.contains("JSONDecoder().decode([TopSitesSitesItem].self"), + "expected JSONDecoder decode into [TopSitesSitesItem]:\n{provider}" + ); + assert!( + provider.contains("sites: (entryDict[\"sites\"])"), + "expected sites to be assigned from entryDict[\"sites\"]:\n{provider}" + ); + // Arrays of scalars cast directly. + assert!(provider.contains("tags: entryDict[\"tags\"] as? [String] ?? []")); + // Scalars are unchanged. + assert!(provider.contains("totalClicks: entryDict[\"totalClicks\"] as? Double ?? 0")); + } + #[test] fn test_conditional() { let widget = make_widget( From edb99c2a0d9b5d0a468af442a2f5e3ac6918dde7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 14 Jun 2026 22:21:04 +0200 Subject: [PATCH 2/2] style: rustfmt pass on link/mod.rs + platform_cmd.rs Pre-existing fmt drift from #5154 (Android NDK `.cmd` if-else); rustfmt 1.96.0 expands the single-line if/else. Unrelated to the widget fix but required to get `cargo fmt --all -- --check` (the lint gate) green. --- crates/perry/src/commands/compile/link/mod.rs | 6 +++++- crates/perry/src/commands/compile/link/platform_cmd.rs | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/crates/perry/src/commands/compile/link/mod.rs b/crates/perry/src/commands/compile/link/mod.rs index b4d1279c6b..34ccb0e1ba 100644 --- a/crates/perry/src/commands/compile/link/mod.rs +++ b/crates/perry/src/commands/compile/link/mod.rs @@ -882,7 +882,11 @@ pub(super) fn build_and_run_link( "{}/toolchains/llvm/prebuilt/{}/bin/aarch64-linux-android24-clang{}", ndk_home, host_tag, - if cfg!(target_os = "windows") { ".cmd" } else { "" } + if cfg!(target_os = "windows") { + ".cmd" + } else { + "" + } ); let stub_ok = Command::new(&ndk_clang) .args(["-c", "-fPIC", "-target", "aarch64-linux-android24"]) diff --git a/crates/perry/src/commands/compile/link/platform_cmd.rs b/crates/perry/src/commands/compile/link/platform_cmd.rs index 05ca762936..5d86946e01 100644 --- a/crates/perry/src/commands/compile/link/platform_cmd.rs +++ b/crates/perry/src/commands/compile/link/platform_cmd.rs @@ -576,7 +576,11 @@ pub fn select_linker_command( "{}/toolchains/llvm/prebuilt/{}/bin/aarch64-linux-android24-clang{}", ndk_home, host_tag, - if cfg!(target_os = "windows") { ".cmd" } else { "" } + if cfg!(target_os = "windows") { + ".cmd" + } else { + "" + } ); if !PathBuf::from(&clang).exists() { return Err(anyhow!("Android NDK clang not found at: {}", clang));