diff --git a/docs/TOC.yml b/docs/TOC.yml index 6ab47c56a5..402aff2a81 100644 --- a/docs/TOC.yml +++ b/docs/TOC.yml @@ -991,6 +991,10 @@ href: ios/device-provisioning/automatic-provisioning.md - name: Manual provisioning href: ios/device-provisioning/manual-provisioning.md + - name: Home screen widgets + href: ios/ios-widgets.md + - name: App Intents for Siri and Shortcuts + href: ios/app-intents.md - name: Universal links href: macios/universal-links.md - name: Syncing with Xcode diff --git a/docs/ios/app-intents.md b/docs/ios/app-intents.md new file mode 100644 index 0000000000..3cddd32970 --- /dev/null +++ b/docs/ios/app-intents.md @@ -0,0 +1,894 @@ +--- +title: "Apple App Intents for Siri and Shortcuts" +description: "Learn how to integrate Apple App Intents into your .NET MAUI iOS app to enable Siri voice commands, Shortcuts actions, and Spotlight suggestions." +ms.date: 03/06/2026 +--- + +# Apple App Intents for Siri and Shortcuts + +Apple App Intents let your .NET Multi-platform App UI (.NET MAUI) app expose actions to Siri, the Shortcuts app, and Spotlight. Users can invoke these actions through voice commands ("Hey Siri, create a task in my app"), by building custom automations in the Shortcuts app, or through proactive suggestions that iOS surfaces based on usage patterns. + +This guide is based on the [Maui.Apple.PlatformFeature.Samples](https://github.com/Redth/Maui.Apple.PlatformFeature.Samples) project on GitHub. + +Because iOS extracts intent metadata at compile time from Swift binaries, App Intents can't be defined purely in C#. Apple's App Intents framework extracts metadata (parameter definitions, descriptions, Siri phrases) from compiled Swift code at build time, before your app runs. Because .NET compiles to IL rather than native Swift binaries, intent declarations must be written in Swift. This guide uses a three-project architecture that keeps Swift as a thin declaration layer while all business logic stays in C#. + +## Architecture + +The integration uses three projects that work together: + +| Project | Language | Purpose | +|---|---|---| +| Xcode framework | Swift | Declares `AppIntent`, `AppEntity`, and `AppEnum` types. Defines an `@objc` bridge protocol that the C# side implements. | +| Binding library | C# | Maps `@objc` types to C# via `ApiDefinition.cs`. Auto-builds the Swift framework into an xcframework. | +| MAUI app | C# | Implements the bridge protocol with all business logic in .NET. Wires up the bridge at startup. | + +The Swift framework is deliberately lightweight. It defines *what* actions are available (intent names, parameters, Siri phrases) but delegates all actual work to C# through the bridge protocol. When Siri or Shortcuts invokes an intent, the Swift code calls through the bridge to your .NET implementation, which has full access to your app's services, data, and dependency injection container. + +> [!NOTE] +> This pattern is necessary because Apple's App Intents framework relies on compile-time metadata extraction from Swift binaries. The metadata tells iOS what actions are available, their parameters, and how to present them in Siri and Shortcuts — all without running the app. + +> [!TIP] +> For a minimum viable implementation, you need: one Swift framework with a single AppIntent and bridge protocol, a binding library with `ApiDefinition.cs`, and the MAUI app implementing the bridge. Start with one intent and expand from there. + +## Prerequisites + +Before you begin, make sure you have: + +- macOS with Xcode 16 or later installed. +- .NET 10 SDK or later with the .NET MAUI workload. +- An iOS 17+ device or simulator. +- An Apple Developer account (required for on-device Siri voice testing). + +> [!NOTE] +> Enable the 'Siri' capability for your app target in Xcode or through your provisioning profile. For more information about iOS capabilities, see [iOS capabilities](capabilities.md). + +## Create the Swift framework + +Create an Xcode framework project that contains your intent definitions and the bridge protocol. In Xcode, create a new iOS Framework project (File > New > Project > iOS > Framework). Set the deployment target to iOS 17.0 and add the Swift source files described below. A typical structure looks like this: + +```text +YourApp/ +├── YourApp.csproj +├── MauiProgram.cs +├── Platforms/ +│ └── iOS/ +│ └── AppDelegate.cs +├── YourBindingLibrary/ +│ ├── YourBindingLibrary.csproj +│ ├── ApiDefinition.cs +│ └── StructsAndEnums.cs +└── YourSwiftFramework/ + ├── YourSwiftFramework.xcodeproj + └── YourSwiftFramework/ + ├── BridgeProtocol.swift + ├── BridgeTypes.swift + ├── TaskIntents.swift + ├── TaskEntity.swift + ├── TaskEnums.swift + ├── IntentDonation.swift + └── AppShortcutsProvider.swift +``` + +### Bridge protocol + +The bridge protocol is the most important piece of the architecture. It defines the contract between Swift and C#. All methods and properties must be marked `@objc` so they're visible to the Objective-C runtime, which is how .NET bindings communicate with Swift. + +Define the protocol and a singleton manager that holds a reference to the C# implementation: + +```swift +import Foundation + +@objc(TaskDataProvider) public protocol TaskDataProvider: AnyObject { + func getAllTasks() -> [BridgeTaskItem] + func getTask(withId id: String) -> BridgeTaskItem? + func createTask(title: String, priorityRawValue: Int, + categoryRawValue: Int, dueDate: Date?, + estimatedMinutes: Int, notes: String) -> BridgeTaskItem? + func completeTask(withId id: String) -> Bool + func searchTasks(query: String) -> [BridgeTaskItem] +} + +@objc(TaskBridgeManager) public class TaskBridgeManager: NSObject { + @objc public static let shared = TaskBridgeManager() + @objc public weak var provider: TaskDataProvider? + private override init() { super.init() } +} +``` + +The `provider` property is `weak` to avoid retain cycles. Your C# code sets this property at app startup, and all Swift intent implementations call through it to reach your .NET business logic. + +> [!NOTE] +> The `@objc` attribute makes Swift declarations visible to the Objective-C runtime, which is how .NET iOS bindings communicate with Swift code. Without `@objc`, these types are invisible to C#. Every method, property, and class that crosses the Swift-to-C# boundary must be marked `@objc`. + +### Bridge data types + +Data that crosses the Swift-to-C# boundary must use `@objc`-compatible types. Define a data transfer object (DTO) class for each entity your intents work with: + +```swift +import Foundation + +@objc(BridgeTaskItem) public class BridgeTaskItem: NSObject { + @objc public var id: String + @objc public var title: String + @objc public var isCompleted: Bool + @objc public var priorityRawValue: Int + @objc public var categoryRawValue: Int + @objc public var dueDate: Date? + @objc public var estimatedMinutes: Int + @objc public var notes: String + + @objc public init(id: String, title: String, isCompleted: Bool, + priorityRawValue: Int, categoryRawValue: Int, + dueDate: Date?, estimatedMinutes: Int, + notes: String) { + self.id = id + self.title = title + self.isCompleted = isCompleted + self.priorityRawValue = priorityRawValue + self.categoryRawValue = categoryRawValue + self.dueDate = dueDate + self.estimatedMinutes = estimatedMinutes + self.notes = notes + } +} +``` + +> [!IMPORTANT] +> When passing optional integers across the bridge, use sentinel values (such as `-1` for "no value") instead of Swift optionals. The Objective-C bridge doesn't support optional value types. Use sentinel values like `-1` for "no value" because Objective-C can only represent optionals for reference types, not value types like integers. Reserve Swift optionals for reference types like `Date?` and `String?`, which map to nullable Objective-C types. + +### Define App Enums + +Define `AppEnum` types for any enumerated values your intents use. These appear as selectable options in the Shortcuts app and Siri dialogs: + +```swift +import AppIntents + +enum TaskPriority: Int, AppEnum { + case low = 0 + case medium = 1 + case high = 2 + case urgent = 3 + + static var typeDisplayRepresentation: TypeDisplayRepresentation { + "Task Priority" + } + + static var caseDisplayRepresentations: [TaskPriority: DisplayRepresentation] { + [ + .low: "Low", + .medium: "Medium", + .high: "High", + .urgent: "Urgent" + ] + } +} + +enum TaskCategory: Int, AppEnum { + case personal = 0 + case work = 1 + case shopping = 2 + case health = 3 + + static var typeDisplayRepresentation: TypeDisplayRepresentation { + "Task Category" + } + + static var caseDisplayRepresentations: [TaskCategory: DisplayRepresentation] { + [ + .personal: "Personal", + .work: "Work", + .shopping: "Shopping", + .health: "Health" + ] + } +} +``` + +### Define App Entities + +An `AppEntity` represents a domain object that intents can operate on. Define an entity with an `EntityStringQuery` so users can search for items by name in Siri and Shortcuts: + +```swift +import AppIntents + +struct TaskEntity: AppEntity { + static var defaultQuery = TaskEntityQuery() + static var typeDisplayRepresentation: TypeDisplayRepresentation { + "Task" + } + + var id: String + var title: String + var isCompleted: Bool + + var displayRepresentation: DisplayRepresentation { + DisplayRepresentation(title: "\(title)") + } +} + +struct TaskEntityQuery: EntityStringQuery { + func entities(for identifiers: [String]) async throws -> [TaskEntity] { + guard let provider = TaskBridgeManager.shared.provider else { + return [] + } + + return identifiers.compactMap { id in + guard let item = provider.getTask(withId: id) else { + return nil + } + return TaskEntity(id: item.id, title: item.title, + isCompleted: item.isCompleted) + } + } + + func entities(matching query: String) async throws -> [TaskEntity] { + guard let provider = TaskBridgeManager.shared.provider else { + return [] + } + + return provider.searchTasks(query: query).map { item in + TaskEntity(id: item.id, title: item.title, + isCompleted: item.isCompleted) + } + } + + func suggestedEntities() async throws -> [TaskEntity] { + guard let provider = TaskBridgeManager.shared.provider else { + return [] + } + + return provider.getAllTasks().map { item in + TaskEntity(id: item.id, title: item.title, + isCompleted: item.isCompleted) + } + } +} +``` + +### Define App Intents + +Each `AppIntent` represents an action users can perform through Siri or Shortcuts. The intent declares its parameters, provides a summary string for the Siri dialog, and implements `perform()` to execute the action through the bridge: + +```swift +import AppIntents + +enum IntentError: Error { + case notReady + case taskCreationFailed + case taskNotFound +} + +struct CreateTaskIntent: AppIntent { + static var title: LocalizedStringResource { "Create Task" } + static var description: IntentDescription { + "Creates a new task in the app." + } + static var openAppWhenRun: Bool = false + + @Parameter(title: "Title") + var taskTitle: String + + @Parameter(title: "Priority") + var priority: TaskPriority + + @Parameter(title: "Category") + var category: TaskCategory + + @Parameter(title: "Due Date", optionsProvider: nil) + var dueDate: Date? + + @Parameter(title: "Estimated Minutes", default: -1) + var estimatedMinutes: Int + + @Parameter(title: "Notes", default: "") + var notes: String + + static var parameterSummary: some ParameterSummary { + Summary("Create task \(\.$taskTitle)") { + \.$priority + \.$category + \.$dueDate + \.$estimatedMinutes + \.$notes + } + } + + func perform() async throws -> some IntentResult & ReturnsValue { + guard let provider = TaskBridgeManager.shared.provider else { + throw IntentError.notReady + } + + guard let item = provider.createTask( + title: taskTitle, + priorityRawValue: priority.rawValue, + categoryRawValue: category.rawValue, + dueDate: dueDate, + estimatedMinutes: estimatedMinutes, + notes: notes + ) else { + throw IntentError.taskCreationFailed + } + + return .result(value: TaskEntity( + id: item.id, title: item.title, + isCompleted: item.isCompleted + )) + } +} +``` + +You can define additional intents following the same pattern. For example, a `CompleteTaskIntent` that marks a task as done: + +```swift +struct CompleteTaskIntent: AppIntent { + static var title: LocalizedStringResource { "Complete Task" } + static var description: IntentDescription { + "Marks a task as completed." + } + static var openAppWhenRun: Bool = false + + @Parameter(title: "Task") + var task: TaskEntity + + static var parameterSummary: some ParameterSummary { + Summary("Complete \(\.$task)") + } + + func perform() async throws -> some IntentResult { + guard let provider = TaskBridgeManager.shared.provider else { + throw IntentError.notReady + } + + let success = provider.completeTask(withId: task.id) + if !success { + throw IntentError.taskNotFound + } + + return .result() + } +} +``` + +### Register Siri phrases + +Define an `AppShortcutsProvider` to register phrases that users can speak to invoke your intents directly through Siri. Each phrase must include an application name token: + +```swift +import AppIntents + +struct TaskAppShortcuts: AppShortcutsProvider { + static var appShortcuts: [AppShortcut] { + return [ + AppShortcut( + intent: CreateTaskIntent(), + phrases: [ + "Create a task in \(.applicationName)", + "Add a new task in \(.applicationName)" + ], + shortTitle: "Create Task", + systemImageName: "plus.circle" + ), + AppShortcut( + intent: CompleteTaskIntent(), + phrases: [ + "Complete a task in \(.applicationName)", + "Mark task done in \(.applicationName)" + ], + shortTitle: "Complete Task", + systemImageName: "checkmark.circle" + ) + ] + } +} +``` + +> [!TIP] +> Keep Siri phrases natural and concise. Include variations for how users might phrase the same request. The `\(.applicationName)` token is required and is automatically replaced with your app's display name. + +## Create the binding library + +The binding library is a .NET project that wraps the Swift framework into a form that C# can consume. It uses the `XcodeProject` MSBuild item to automatically build the Swift framework and includes a target that extracts App Intents metadata. + +### Project configuration + +Create a .NET iOS binding library project with the following `.csproj`: + +```xml + + + net10.0-ios + true + true + + + + + + + + + + YourSwiftFramework + + + + + + + <_XcArchivePath>$([System.IO.Path]::Combine( + $(IntermediateOutputPath), 'xcode', 'xcarchive', + 'YourSwiftFramework.xcarchive')) + <_MetadataSrc>$(_XcArchivePath)/Metadata.appintents + <_MetadataDst>$(IntermediateOutputPath)Metadata.appintents + + + <_MetadataFiles Include="$(_MetadataSrc)/**/*" /> + + + + +``` + +The `XcodeProject` item tells the .NET build system to compile the Swift project into an xcframework automatically. The `XcodeProject` item type is provided by the .NET for iOS workload and automatically builds Swift frameworks into xcframeworks during `dotnet build`. + +> [!NOTE] +> The `XcodeProject` item type only works when building on a Mac. It doesn't work when building from Windows. + +The `ExtractAppIntentsMetadata` target copies the `Metadata.appintents` bundle from the xcarchive output into the intermediate output path, where the MAUI app can pick it up. + +### ApiDefinition.cs + +The API definition file maps Swift `@objc` types to C# interfaces and classes. Each `[Export]` attribute specifies the Objective-C selector name that matches the Swift declaration: + +```csharp +using Foundation; +using ObjCRuntime; + +namespace YourBindingLibrary; + +[BaseType(typeof(NSObject))] +interface BridgeTaskItem +{ + [Export("id")] + string Id { get; set; } + + [Export("title")] + string Title { get; set; } + + [Export("isCompleted")] + bool IsCompleted { get; set; } + + [Export("priorityRawValue")] + nint PriorityRawValue { get; set; } + + [Export("categoryRawValue")] + nint CategoryRawValue { get; set; } + + [Export("dueDate")] + [NullAllowed] + NSDate DueDate { get; set; } + + [Export("estimatedMinutes")] + nint EstimatedMinutes { get; set; } + + [Export("notes")] + string Notes { get; set; } + + [Export("initWithId:title:isCompleted:priorityRawValue:" + + "categoryRawValue:dueDate:estimatedMinutes:notes:")] + NativeHandle Constructor(string id, string title, bool isCompleted, + nint priorityRawValue, nint categoryRawValue, + [NullAllowed] NSDate dueDate, nint estimatedMinutes, + string notes); +} + +[Protocol] +[BaseType(typeof(NSObject), Name = "TaskDataProvider")] +[Model] +interface TaskDataProvider +{ + [Abstract] + [Export("getAllTasks")] + BridgeTaskItem[] GetAllTasks(); + + [Abstract] + [Export("getTaskWithId:")] + [return: NullAllowed] + BridgeTaskItem GetTask(string id); + + [Abstract] + [Export("createTaskWithTitle:priorityRawValue:categoryRawValue:" + + "dueDate:estimatedMinutes:notes:")] + [return: NullAllowed] + BridgeTaskItem CreateTask(string title, nint priorityRawValue, + nint categoryRawValue, [NullAllowed] NSDate dueDate, + nint estimatedMinutes, string notes); + + [Abstract] + [Export("completeTaskWithId:")] + bool CompleteTask(string id); + + [Abstract] + [Export("searchTasksWithQuery:")] + BridgeTaskItem[] SearchTasks(string query); +} + +[BaseType(typeof(NSObject))] +[DisableDefaultCtor] +interface TaskBridgeManager +{ + [Static] + [Export("shared")] + TaskBridgeManager Shared { get; } + + [Export("provider", ArgumentSemantic.Weak)] + [NullAllowed] + ITaskDataProvider Provider { get; set; } +} +``` + +> [!NOTE] +> Key mapping rules between Swift and C#: +> +> - Swift `Int` maps to `nint` in C# (platform-native integer size). +> - Swift `Date?` maps to `[NullAllowed] NSDate` in C#. +> - Swift optional reference types use `[NullAllowed]` on the property or return value. +> - The `[Export]` selector must match the Objective-C selector that Swift generates. For a method `func getTask(withId id: String)`, the selector is `getTaskWithId:`. +> - Use `[DisableDefaultCtor]` for singleton classes that should only be accessed through a static `Shared` property. + +### StructsAndEnums.cs + +If your bridge uses enums or structs that need explicit C# definitions, define them in the `StructsAndEnums.cs` file. For simple bridge patterns where enums are passed as raw integer values, this file can remain empty: + +```csharp +using ObjCRuntime; + +namespace YourBindingLibrary; + +// Enums are passed as raw Int values across the bridge, +// so no enum definitions are needed here. +``` + +## Set up intent donation + +Intent donation tells iOS when users perform actions in your app's UI, allowing the system to learn usage patterns and proactively suggest shortcuts. Define a donation bridge in Swift that C# can call: + +```swift +import AppIntents +import Foundation + +@objc(IntentDonationBridge) public class IntentDonationBridge: NSObject { + @objc public static let shared = IntentDonationBridge() + private override init() { super.init() } + + @objc public func donateCreateTask(title: String, + priorityRawValue: Int, + categoryRawValue: Int) { + let intent = CreateTaskIntent() + intent.taskTitle = title + intent.priority = TaskPriority(rawValue: priorityRawValue) ?? .medium + intent.category = TaskCategory(rawValue: categoryRawValue) ?? .personal + + Task { + try? await intent.donate() + } + } +} +``` + +Add the corresponding binding in `ApiDefinition.cs`: + +```csharp +[BaseType(typeof(NSObject))] +[DisableDefaultCtor] +interface IntentDonationBridge +{ + [Static] + [Export("shared")] + IntentDonationBridge Shared { get; } + + [Export("donateCreateTaskWithTitle:priorityRawValue:categoryRawValue:")] + void DonateCreateTask(string title, nint priorityRawValue, + nint categoryRawValue); +} +``` + +## Implement the bridge in C# + +The C# side of the bridge needs domain model types and a service interface that the bridge provider delegates to. + +Define the enums that match the Swift `AppEnum` raw values: + +```csharp +public enum TaskPriorityLevel { Low = 0, Medium = 1, High = 2, Urgent = 3 } +public enum TaskCategoryType { Personal = 0, Work = 1, Shopping = 2, Health = 3, Other = 4 } +``` + +Define the domain model: + +```csharp +public class TaskItem +{ + public string Id { get; set; } = Guid.NewGuid().ToString(); + public string Title { get; set; } = string.Empty; + public bool IsCompleted { get; set; } + public TaskPriorityLevel Priority { get; set; } + public TaskCategoryType Category { get; set; } + public DateTime? DueDate { get; set; } + public int? EstimatedMinutes { get; set; } + public string? Notes { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} +``` + +Define the service interface that the bridge provider delegates to: + +```csharp +public interface ITaskService +{ + IEnumerable GetAll(); + TaskItem? GetById(string id); + TaskItem Create(string title, TaskPriorityLevel priority, + TaskCategoryType category, DateTime? dueDate, + int? estimatedMinutes, string? notes); + bool Complete(string id); + IEnumerable Search(string query); + IEnumerable GetFiltered(TaskCategoryType? category, + TaskPriorityLevel? priority, bool showCompleted); + bool SetDueDate(string id, DateTime date); +} +``` + +### Bridge provider + +Create a class in your MAUI app that inherits from the generated `TaskDataProvider` base class and delegates to your app's service layer: + +```csharp +using Foundation; +using System.Linq; +using YourBindingLibrary; + +namespace YourApp.Platforms.iOS; + +public class AppIntentsBridgeProvider : TaskDataProvider +{ + private readonly ITaskService _taskService; + + public AppIntentsBridgeProvider(ITaskService taskService) + { + _taskService = taskService; + } + + public override BridgeTaskItem[] GetAllTasks() + { + return _taskService.GetAll() + .Select(t => t.ToBridge()) + .ToArray(); + } + + public override BridgeTaskItem? GetTask(string id) + { + var task = _taskService.GetById(id); + return task?.ToBridge(); + } + + public override BridgeTaskItem? CreateTask( + string title, nint priorityRawValue, nint categoryRawValue, + NSDate? dueDate, nint estimatedMinutes, string notes) + { + var newTask = _taskService.Create( + title, + (TaskPriorityLevel)(int)priorityRawValue, + (TaskCategoryType)(int)categoryRawValue, + dueDate is NSDate d + ? (DateTime)(DateTimeOffset)d + : null, + (int)estimatedMinutes >= 0 + ? (int)estimatedMinutes + : null, + notes); + + return newTask?.ToBridge(); + } + + public override bool CompleteTask(string id) + { + return _taskService.Complete(id); + } + + public override BridgeTaskItem[] SearchTasks(string query) + { + return _taskService.Search(query) + .Select(t => t.ToBridge()) + .ToArray(); + } +} +``` + +Define a `ToBridge()` extension method to handle the type conversions between your domain model and the bridge DTO: + +```csharp +using Foundation; +using YourBindingLibrary; + +namespace YourApp.Platforms.iOS; + +public static class BridgeExtensions +{ + public static BridgeTaskItem ToBridge(this TaskItem task) + { + return new BridgeTaskItem( + id: task.Id, + title: task.Title, + isCompleted: task.IsCompleted, + priorityRawValue: (nint)(int)task.Priority, + categoryRawValue: (nint)(int)task.Category, + dueDate: task.DueDate.HasValue + ? (NSDate)(DateTimeOffset)task.DueDate.Value + : null, + estimatedMinutes: task.EstimatedMinutes.HasValue + ? (nint)task.EstimatedMinutes.Value + : -1, + notes: task.Notes ?? string.Empty + ); + } +} +``` + +> [!IMPORTANT] +> Pay attention to type marshaling across the bridge: +> +> - Cast C# enums to `int`, then to `nint` for the bridge. +> - Convert `DateTime` to `NSDate` using the explicit cast operator: `(NSDate)dateTime`. +> - Use sentinel values like `-1` for optional integers, since the Objective-C bridge doesn't support nullable value types. + +### Wire up in AppDelegate + +Set the bridge provider in `AppDelegate.cs` so that intents can reach your C# logic as soon as the app launches: + +```csharp +using Foundation; +using UIKit; +using YourBindingLibrary; + +namespace YourApp.Platforms.iOS; + +[Register("AppDelegate")] +public class AppDelegate : MauiUIApplicationDelegate +{ + protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp(); + + public override bool FinishedLaunching( + UIApplication application, NSDictionary launchOptions) + { + var result = base.FinishedLaunching(application, launchOptions); + WireUpAppIntentsBridge(); + return result; + } + + private void WireUpAppIntentsBridge() + { + var taskService = IPlatformApplication.Current?.Services + .GetRequiredService(); + + if (taskService is not null) + { + TaskBridgeManager.Shared.Provider = + new AppIntentsBridgeProvider(taskService); + } + } +} +``` + +Register `ITaskService` in your `MauiProgram.cs` so the bridge provider can resolve it: + +```csharp +builder.Services.AddSingleton(); +``` + +> [!NOTE] +> You must implement the `TaskService` class based on the `ITaskService` interface defined earlier. This class contains your app's business logic for managing tasks (for example, backed by a database or in-memory collection). + +### Donate intents from your UI + +When users perform actions in your app's UI, donate the corresponding intent so iOS can learn patterns and suggest shortcuts: + +```csharp +private void OnTaskCreated(string title, int priority, int category) +{ +#if IOS + IntentDonationBridge.Shared.DonateCreateTask( + title, (nint)priority, (nint)category); +#endif +} +``` + +### Copy metadata to app bundle + +The MAUI app's `.csproj` must include the `Metadata.appintents` bundle from the binding library as a bundle resource. Without this metadata, iOS doesn't know your intents exist. + +Your MAUI project must also have a `` to the binding library project: + +```xml + +``` + +Add the metadata directory as a `BundleResource` in your MAUI app's `.csproj`: + +```xml + + + +``` + +The `BundleResource` item group tells the .NET build system to include these files in the app bundle. The `Link` metadata preserves the `Metadata.appintents` directory structure inside the bundle. + +## Build and test + +Build the app from the command line: + +```bash +dotnet build YourApp.csproj -f net10.0-ios -r iossimulator-arm64 +``` + +For device builds, use `ios-arm64` as the runtime identifier: + +```bash +dotnet build YourApp.csproj -f net10.0-ios -r ios-arm64 +``` + +### Verify metadata + +After building, confirm that the `Metadata.appintents` bundle exists inside your app bundle: + +```bash +find bin/Debug/net10.0-ios/iossimulator-arm64/YourApp.app \ + -name "Metadata.appintents" -type d +``` + +If the command returns no results, the metadata wasn't copied correctly. Review the `CopyAppIntentsMetadata` target in your `.csproj`. + +### Simulator testing + +On the iOS simulator, you can: + +- Open the **Shortcuts** app to see all registered intents and test them interactively. +- Run intents to verify they execute correctly through the bridge. +- Check the Xcode console for `[AppIntents]` log messages that confirm metadata was loaded. + +### Device testing + +On a physical device with an Apple Developer account: + +- Siri voice invocation works with the phrases registered in `AppShortcutsProvider`. +- Spotlight suggestions appear based on donated intents. +- The Shortcuts app shows all registered intents with their full parameter UI. + +> [!TIP] +> During development, if intents stop appearing after code changes, delete the app from the simulator or device and reinstall. iOS caches intent metadata aggressively, and a clean install forces re-registration. + +## Troubleshooting + +| Problem | Solution | +|---|---| +| Intents don't appear in the Shortcuts app. | Verify that `Metadata.appintents` exists inside the app bundle at the correct path. Run the `find` command in the [Verify metadata](#verify-metadata) section. | +| "App not ready" errors when invoking an intent. | Ensure the bridge is wired up in `AppDelegate.FinishedLaunching` before any intents can fire. Check that `TaskBridgeManager.Shared.Provider` is set. | +| Type conversion errors at runtime. | Check that sentinel values (like `-1` for optional integers) are handled correctly on both sides of the bridge. Verify `nint` casts for enum raw values. | +| Build fails with "scheme not found." | Verify that the `SchemeName` in the `XcodeProject` MSBuild item matches the scheme name in your Xcode project exactly. | +| Intents appear but return errors. | Check the Xcode console or device logs for `[AppIntents]` messages. Verify that `ITaskService` is registered in the DI container before the bridge is wired up. | +| Metadata exists but intents still don't register. | Confirm the `CopyAppIntentsMetadata` target destination path ends with `/`. Verify the metadata was copied into the `.app` bundle, not alongside it. | + +## See also + +- [Maui.Apple.PlatformFeature.Samples on GitHub](https://github.com/Redth/Maui.Apple.PlatformFeature.Samples) — the sample project this guide is based on. +- [Apple App Intents documentation](https://developer.apple.com/documentation/appintents) +- [Apple Shortcuts documentation](https://developer.apple.com/documentation/appintents/app-shortcuts) +- [Apple SiriKit documentation](https://developer.apple.com/documentation/sirikit) +- [iOS entitlements](entitlements.md) +- [iOS capabilities](capabilities.md) diff --git a/docs/ios/ios-widgets.md b/docs/ios/ios-widgets.md new file mode 100644 index 0000000000..91a01b2b03 --- /dev/null +++ b/docs/ios/ios-widgets.md @@ -0,0 +1,812 @@ +--- +title: "iOS home screen widgets" +description: "Learn how to add iOS home screen widgets to your .NET MAUI app using a Swift widget extension with App Group shared containers." +ms.date: 03/06/2026 +--- + +# iOS home screen widgets + +iOS home screen widgets let your .NET Multi-platform App UI (.NET MAUI) app display glanceable content and interactive controls directly on the user's home screen. Because Apple's WidgetKit framework requires SwiftUI, widgets are built as a separate Xcode project that's embedded into your .NET MAUI app at build time. The .NET MAUI app and the widget extension communicate through JSON files stored in an App Group shared container. + +This guide is based on the [Maui.Apple.PlatformFeature.Samples](https://github.com/Redth/Maui.Apple.PlatformFeature.Samples) project on GitHub. + +This guide walks you through creating a widget extension, wiring up shared data, handling deep links, and configuring your project to build and embed the extension automatically. + +## Architecture + +A widget extension runs in its own process and can't directly access your app's memory or state. All communication flows through an App Group shared container, which is a directory on disk that both the app and the extension can read and write to. + +The following table summarizes the three communication channels between your app and the widget: + +| Direction | Mechanism | How it works | +|---|---|---| +| **App → Widget** | JSON file + WidgetKit reload | The app writes a JSON file to the shared container and calls `WidgetCenter.ReloadTimelines` to tell the widget to refresh. | +| **Widget → App (tap)** | Deep link URL scheme | The widget uses a SwiftUI `Link` or `widgetURL` to open a URL with your app's custom scheme. The app receives it in `OpenUrl`. | +| **Widget → App (interactive)** | AppIntent buttons | A `Button(intent:)` in the widget runs an `AppIntent` that writes a JSON file to the shared container. The app reads it on next launch or resume. | + +> [!NOTE] +> Interactive widget buttons (AppIntents) are available on iOS 17 and later. On earlier versions, only tap-to-open deep links are supported. + +## Prerequisites + +Before you begin, make sure you have: + +- macOS with Xcode 16 or later installed. +- .NET 10 SDK or later with the .NET MAUI workload. +- An iOS 14+ device or simulator. Widgets require iOS 14 or later; interactive widget buttons (AppIntents) require iOS 17 or later. +- [xcodegen](https://github.com/yonaskolb/XcodeGen) (optional). Generates an Xcode project from a YAML specification, which avoids checking in `.xcodeproj` files. + +## Create the widget extension + +The widget is an Xcode project that lives inside your .NET MAUI solution. You can create it in Xcode or generate it with xcodegen. + +### Create the Xcode project + +1. Open Xcode and create a new project using the **App** template with Swift. +1. Set the bundle identifier to match your .NET MAUI app's bundle ID. This Xcode project serves only as a development container for the widget and won't ship as a standalone app. +1. Go to **File > New > Target** and choose the **Widget Extension** template. +1. Enter a name for your widget extension (for example, `SimpleWidgetExtension`). The bundle identifier must be a child of your app's bundle ID (for example, `com.contoso.myapp.SimpleWidgetExtension`). +1. Ensure all targets use the same minimum iOS deployment version by selecting the project name, navigating to each target's **General** tab, and setting **Minimum Deployments**. + +Build and run the widget extension on a simulator to verify the template works before customizing it. + +> [!TIP] +> Alternatively, use [xcodegen](https://github.com/yonaskolb/XcodeGen) to generate the `.xcodeproj` from a `project.yml` file. This avoids checking in Xcode project files and simplifies merge conflicts. + +### Project structure + +A typical directory structure looks like this: + +```text +YourApp/ +├── YourApp.csproj +├── MauiProgram.cs +├── Platforms/ +│ └── iOS/ +│ ├── AppDelegate.cs +│ └── Entitlements.plist +└── widget/ + ├── project.yml # xcodegen spec (optional) + ├── SimpleWidget/ + │ ├── Settings.swift + │ ├── WidgetData.swift + │ ├── SharedStorage.swift + │ ├── Provider.swift + │ ├── SimpleWidgetView.swift + │ ├── IncrementCounterIntent.swift + │ └── SimpleWidgetBundle.swift + └── SimpleWidgetExtension.entitlements +``` + +### Widget components + +A widget extension consists of several key components: + +- **WidgetBundle**: The `@main` entry point that exposes one or more widgets. +- **Widget**: The configuration object that defines the widget's view, provider, and supported sizes. +- **AppIntentTimelineProvider**: Supplies data to the widget. It includes `placeholder` (loading state), `snapshot` (gallery preview), and `timeline` (live data) methods. +- **TimelineEntry**: The data model structure the widget displays. +- **View**: The SwiftUI view that renders the widget. + +The following sections describe each Swift file and its role. + +### Settings.swift – constants that must match C# + +Define constants for the App Group identifier, file names, widget kind, and URL scheme. These values must exactly match the corresponding C# constants in your .NET MAUI project: + +```swift +import Foundation + +struct Settings { + static let groupId = "group.com.yourapp" + static let fromAppFile = "widget_data_fromapp.json" + static let fromWidgetFile = "widget_data_fromwidget.json" + static let widgetKind = "SimpleWidget" + static let urlScheme = "yourapp" + static let urlHost = "widget" +} +``` + +### WidgetData.swift – shared JSON contract + +Define the data structure that both Swift and C# serialize and deserialize. Keep this structure flat and simple so that both sides can work with it reliably: + +```swift +import Foundation + +struct WidgetData: Codable { + var version: Int = 1 + var title: String = "" + var message: String = "" + var counter: Int = 0 + var updatedAt: String = "" + var extras: [String: String] = [:] +} +``` + +### SharedStorage.swift – file I/O via App Group + +The shared storage class reads and writes JSON files in the App Group container. This is the core mechanism for passing data between the app and the widget. + +```swift +import Foundation + +struct SharedStorage { + private func containerURL() -> URL? { + FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: Settings.groupId + ) + } + + func readAppData() -> WidgetData? { + guard let url = containerURL()?.appendingPathComponent( + Settings.fromAppFile) else { return nil } + guard let data = try? Data(contentsOf: url) else { return nil } + return try? JSONDecoder().decode(WidgetData.self, from: data) + } + + func readWidgetData() -> WidgetData? { + guard let url = containerURL()?.appendingPathComponent( + Settings.fromWidgetFile) else { return nil } + guard let data = try? Data(contentsOf: url) else { return nil } + return try? JSONDecoder().decode(WidgetData.self, from: data) + } + + func writeWidgetData(_ widgetData: WidgetData) { + guard let url = containerURL()?.appendingPathComponent( + Settings.fromWidgetFile) else { return } + guard let data = try? JSONEncoder().encode(widgetData) + else { return } + try? data.write(to: url) + } + + func getBestCounter() -> Int { + let appData = readAppData() + let widgetData = readWidgetData() + + let appDate = parseDate(appData?.updatedAt) + let widgetDate = parseDate(widgetData?.updatedAt) + + if let ad = appDate, let wd = widgetDate { + return ad > wd + ? (appData?.counter ?? 0) : (widgetData?.counter ?? 0) + } + return widgetData?.counter ?? appData?.counter ?? 0 + } + + private func parseDate(_ str: String?) -> Date? { + guard let str = str else { return nil } + return ISO8601DateFormatter().date(from: str) + } +} +``` + +> [!IMPORTANT] +> Use file-based I/O instead of `UserDefaults(suiteName:)`. Although `UserDefaults` with a suite name is commonly recommended, the suite name can resolve to different `.plist` files for the app process and the extension process on some configurations. In certain iOS configurations, `UserDefaults(suiteName:)` can resolve to different .plist paths for the app process vs. the extension process, causing data to silently not synchronize. File-based I/O through the App Group container directory is more reliable. + +The `getBestCounter` method compares timestamps from both files to determine which counter value is most recent. This avoids race conditions when the app and widget both update the counter. + +### Provider.swift – timeline provider + +The timeline provider tells WidgetKit when and how to render the widget. Use `AppIntentTimelineProvider` to support interactive widgets: + +```swift +import WidgetKit +import SwiftUI + +struct Provider: AppIntentTimelineProvider { + typealias Entry = SimpleEntry + typealias Intent = ConfigurationAppIntent + + func placeholder(in context: Context) -> SimpleEntry { + SimpleEntry(date: Date(), data: WidgetData()) + } + + func snapshot(for intent: ConfigurationAppIntent, + in context: Context) async -> SimpleEntry { + let storage = SharedStorage() + let data = storage.readAppData() ?? WidgetData() + return SimpleEntry(date: Date(), data: data) + } + + func timeline(for intent: ConfigurationAppIntent, + in context: Context) async -> Timeline { + let storage = SharedStorage() + let data = storage.readAppData() ?? WidgetData() + let entry = SimpleEntry(date: Date(), data: data) + return Timeline(entries: [entry], policy: .never) + } +} + +struct SimpleEntry: TimelineEntry { + let date: Date + let data: WidgetData +} + +struct ConfigurationAppIntent: WidgetConfigurationIntent { + static var title: LocalizedStringResource { "Configuration" } + static var description: IntentDescription { "Simple widget." } +} +``` + +> [!TIP] +> The `.never` refresh policy means the widget only refreshes when your app explicitly calls `WidgetCenter.ReloadTimelines`. This avoids unnecessary background work and gives you full control over when the widget updates. + +### SimpleWidgetView.swift – the SwiftUI view + +The widget view displays data from the shared storage and provides interaction through deep links and buttons: + +```swift +import SwiftUI +import WidgetKit + +struct SimpleWidgetView: View { + var entry: Provider.Entry + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + // Tapping this text opens the app via deep link + Link(destination: URL( + string: "\(Settings.urlScheme)://\(Settings.urlHost)/action" + )!) { + Text(entry.data.title.isEmpty + ? "My Widget" : entry.data.title) + .font(.headline) + } + + Text("Counter: \(entry.data.counter)") + .font(.body) + + // Interactive button – runs an AppIntent + Button(intent: IncrementCounterIntent()) { + Label("Increment", systemImage: "plus.circle.fill") + } + .buttonStyle(.borderedProminent) + .tint(.blue) + } + .padding() + } +} +``` + +> [!WARNING] +> Do not wrap a `Button(intent:)` inside a `widgetURL()` modifier or a `Link`. Both `widgetURL` and `Link` intercept all taps in their view hierarchy, which prevents the button's intent from firing. If your layout uses `widgetURL` to make the entire widget tappable, place `Button(intent:)` controls outside that container. Similarly, do not nest a `Button(intent:)` inside a `Link` view. + +### Interactive buttons with AppIntents + +AppIntents let the widget perform actions without opening the app. The following intent increments the counter, writes the result to the shared container, and tells WidgetKit to reload: + +```swift +import AppIntents +import WidgetKit + +struct IncrementCounterIntent: AppIntent { + static var title: LocalizedStringResource { "Increment Counter" } + static var description: IntentDescription { + "Increments the counter by 1" + } + + func perform() async throws -> some IntentResult { + let storage = SharedStorage() + let currentCount = storage.getBestCounter() + let newCount = currentCount + 1 + + let data = WidgetData( + version: 1, + title: "", + message: "incremented via widget", + counter: newCount, + updatedAt: ISO8601DateFormatter().string(from: Date()), + extras: [:] + ) + + storage.writeWidgetData(data) + WidgetCenter.shared.reloadTimelines(ofKind: Settings.widgetKind) + return .result() + } +} +``` + +> [!TIP] +> For a read-only widget without interactive buttons, you can omit the AppIntents, `Button(intent:)`, and the widget-to-app file. Only implement `SharedStorage.readAppData()`, the timeline `Provider`, a `SimpleWidgetView` with `Link` for taps, and `SendDataToWidget` + `RefreshWidget` in C#. + +### Widget bundle entry point + +Register the widget in a `WidgetBundle`: + +```swift +import WidgetKit +import SwiftUI + +@main +struct SimpleWidgetBundle: WidgetBundle { + var body: some Widget { + SimpleWidget() + } +} + +struct SimpleWidget: Widget { + let kind: String = Settings.widgetKind + + var body: some WidgetConfiguration { + AppIntentConfiguration( + kind: kind, + intent: ConfigurationAppIntent.self, + provider: Provider() + ) { entry in + SimpleWidgetView(entry: entry) + .containerBackground(.fill.tertiary, for: .widget) + } + .configurationDisplayName("Simple Widget") + .description("A sample .NET MAUI widget.") + .supportedFamilies([.systemSmall, .systemMedium]) + } +} +``` + +## Set up the .NET MAUI project + +### Shared data contract (C#) + +Create a C# record that mirrors the Swift `WidgetData` struct. Use `JsonPropertyName` attributes to match the Swift property names exactly: + +```csharp +using System.Text.Json.Serialization; + +namespace YourApp.Models; + +public record WidgetData +{ + [JsonPropertyName("version")] + public int Version { get; set; } = 1; + + [JsonPropertyName("title")] + public string Title { get; set; } = string.Empty; + + [JsonPropertyName("message")] + public string Message { get; set; } = string.Empty; + + [JsonPropertyName("counter")] + public int Counter { get; set; } + + [JsonPropertyName("updatedAt")] + public string UpdatedAt { get; set; } = string.Empty; + + [JsonPropertyName("extras")] + public Dictionary Extras { get; set; } = []; +} +``` + +Define a constants class that mirrors `Settings.swift`: + +```csharp +namespace YourApp; + +public static class WidgetConstants +{ + public const string GroupId = "group.com.yourapp"; + public const string FromAppFile = "widget_data_fromapp.json"; + public const string FromWidgetFile = "widget_data_fromwidget.json"; + public const string WidgetKind = "SimpleWidget"; + public const string UrlScheme = "yourapp"; + public const string UrlHost = "widget"; +} +``` + +> [!IMPORTANT] +> If any of these constants differ between the Swift and C# code, data sharing silently fails. Double-check that `GroupId`, file names, `WidgetKind`, and the URL scheme match exactly. + +### Widget data service + +Define an interface for reading and writing widget data: + +```csharp +using YourApp.Models; + +namespace YourApp.Services; + +public interface IWidgetDataService +{ + WidgetData? ReadAppData(); + WidgetData? ReadWidgetData(); + void WriteAppData(WidgetData data); + void ClearWidgetData(); + void RefreshWidget(); +} +``` + +On iOS, implement the service using `NSFileManager` to access the App Group container: + +```csharp +#if IOS +using Foundation; +using System.Text.Json; +using YourApp.Models; +using WidgetKit; + +namespace YourApp.Services; + +public class AppleWidgetDataService : IWidgetDataService +{ + private string? GetContainerPath() + { + var url = NSFileManager.DefaultManager + .GetContainerUrl(WidgetConstants.GroupId); + return url?.Path; + } + + public WidgetData? ReadAppData() + { + return ReadFile(WidgetConstants.FromAppFile); + } + + public WidgetData? ReadWidgetData() + { + return ReadFile(WidgetConstants.FromWidgetFile); + } + + public void WriteAppData(WidgetData data) + { + data.UpdatedAt = DateTime.UtcNow.ToString("o"); + var path = GetFilePath(WidgetConstants.FromAppFile); + if (path is null) return; + + var json = JsonSerializer.Serialize(data); + File.WriteAllText(path, json); + } + + public void ClearWidgetData() + { + var path = GetFilePath(WidgetConstants.FromWidgetFile); + if (path is not null && File.Exists(path)) + File.Delete(path); + } + + public void RefreshWidget() + { + // From the WidgetKit.WidgetCenterProxy NuGet package + WidgetCenterProxy.ReloadTimelines(WidgetConstants.WidgetKind); + } + + private string? GetFilePath(string fileName) + { + var container = GetContainerPath(); + return container is null ? null : Path.Combine(container, fileName); + } + + private WidgetData? ReadFile(string fileName) + { + var path = GetFilePath(fileName); + if (path is null || !File.Exists(path)) return null; + + var json = File.ReadAllText(path); + return JsonSerializer.Deserialize(json); + } +} +#endif +``` + +For non-iOS platforms, provide a stub implementation so that your shared code compiles: + +```csharp +using YourApp.Models; + +namespace YourApp.Services; + +public class StubWidgetDataService : IWidgetDataService +{ + public WidgetData? ReadAppData() => null; + public WidgetData? ReadWidgetData() => null; + public void WriteAppData(WidgetData data) { } + public void ClearWidgetData() { } + public void RefreshWidget() { } +} +``` + +Register the service in `MauiProgram.cs`: + +```csharp +public static MauiApp CreateMauiApp() +{ + var builder = MauiApp.CreateBuilder(); + builder.UseMauiApp(); + +#if IOS + builder.Services.AddSingleton(); +#else + builder.Services.AddSingleton(); +#endif + + return builder.Build(); +} +``` + +### Handle deep links + +When a user taps the widget (not an interactive button), the widget opens your app through a deep link URL. Handle this in `AppDelegate.cs`: + +```csharp +using Foundation; +using UIKit; + +namespace YourApp.Platforms.iOS; + +[Register("AppDelegate")] +public class AppDelegate : MauiUIApplicationDelegate +{ + protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp(); + + [Export("application:openURL:options:")] + public override bool OpenUrl( + UIApplication application, + NSUrl url, + NSDictionary options) + { + if (url.Scheme == WidgetConstants.UrlScheme + && url.Host == WidgetConstants.UrlHost) + { + if (App.Current is App app) + { + app.HandleWidgetUrl(url.AbsoluteString ?? string.Empty); + } + } + + return base.OpenUrl(application, url, options); + } +} +``` + +> [!NOTE] +> The URL scheme must be registered in your app's `Info.plist` for iOS to route widget taps to your app: +> +> ```xml +> CFBundleURLTypes +> +> +> CFBundleURLSchemes +> +> yourapp +> +> +> +> ``` + +In your `App.xaml.cs`, add the following method: + +```csharp +public void HandleWidgetUrl(string url) +{ + // Parse the URL path and query to determine which action to take. + // For example, navigate to a specific page or update state. + MainThread.BeginInvokeOnMainThread(() => + { + // Handle the deep link action + }); +} +``` + +### Send data to the widget + +After a user action (for example, tapping a button on `MainPage`), write data to the shared container and tell the widget to refresh: + +```csharp +private int _currentCounter = 0; + +private void OnSendToWidgetClicked(object sender, EventArgs e) +{ + var service = App.Current?.Handler?.MauiContext? + .Services.GetRequiredService(); + if (service is null) return; + + var data = new WidgetData + { + Title = "Hello from MAUI", + Message = "Updated at " + DateTime.Now.ToString("t"), + Counter = _currentCounter, + }; + + service.WriteAppData(data); + service.RefreshWidget(); +} +``` + +### Read data from the widget + +Check for incoming widget data when the page appears and when the app resumes: + +```csharp +protected override void OnAppearing() +{ + base.OnAppearing(); + ReadWidgetData(); +} + +private void ReadWidgetData() +{ + var service = App.Current?.Handler?.MauiContext? + .Services.GetRequiredService(); + if (service is null) return; + + var data = service.ReadWidgetData(); + if (data is null) return; + + _currentCounter = data.Counter; + CounterLabel.Text = $"Counter: {data.Counter}"; + StatusLabel.Text = $"Widget said: {data.Message}"; + + // Clear after reading to avoid processing stale data + service.ClearWidgetData(); +} +``` + +> [!NOTE] +> The preceding code assumes `Label` controls named `CounterLabel` and `StatusLabel` are defined in your XAML. + +## Configure entitlements + +Both the .NET MAUI app and the widget extension must declare the same App Group in their entitlements. + +Create `Platforms/iOS/Entitlements.plist` for the .NET MAUI app: + +```xml + + + + + com.apple.security.application-groups + + group.com.yourapp + + + +``` + +Create `widget/SimpleWidgetExtension.entitlements` for the widget extension: + +```xml + + + + + com.apple.security.application-groups + + group.com.yourapp + + + +``` + +> [!IMPORTANT] +> The App Group identifier (for example, `group.com.yourapp`) must be identical in both entitlements files. The widget extension's bundle identifier must also be a child of the app's bundle identifier. For example, if the app is `com.yourcompany.yourapp`, the widget must be something like `com.yourcompany.yourapp.widget`. + +> [!NOTE] +> You must also create the App Group identifier in the [Apple Developer portal](https://developer.apple.com/account/resources/identifiers/list/applicationGroup) and add it to the provisioning profiles for both the app and the widget extension. + +For more information about iOS entitlements, see [iOS entitlements](entitlements.md). For information about capabilities, see [iOS capabilities](capabilities.md). + +## Configure the project file + +Your `.csproj` file needs several additions to build the Swift widget extension and embed it into the app bundle. Add the following elements: + +### Entitlements and NuGet package + +```xml + + Platforms/iOS/Entitlements.plist + + + + + +``` + +The `WidgetKit.WidgetCenterProxy` package provides a C# API to call `WidgetCenter.reloadTimelines` from your .NET MAUI code. + +### Embed the widget extension + +Tell the build system to include the compiled `.appex` in the app bundle: + +```xml + + + com.yourcompany.yourapp.SimpleWidgetExtension + + +``` + +### Build the widget extension automatically + +Add an MSBuild target that compiles the Xcode project before the .NET MAUI build. This target detects whether you're building for the simulator or a device, optionally runs `xcodegen`, and invokes `xcodebuild`: + +```xml + + + + + <_WidgetSdk + Condition="$(RuntimeIdentifier.Contains('simulator'))">iphonesimulator + <_WidgetSdk + Condition="!$(RuntimeIdentifier.Contains('simulator'))">iphoneos + <_WidgetArch + Condition="$(RuntimeIdentifier.Contains('arm64'))">arm64 + <_WidgetArch + Condition="!$(RuntimeIdentifier.Contains('arm64'))">x86_64 + <_WidgetConfig + Condition="'$(Configuration)' == 'Debug'">Debug + <_WidgetConfig + Condition="'$(Configuration)' != 'Debug'">Release + <_WidgetProjectDir>$(MSBuildProjectDirectory)/widget + <_WidgetOutputDir>$(MSBuildProjectDirectory)/WidgetExtensions + + + + + + + + + +``` + +> [!NOTE] +> Setting `CODE_SIGNING_ALLOWED=NO` in `xcodebuild` lets the .NET build system handle all code signing. The widget extension is re-signed as part of the .NET MAUI app's signing step. + +> [!NOTE] +> On CI agents (GitHub Actions, Azure DevOps), ensure `xcodebuild` is available by installing Xcode. Optionally set `DEVELOPER_DIR` to pin the Xcode version. The `BuildWidgetExtension` target runs automatically during `dotnet build` on any macOS agent with Xcode installed. + +## Build and test + +Build the app from the command line: + +```bash +dotnet build YourApp.csproj -f net10.0-ios -r iossimulator-arm64 \ + -p:CodesignRequireProvisioningProfile=false +``` + +For device builds, use `ios-arm64` as the runtime identifier and supply your provisioning profile. Pass signing properties: `-p:CodesignKey="Apple Distribution: ..." -p:CodesignProvision="YourProfileName"`. For more information, see [iOS device provisioning](device-provisioning/index.md). + +### Simulator testing + +After deploying, long-press the home screen and tap **+** to add a widget. Your widget appears in the gallery under the app name. + +> [!TIP] +> If you make changes to the widget's Swift code, delete the `WidgetExtensions` directory and rebuild to ensure the latest `.appex` is generated. + +## Troubleshooting + +| Problem | Solution | +|---|---| +| Widget doesn't appear in the widget gallery. | Verify the widget extension's bundle identifier is a child of the app's bundle identifier (for example, `com.yourcompany.yourapp.SimpleWidgetExtension`). | +| Data isn't syncing between app and widget. | Confirm the App Group identifier matches exactly in both the app's and the widget extension's entitlements files. | +| `UserDefaults(suiteName:)` returns stale or nil data. | Switch to file-based I/O through the App Group container directory. See the [SharedStorage.swift](#sharedstorageswift--file-io-via-app-group) section. | +| Widget buttons don't respond to taps. | Don't wrap `Button(intent:)` inside a `widgetURL()` modifier or `Link`. These intercept all taps and prevent the intent from running. | +| App Group doesn't work on simulator after build. | Verify your `Entitlements.plist` includes the correct App Group identifier and that the entitlements are configured in your project properties. | +| Build fails with entitlements parsing error. | Ensure your `Entitlements.plist` files use LF line endings, not CRLF. Some editors and source control systems convert line endings automatically. | +| `xcodebuild` can't find the scheme. | If you use `xcodegen`, make sure `project.yml` defines the `SimpleWidgetExtension` scheme, or run `xcodegen generate` manually in the `widget` directory. | + +## See also + +- [How to Build iOS Widgets with .NET MAUI](https://devblogs.microsoft.com/dotnet/how-to-build-ios-widgets-with-dotnet-maui/) — blog post with a complete working example. +- [Maui.Apple.PlatformFeature.Samples on GitHub](https://github.com/Redth/Maui.Apple.PlatformFeature.Samples) — the sample project this guide is based on. +- [Apple WidgetKit documentation](https://developer.apple.com/documentation/widgetkit) +- [Apple App Groups documentation](https://developer.apple.com/documentation/bundleresources/entitlements/com_apple_security_application-groups)