Skip to content
Shiny.NET
Commercial SupportGuaranteed response times on Shiny bug fixes, straight from the maintainers. Plans from US$1,500 a year.See the plans

The iOS Widget Extension

iOS renders a Live Activity from a SwiftUI widget extension inside your app bundle. WidgetKit requires it — there is no way to drive the layout from C#. Shiny.Mobile.LiveActivities builds that extension for you as part of your app’s normal dotnet build: no Xcode project, no Swift to copy, nothing to wire up.

Android needs none of this — it draws a notification, which the library posts itself.

In your app’s .csproj:

<PropertyGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'ios'">
<ShinyLiveActivityWidget>true</ShinyLiveActivityWidget>
</PropertyGroup>

That is the whole setup. On every iOS build the package:

  1. compiles the stock widget with swiftc into PlugIns/ShinyLiveActivity.appex, with bundle id <your-app-bundle-id>.LiveActivity and the app’s own version numbers;
  2. picks a provisioning profile for it and signs it with the identity the app is signed with (device builds);
  3. adds NSSupportsLiveActivities to the app’s Info.plist.

The stock widget renders whatever LiveActivityContent your app or your server sends — title, body, short status and progress on the Lock Screen and in the Dynamic Island — so most apps never touch Swift at all.

It is opt-in so that Android-only and push-only apps never run a Swift build. It needs a Mac with Xcode, as any iOS build does; a build on Windows skips the extension with a warning.

Simulator builds need nothing. On a device the extension is a second bundle, so it needs a provisioning profile for its own bundle id. Shiny looks for one the same way the .NET iOS SDK looks for the app’s:

What Inferred from Override
Extension bundle id the app’s bundle id + .LiveActivity ShinyLiveActivityBundleId
Signing identity the identity the app is signed with — (Apple requires the same team)
Provisioning profile the best installed profile for the bundle id — an exact match beats a team wildcard ShinyLiveActivityCodesignProvision (name or UUID)
Extra entitlements (app groups, …) none ShinyLiveActivityCodesignEntitlements (path to a plist)

In practice:

  • Debug on a device — a team wildcard development profile (Xcode’s iOS Team Provisioning Profile: *) covers <app>.LiveActivity, so it just works.

  • App Store, TestFlight and Ad Hoc — a wildcard App Store profile is picked up too. With explicit profiles, create one for <app>.LiveActivity in the Apple developer portal; once it is installed it is found automatically. Name it when several match, or in CI:

    <PropertyGroup Condition="'$(Configuration)' == 'Release' and $([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'ios'">
    <ShinyLiveActivityCodesignProvision>MyApp LiveActivity AppStore</ShinyLiveActivityCodesignProvision>
    </PropertyGroup>
  • No matching profile is a build error naming the bundle id it looked for. Shipping an unsigned extension would only fail later, at install, with a far less helpful message.

  • A different kind of profile from the app’s (Ad Hoc next to App Store, say) is a build warning — App Store Connect and device installs reject the mix.

The bundle id becomes permanent once you ship: changing it orphans the provisioning profile. Pick the override before your first release if you want a different one.

The stock widget is ordinary SwiftUI. To replace it, add your own Swift files — with a @main WidgetBundle — and Shiny compiles those instead:

<ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'ios'">
<ShinyLiveActivityWidgetSource Include="Platforms/iOS/LiveActivity/*.swift" />
</ItemGroup>
import ActivityKit
import SwiftUI
import WidgetKit
@main
struct MyLiveActivityBundle: WidgetBundle {
var body: some Widget { MyLiveActivityWidget() }
}
struct MyLiveActivityWidget: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: ShinyActivityAttributes.self) { context in
HStack {
Text(context.state.title ?? "").font(.headline)
Spacer()
Text(context.state.data?["orderId"] ?? "")
}
.padding()
} dynamicIsland: { context in
DynamicIsland {
DynamicIslandExpandedRegion(.center) { Text(context.state.body ?? "") }
} compactLeading: {
Image(systemName: "shippingbox")
} compactTrailing: {
Text(context.state.shortStatus ?? "")
} minimal: {
Image(systemName: "shippingbox")
}
}
}
}

Start from the stock widget (templates/WidgetExtension/ShinyLiveActivityWidget.swift in the repo) rather than from scratch. Branch on context.attributes.kind (set from LiveActivityRequest.Kind in C#) for different layouts per activity type, and read your own values out of context.state.data (dynamic) and context.attributes.values (static).

Don’t declare ShinyActivityAttributes yourself. Shiny always compiles its own copy next to your files, so the type your widget renders is exactly the one the app starts — the most common reason a hand-built widget silently stopped updating was those two copies drifting apart.

Your sources are Swift only: there is no asset catalog or localized strings file in the extension, so use SF Symbols and text that arrives in the content.

Property / item Default Purpose
ShinyLiveActivityWidget false Build and embed the widget extension
ShinyLiveActivityBundleId $(ApplicationId).LiveActivity The extension’s bundle id
ShinyLiveActivityCodesignProvision best installed match Provisioning profile for the extension (name or UUID)
ShinyLiveActivityCodesignEntitlements — Extra entitlements plist for the extension
ShinyLiveActivityMinimumOSVersion SupportedOSPlatformVersion, at least 16.2 The extension’s deployment target. An app that supports older iOS still installs there; the extension just doesn’t load
ShinyLiveActivityDisplayName Live Activity The extension’s display name
ShinyLiveActivitySupportsFrequentUpdates false Also adds NSSupportsLiveActivitiesFrequentUpdates — only if you update more than a handful of times an hour
ShinyLiveActivityWidgetSource (item) the stock widget Your own SwiftUI widget sources

LiveActivityContent (C#), ShinyActivityAttributes.ContentState (Swift) and the content-state a server pushes are one contract. A field-name or type drift between them does not throw — ActivityKit decodes the state with a stock JSONDecoder, so a mismatch just makes the update disappear and the activity stops refreshing. The widget build removes the app-versus-widget half of that risk; the server half is still yours. See push tokens and server updates for the wire shape, including the 2001 epoch trap.