diff --git a/README.md b/README.md index 5983e68..cd00b37 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@

Missing a feature? We’ll build the plugin for you 💪

-Create WidgetKit, ActivityKit, and Android widget experiences from Capacitor without forcing one rendering model. +Create Home Screen WidgetKit, ActivityKit, and Android widget experiences from Capacitor without forcing one rendering model. ## Demo @@ -14,7 +14,7 @@ Create WidgetKit, ActivityKit, and Android widget experiences from Capacitor wit The plugin has two implementation paths: -- SVG template activities: store SVG layouts, optional named frames, hotspots, declarative state patches, pause/resume timers, and interaction events. Use this when your widget can be driven by resolved SVG output. +- SVG template widgets: store Home Screen, Lock Screen, Dynamic Island, and Android layouts with optional named frames, hotspots, declarative state patches, pause/resume timers, and interaction events. Use this when your widget can be driven by resolved SVG output. - Full-native widget sessions: store shared JSON state for native widget code and queue app-to-widget or widget-to-app messages. Use this when you want to render the widget fully in Swift/Kotlin/Java but still need Capacitor to start, stop, sync, or process async work. The included workout flow is only an example helper built on top of the generic SVG abstraction. @@ -29,7 +29,8 @@ bunx cap sync android ## iOS Requirements -- iOS 17+ is recommended for interactive Live Activity buttons. +- Add a Widget Extension target for Home Screen / SpringBoard widgets. +- iOS 17+ is recommended for interactive Home Screen widget and Live Activity buttons. - Add `NSSupportsLiveActivities` to the app `Info.plist` when using ActivityKit. - Add the same App Group to the app target and the widget extension target. - Set `CapgoWidgetKitAppGroup` in both `Info.plist` files to the shared App Group identifier. @@ -47,7 +48,9 @@ The plugin ships the native pieces a widget extension or Android widget can use: - `CapgoTemplateActivityAttributes` for the iOS Live Activity bridge - `CapgoTemplateActionIntent` for interactive iOS template buttons +- `CapgoTemplateWidgetTimelineProvider` and `CapgoTemplateHomeWidgetView` for real iOS Home Screen / SpringBoard widgets backed by WidgetKit timelines - `CapgoTemplateWidgetBridge` to load a stored SVG activity and resolve one surface into `svg + width/height + frameId + hotspots + metadata` +- `CapgoTemplateSurfaceView`, `CapgoTemplateWidgetSurface`, and `CapgoTemplateLatestWidgetSurface` to place a rendered SVG view under native hotspot buttons in SwiftUI - `CapgoNativeWidgetBridge` to load full-native widget sessions and exchange async messages without using SVG templates - `CapgoTemplateActionReceiver` and `CapgoTemplateWidgetBridge` for Android template widgets @@ -62,23 +65,53 @@ import CapgoWidgetKitPlugin @main struct ExampleWidgetBundle: WidgetBundle { var body: some Widget { + ExampleTemplateHomeWidget() + if #available(iOS 16.2, *) { ExampleTemplateLiveActivityWidget() } } } + +struct ExampleTemplateHomeWidget: Widget { + private let kind = "ExampleTemplateHomeWidget" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: CapgoTemplateWidgetTimelineProvider()) { entry in + CapgoTemplateHomeWidgetView(entry: entry) { layout in + MySvgRenderer(svg: layout.svg) + } placeholder: { + Text("No active widget") + } + } + .configurationDisplayName("Capgo Template") + .description("Home Screen widget rendered from the shared Capgo SVG template store.") + .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) + } +} +``` + +Use the SwiftUI surface helpers when a custom WidgetKit view should be designed from JS but rendered by native widget code: + +```swift +CapgoTemplateLatestWidgetSurface { + layout in + MySvgRenderer(svg: layout.svg) +} placeholder: { + Text("No active widget") +} ``` -See `example-app/widget-extension/ExampleWidgetBundle.swift` for a complete scaffold. The sample intentionally uses a placeholder card so you can plug in your own SVG renderer while keeping the same bridge and action intent wiring. +The helper positions `CapgoTemplateActionIntent` buttons over the resolved SVG hotspots on iOS 17+. See `example-app/widget-extension/ExampleWidgetBundle.swift` for a complete Home Screen widget and Live Activity scaffold. ## SVG Template Usage -This mode is for widgets that can render resolved SVG. Hotspot actions can switch frames, mutate state, pause/play timers, and emit events for the app to process later. +This mode is for Home Screen, Lock Screen, Dynamic Island, and Android widgets that can render resolved SVG. Hotspot actions can switch frames, mutate state, pause/play timers, and emit events for the app to process later. ```ts import { CapgoWidgetKit } from '@capgo/capacitor-widget-kit'; -const { activity } = await CapgoWidgetKit.startTemplateActivity({ +const { activity } = await CapgoWidgetKit.startTemplateWidget({ activityId: 'session-1', openUrl: 'widgetkitdemo://session/session-1', state: { @@ -93,7 +126,7 @@ const { activity } = await CapgoWidgetKit.startTemplateActivity({ { id: 'next-frame', eventName: 'widget.frame.changed', - frameMutations: [{ op: 'next', path: 'frame', surface: 'lockScreen' }], + frameMutations: [{ op: 'next', path: 'frame', surface: 'homeScreen' }], }, { id: 'toggle-rest', @@ -102,7 +135,7 @@ const { activity } = await CapgoWidgetKit.startTemplateActivity({ }, ], layouts: { - lockScreen: { + homeScreen: { width: 100, height: 40, frameIdPath: 'state.frame', @@ -173,12 +206,12 @@ await CapgoWidgetKit.stopWidgetSession({ widgetId: session.widgetId }); The `example-app/` folder is a lightweight Vite demo for the generic template flow. It runs in the browser using the preview store and demonstrates: -- starting one SVG template activity -- resolving the lock-screen surface +- starting one SVG template widget for the Home Screen surface +- resolving the Home Screen surface - running an action from the app and from a hotspot overlay -- reading the stored activity back +- reading the stored widget template back - reading and acknowledging the event log -- ending the activity +- ending the stored template The workout helper is only used there as an example template factory. @@ -188,6 +221,7 @@ The workout helper is only used there as an example template factory. * [`areActivitiesSupported()`](#areactivitiessupported) * [`startTemplateActivity(...)`](#starttemplateactivity) +* [`startTemplateWidget(...)`](#starttemplatewidget) * [`updateTemplateActivity(...)`](#updatetemplateactivity) * [`endTemplateActivity(...)`](#endtemplateactivity) * [`performTemplateAction(...)`](#performtemplateaction) @@ -204,6 +238,7 @@ The workout helper is only used there as an example template factory. * [`listWidgetMessages(...)`](#listwidgetmessages) * [`acknowledgeWidgetMessages(...)`](#acknowledgewidgetmessages) * [`completeWidgetMessage(...)`](#completewidgetmessage) +* [`reloadWidgets(...)`](#reloadwidgets) * [`getPluginVersion()`](#getpluginversion) * [Interfaces](#interfaces) * [Type Aliases](#type-aliases) @@ -215,14 +250,15 @@ The workout helper is only used there as an example template factory. Capacitor bridge for an iOS-first WidgetKit / Live Activities plugin. -The core abstraction is a generic SVG template activity: +The core abstraction is a generic SVG template record: - raw SVG templates with binding placeholders - declarative action patches - timer bindings exposed to the template scope - event logging so the host app can process button results later The plugin owns shared persistence, declarative action execution, and event retrieval. -The host widget extension keeps full freedom over actual WidgetKit rendering. +The host widget extension keeps full freedom over actual WidgetKit rendering, including +Home Screen / SpringBoard widgets backed by WidgetKit timelines. Full-native widgets can use widget sessions for synchronous shared state and widget messages for asynchronous app/widget jobs without adopting the SVG template renderer. @@ -257,6 +293,23 @@ Persist a generic SVG template activity and start the matching native Live Activ -------------------- +### startTemplateWidget(...) + +```typescript +startTemplateWidget(options: StartTemplateWidgetOptions) => Promise +``` + +Persist a generic SVG template for Home Screen / SpringBoard widgets without starting a Live Activity. + +| Param | Type | +| ------------- | --------------------------------------------------------------------------------- | +| **`options`** | StartTemplateWidgetOptions | + +**Returns:** Promise<StartTemplateActivityResult> + +-------------------- + + ### updateTemplateActivity(...) ```typescript @@ -513,6 +566,21 @@ Complete or fail an async widget bridge message. -------------------- +### reloadWidgets(...) + +```typescript +reloadWidgets(options?: ReloadWidgetsOptions | undefined) => Promise +``` + +Ask native widgets to reload after external app state changes. + +| Param | Type | +| ------------- | --------------------------------------------------------------------- | +| **`options`** | ReloadWidgetsOptions | + +-------------------- + + ### getPluginVersion() ```typescript @@ -582,13 +650,14 @@ Generic SVG template definition stored by the plugin. Bundle of optional WidgetKit surface layouts. -| Prop | Type | Description | -| ---------------------------------- | --------------------------------------------------------------- | ------------------------------------------------ | -| **`lockScreen`** | SvgTemplateLayout | Primary lock-screen / banner layout. | -| **`dynamicIslandExpanded`** | SvgTemplateLayout | Optional expanded Dynamic Island layout. | -| **`dynamicIslandCompactLeading`** | SvgTemplateLayout | Optional compact leading Dynamic Island layout. | -| **`dynamicIslandCompactTrailing`** | SvgTemplateLayout | Optional compact trailing Dynamic Island layout. | -| **`dynamicIslandMinimal`** | SvgTemplateLayout | Optional minimal Dynamic Island layout. | +| Prop | Type | Description | +| ---------------------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | +| **`homeScreen`** | SvgTemplateLayout | Optional Home Screen / SpringBoard widget layout. When omitted, native Home Screen widgets may fall back to `lockScreen`. | +| **`lockScreen`** | SvgTemplateLayout | Optional lock-screen / Live Activity banner layout. | +| **`dynamicIslandExpanded`** | SvgTemplateLayout | Optional expanded Dynamic Island layout. | +| **`dynamicIslandCompactLeading`** | SvgTemplateLayout | Optional compact leading Dynamic Island layout. | +| **`dynamicIslandCompactTrailing`** | SvgTemplateLayout | Optional compact trailing Dynamic Island layout. | +| **`dynamicIslandMinimal`** | SvgTemplateLayout | Optional minimal Dynamic Island layout. | #### SvgTemplateLayoutWithSvg @@ -740,6 +809,22 @@ Persisted timer runtime state. Options for starting a generic SVG template activity. +| Prop | Type | Description | +| ----------------------- | ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **`activityId`** | string | Optional explicit activity identifier. When omitted, the native runtime creates one. | +| **`definition`** | SvgTemplateDefinition | Generic SVG template definition. | +| **`state`** | SvgTemplateState | Initial JSON state exposed under `state.*`. | +| **`openUrl`** | string | Optional deep link used when the widget body is tapped. | +| **`startLiveActivity`** | boolean | Whether iOS should also start a native Live Activity. Defaults to `true`. Set to `false` when the same SVG template should only back a home-screen or lock-screen widget surface. | + + +#### StartTemplateWidgetOptions + +Options for starting or replacing a Home Screen / SpringBoard widget template. + +This persists the same SVG template record as `startTemplateActivity`, but native iOS +implementations do not start an ActivityKit Live Activity. + | Prop | Type | Description | | ---------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | | **`activityId`** | string | Optional explicit activity identifier. When omitted, the native runtime creates one. | @@ -1046,6 +1131,15 @@ Options for completing an async widget bridge message. | **`error`** | string | Optional error string. When set, the message status becomes `failed`. | +#### ReloadWidgetsOptions + +Options for forcing installed native widgets to reload their timeline. + +| Prop | Type | Description | +| ---------- | ------------------- | ---------------------------------------------------------------------------------------------- | +| **`kind`** | string | Optional native widget kind to reload on iOS. When omitted, every widget timeline is reloaded. | + + #### PluginVersionResult Result payload for plugin version queries. @@ -1090,7 +1184,7 @@ JSON-safe array used as activity state. Named WidgetKit surface for one SVG layout variant. -'lockScreen' | 'dynamicIslandExpanded' | 'dynamicIslandCompactLeading' | 'dynamicIslandCompactTrailing' | 'dynamicIslandMinimal' +'homeScreen' | 'lockScreen' | 'dynamicIslandExpanded' | 'dynamicIslandCompactLeading' | 'dynamicIslandCompactTrailing' | 'dynamicIslandMinimal' #### SvgTemplateState diff --git a/android/src/main/java/app/capgo/widgetkit/CapgoWidgetKit.java b/android/src/main/java/app/capgo/widgetkit/CapgoWidgetKit.java index b429611..de45e1d 100644 --- a/android/src/main/java/app/capgo/widgetkit/CapgoWidgetKit.java +++ b/android/src/main/java/app/capgo/widgetkit/CapgoWidgetKit.java @@ -185,6 +185,11 @@ public JSONObject completeWidgetMessage(final String messageId, final JSONObject return message; } + public void reloadWidgets(final String kind) { + notifyStoreChanged(null, kind); + notifyWidgetBridgeChanged(null, null, kind); + } + public JSONObject getPluginVersion() throws JSONException { String versionName = "android"; try { @@ -199,13 +204,25 @@ public JSONObject getPluginVersion() throws JSONException { } private void notifyStoreChanged(final String activityId) { - final Intent intent = new Intent(CapgoWidgetKitConstants.ACTION_TEMPLATE_STORE_CHANGED) - .setPackage(context.getPackageName()) - .putExtra(CapgoWidgetKitConstants.EXTRA_ACTIVITY_ID, activityId); + notifyStoreChanged(activityId, null); + } + + private void notifyStoreChanged(final String activityId, final String kind) { + final Intent intent = new Intent(CapgoWidgetKitConstants.ACTION_TEMPLATE_STORE_CHANGED).setPackage(context.getPackageName()); + if (activityId != null) { + intent.putExtra(CapgoWidgetKitConstants.EXTRA_ACTIVITY_ID, activityId); + } + if (kind != null) { + intent.putExtra(CapgoWidgetKitConstants.EXTRA_WIDGET_KIND, kind); + } context.sendBroadcast(intent); } private void notifyWidgetBridgeChanged(final String widgetId, final String messageId) { + notifyWidgetBridgeChanged(widgetId, messageId, null); + } + + private void notifyWidgetBridgeChanged(final String widgetId, final String messageId, final String kind) { final Intent intent = new Intent(CapgoWidgetKitConstants.ACTION_NATIVE_WIDGET_BRIDGE_CHANGED).setPackage(context.getPackageName()); if (widgetId != null) { intent.putExtra(CapgoWidgetKitConstants.EXTRA_WIDGET_ID, widgetId); @@ -213,6 +230,9 @@ private void notifyWidgetBridgeChanged(final String widgetId, final String messa if (messageId != null) { intent.putExtra(CapgoWidgetKitConstants.EXTRA_MESSAGE_ID, messageId); } + if (kind != null) { + intent.putExtra(CapgoWidgetKitConstants.EXTRA_WIDGET_KIND, kind); + } context.sendBroadcast(intent); } } diff --git a/android/src/main/java/app/capgo/widgetkit/CapgoWidgetKitConstants.java b/android/src/main/java/app/capgo/widgetkit/CapgoWidgetKitConstants.java index 648f439..6340119 100644 --- a/android/src/main/java/app/capgo/widgetkit/CapgoWidgetKitConstants.java +++ b/android/src/main/java/app/capgo/widgetkit/CapgoWidgetKitConstants.java @@ -10,6 +10,7 @@ public final class CapgoWidgetKitConstants { public static final String EXTRA_ACTION_ID = "actionId"; public static final String EXTRA_SOURCE_ID = "sourceId"; public static final String EXTRA_PAYLOAD_JSON = "payloadJson"; + public static final String EXTRA_WIDGET_KIND = "widgetKind"; public static final String EXTRA_WIDGET_ID = "widgetId"; public static final String EXTRA_MESSAGE_ID = "messageId"; public static final String ACTIVITY_IDS_KEY = "activityIds"; diff --git a/android/src/main/java/app/capgo/widgetkit/CapgoWidgetKitPlugin.java b/android/src/main/java/app/capgo/widgetkit/CapgoWidgetKitPlugin.java index 1ee427e..c6a7421 100644 --- a/android/src/main/java/app/capgo/widgetkit/CapgoWidgetKitPlugin.java +++ b/android/src/main/java/app/capgo/widgetkit/CapgoWidgetKitPlugin.java @@ -32,6 +32,15 @@ public void areActivitiesSupported(final PluginCall call) { @PluginMethod public void startTemplateActivity(final PluginCall call) { + startTemplateRecord(call); + } + + @PluginMethod + public void startTemplateWidget(final PluginCall call) { + startTemplateRecord(call); + } + + private void startTemplateRecord(final PluginCall call) { final JSObject definition = call.getObject("definition"); final JSObject state = call.getObject("state"); @@ -347,6 +356,12 @@ public void completeWidgetMessage(final PluginCall call) { } } + @PluginMethod + public void reloadWidgets(final PluginCall call) { + implementation.reloadWidgets(call.getString("kind")); + call.resolve(); + } + @PluginMethod public void getPluginVersion(final PluginCall call) { try { diff --git a/android/src/main/java/app/capgo/widgetkit/TemplateRuntime.java b/android/src/main/java/app/capgo/widgetkit/TemplateRuntime.java index 723c39d..f477f40 100644 --- a/android/src/main/java/app/capgo/widgetkit/TemplateRuntime.java +++ b/android/src/main/java/app/capgo/widgetkit/TemplateRuntime.java @@ -167,7 +167,7 @@ static JSONArray acknowledgeEvents(final JSONArray events, final String activity static JSONObject resolveSurface(final JSONObject record, final String surface, final long nowMs) throws JSONException { final JSONObject layouts = record.getJSONObject("definition").getJSONObject("layouts"); - final JSONObject layout = layouts.optJSONObject(surface); + final JSONObject layout = layoutForSurface(layouts, surface); if (layout == null) { return null; } @@ -431,7 +431,7 @@ private static JSONArray frameIdsForMutation(final JSONObject activity, final JS return new JSONArray(); } - final JSONObject layout = activity.getJSONObject("definition").getJSONObject("layouts").optJSONObject(surface); + final JSONObject layout = layoutForSurface(activity.getJSONObject("definition").getJSONObject("layouts"), surface); final JSONArray frames = layout != null ? TemplateJsonUtils.arrayOrEmpty(layout, "frames") : new JSONArray(); final JSONArray frameIds = new JSONArray(); for (int index = 0; index < frames.length(); index += 1) { @@ -443,6 +443,14 @@ private static JSONArray frameIdsForMutation(final JSONObject activity, final JS return frameIds; } + private static JSONObject layoutForSurface(final JSONObject layouts, final String surface) { + final JSONObject layout = layouts.optJSONObject(surface); + if (layout != null) { + return layout; + } + return "homeScreen".equals(surface) ? layouts.optJSONObject("lockScreen") : null; + } + private static int indexOfString(final JSONArray values, final String target) { for (int index = 0; index < values.length(); index += 1) { if (target.equals(values.optString(index))) { diff --git a/example-app/android/app/src/main/java/app/capgo/widgetkit/exampleapp/TemplateSampleWidgetProvider.java b/example-app/android/app/src/main/java/app/capgo/widgetkit/exampleapp/TemplateSampleWidgetProvider.java index 8417a13..63d9bcb 100644 --- a/example-app/android/app/src/main/java/app/capgo/widgetkit/exampleapp/TemplateSampleWidgetProvider.java +++ b/example-app/android/app/src/main/java/app/capgo/widgetkit/exampleapp/TemplateSampleWidgetProvider.java @@ -18,7 +18,7 @@ public class TemplateSampleWidgetProvider extends AppWidgetProvider { - private static final String SURFACE_LOCK_SCREEN = "lockScreen"; + private static final String SURFACE_HOME_SCREEN = "homeScreen"; private static final int PREVIEW_WIDTH_PX = 720; private static final int PREVIEW_HEIGHT_PX = 360; @@ -65,7 +65,7 @@ private RemoteViews buildViews(final Context context) { return views; } - final JSONObject layout = bridge.resolveLayout(activity.optString("activityId"), SURFACE_LOCK_SCREEN); + final JSONObject layout = bridge.resolveLayout(activity.optString("activityId"), SURFACE_HOME_SCREEN); if (layout == null) { renderEmptyState(views); return views; diff --git a/example-app/ios/App/ExampleWidgetExtension/ExampleWidgetBundle.swift b/example-app/ios/App/ExampleWidgetExtension/ExampleWidgetBundle.swift index 142a2a0..17f32b1 100644 --- a/example-app/ios/App/ExampleWidgetExtension/ExampleWidgetBundle.swift +++ b/example-app/ios/App/ExampleWidgetExtension/ExampleWidgetBundle.swift @@ -6,12 +6,31 @@ import CapgoWidgetKitPlugin @main struct ExampleWidgetBundle: WidgetBundle { var body: some Widget { + ExampleTemplateHomeWidget() + if #available(iOS 16.2, *) { ExampleTemplateLiveActivityWidget() } } } +struct ExampleTemplateHomeWidget: Widget { + private let kind = "ExampleTemplateHomeWidget" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: CapgoTemplateWidgetTimelineProvider()) { entry in + CapgoTemplateHomeWidgetView(entry: entry) { layout in + ExampleTemplateSurfaceCard(layout: layout) + } placeholder: { + ExampleTemplateSurfaceCard(layout: nil) + } + } + .configurationDisplayName("Capgo Template") + .description("Home Screen widget rendered from the shared Capgo SVG template store.") + .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) + } +} + @available(iOS 16.2, *) struct ExampleTemplateLiveActivityWidget: Widget { private let bridge = CapgoTemplateWidgetBridge() diff --git a/example-app/src/main.js b/example-app/src/main.js index 0069ae1..4d60093 100644 --- a/example-app/src/main.js +++ b/example-app/src/main.js @@ -281,11 +281,11 @@ const renderPreview = () => { return; } - const resolvedLayout = resolveTemplateSurface(currentActivity, 'lockScreen'); + const resolvedLayout = resolveTemplateSurface(currentActivity, 'homeScreen'); if (!resolvedLayout) { preview.innerHTML = `
-

The lock screen surface is missing from this activity.

+

The home screen surface is missing from this activity.

`; updateActionState(); @@ -341,7 +341,7 @@ const checkSupport = async () => { const startTemplate = async () => { try { - const result = await CapgoWidgetKit.startTemplateActivity(createWorkoutTemplateActivity(sampleSession())); + const result = await CapgoWidgetKit.startTemplateWidget(createWorkoutTemplateActivity(sampleSession())); currentActivity = result.activity; currentEvents = []; setActivityBadge(currentActivity); diff --git a/example-app/widget-extension/ExampleWidgetBundle.swift b/example-app/widget-extension/ExampleWidgetBundle.swift index 142a2a0..17f32b1 100644 --- a/example-app/widget-extension/ExampleWidgetBundle.swift +++ b/example-app/widget-extension/ExampleWidgetBundle.swift @@ -6,12 +6,31 @@ import CapgoWidgetKitPlugin @main struct ExampleWidgetBundle: WidgetBundle { var body: some Widget { + ExampleTemplateHomeWidget() + if #available(iOS 16.2, *) { ExampleTemplateLiveActivityWidget() } } } +struct ExampleTemplateHomeWidget: Widget { + private let kind = "ExampleTemplateHomeWidget" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: CapgoTemplateWidgetTimelineProvider()) { entry in + CapgoTemplateHomeWidgetView(entry: entry) { layout in + ExampleTemplateSurfaceCard(layout: layout) + } placeholder: { + ExampleTemplateSurfaceCard(layout: nil) + } + } + .configurationDisplayName("Capgo Template") + .description("Home Screen widget rendered from the shared Capgo SVG template store.") + .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) + } +} + @available(iOS 16.2, *) struct ExampleTemplateLiveActivityWidget: Widget { private let bridge = CapgoTemplateWidgetBridge() diff --git a/ios/Sources/CapgoWidgetKitPlugin/CapgoTemplateHomeWidget.swift b/ios/Sources/CapgoWidgetKitPlugin/CapgoTemplateHomeWidget.swift new file mode 100644 index 0000000..effb550 --- /dev/null +++ b/ios/Sources/CapgoWidgetKitPlugin/CapgoTemplateHomeWidget.swift @@ -0,0 +1,117 @@ +#if canImport(SwiftUI) && canImport(WidgetKit) +import Foundation +import OSLog +import SwiftUI +import WidgetKit + +@available(iOS 16.0, *) +public struct CapgoTemplateWidgetEntry: TimelineEntry, Sendable { + public let date: Date + public let layout: CapgoResolvedTemplateLayout? + + public init(date: Date, layout: CapgoResolvedTemplateLayout?) { + self.date = date + self.layout = layout + } +} + +@available(iOS 16.0, *) +public struct CapgoTemplateWidgetTimelineProvider: TimelineProvider { + public typealias Entry = CapgoTemplateWidgetEntry + + private let surface: CapgoTemplateSurface + private let status: String? + private let bundle: Bundle + private let refreshInterval: TimeInterval + private let bridge: CapgoTemplateWidgetBridge + + public init( + surface: CapgoTemplateSurface = .homeScreen, + status: String? = "active", + bundle: Bundle = .main, + refreshInterval: TimeInterval = 15 * 60, + bridge: CapgoTemplateWidgetBridge = CapgoTemplateWidgetBridge() + ) { + self.surface = surface + self.status = status + self.bundle = bundle + self.refreshInterval = refreshInterval + self.bridge = bridge + } + + public func placeholder(in context: Context) -> CapgoTemplateWidgetEntry { + CapgoTemplateWidgetEntry(date: Date(), layout: nil) + } + + public func getSnapshot(in context: Context, completion: @escaping (CapgoTemplateWidgetEntry) -> Void) { + completion(entry(date: Date())) + } + + public func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + let date = Date() + let entry = entry(date: date) + let nextReload = date.addingTimeInterval(max(refreshInterval, 60)) + completion(Timeline(entries: [entry], policy: .after(nextReload))) + } + + private func entry(date: Date) -> CapgoTemplateWidgetEntry { + do { + let layout = try bridge.resolveLatestLayout(surface: surface, status: status, bundle: bundle, now: date) + return CapgoTemplateWidgetEntry(date: date, layout: layout) + } catch { + logTemplateHomeWidgetResolutionFailure(error, surface: surface, bundle: bundle, status: status) + return CapgoTemplateWidgetEntry(date: date, layout: nil) + } + } +} + +@available(iOS 16.0, *) +public struct CapgoTemplateHomeWidgetView: View { + private let entry: CapgoTemplateWidgetEntry + private let showsHotspots: Bool + private let svgContent: (CapgoResolvedTemplateLayout) -> SVGContent + private let placeholder: () -> Placeholder + + public init( + entry: CapgoTemplateWidgetEntry, + showsHotspots: Bool = true, + @ViewBuilder svgContent: @escaping (CapgoResolvedTemplateLayout) -> SVGContent, + @ViewBuilder placeholder: @escaping () -> Placeholder + ) { + self.entry = entry + self.showsHotspots = showsHotspots + self.svgContent = svgContent + self.placeholder = placeholder + } + + public var body: some View { + CapgoTemplateSurfaceView( + layout: entry.layout, + showsHotspots: showsHotspots, + svgContent: svgContent, + placeholder: placeholder + ) + .widgetURL(entry.layout?.openUrl.flatMap(URL.init(string:))) + } +} + +@available(iOS 16.0, *) +private enum CapgoTemplateHomeWidgetLog { + static let logger = Logger(subsystem: "app.capgo.widgetkit", category: "TemplateHomeWidget") +} + +@available(iOS 16.0, *) +private func logTemplateHomeWidgetResolutionFailure( + _ error: Error, + surface: CapgoTemplateSurface, + bundle: Bundle, + status: String? +) { + let statusContext = status ?? "any" + let bundleIdentifier = bundle.bundleIdentifier ?? "unknown" + let context = "status=\(statusContext) surface=\(surface.rawValue) bundle=\(bundleIdentifier)" + CapgoTemplateHomeWidgetLog.logger.error( + "Failed to resolve Home Screen widget template \(context, privacy: .public): \(error.localizedDescription, privacy: .public)" + ) +} +#endif diff --git a/ios/Sources/CapgoWidgetKitPlugin/CapgoTemplateWidgetBridge.swift b/ios/Sources/CapgoWidgetKitPlugin/CapgoTemplateWidgetBridge.swift index 87dab87..f242fa5 100644 --- a/ios/Sources/CapgoWidgetKitPlugin/CapgoTemplateWidgetBridge.swift +++ b/ios/Sources/CapgoWidgetKitPlugin/CapgoTemplateWidgetBridge.swift @@ -2,6 +2,7 @@ import Foundation public enum CapgoTemplateSurface: String, CaseIterable, Sendable { + case homeScreen case lockScreen case dynamicIslandExpanded case dynamicIslandCompactLeading @@ -102,6 +103,19 @@ public final class CapgoTemplateWidgetBridge { try TemplateActivityStore.make(bundle: bundle).listEnvelopes() } + public func latestActivity(status: String? = "active", bundle: Bundle = .main) throws -> StoredTemplateActivityEnvelope? { + try listActivities(bundle: bundle) + .filter { envelope in + guard let status else { + return true + } + return envelope.status == status + } + .max { left, right in + left.updatedAtMs < right.updatedAtMs + } + } + public func resolveLayout( activityId: String, surface: CapgoTemplateSurface, @@ -113,10 +127,9 @@ public final class CapgoTemplateWidgetBridge { } let record = try TemplateRuntimeRecord(envelope: envelope) - let layoutKey = surface.rawValue guard let layouts = record.definition["layouts"] as? [String: Any], - let layoutObject = layouts[layoutKey] as? [String: Any] + let layoutObject = layoutObject(for: surface, in: layouts) else { return nil } @@ -143,6 +156,28 @@ public final class CapgoTemplateWidgetBridge { ) } + public func resolveLatestLayout( + surface: CapgoTemplateSurface, + status: String? = "active", + bundle: Bundle = .main, + now: Date = Date() + ) throws -> CapgoResolvedTemplateLayout? { + guard let envelope = try latestActivity(status: status, bundle: bundle) else { + return nil + } + return try resolveLayout(activityId: envelope.activityId, surface: surface, bundle: bundle, now: now) + } + + private func layoutObject(for surface: CapgoTemplateSurface, in layouts: [String: Any]) -> [String: Any]? { + if let layout = layouts[surface.rawValue] as? [String: Any] { + return layout + } + if surface == .homeScreen { + return layouts[CapgoTemplateSurface.lockScreen.rawValue] as? [String: Any] + } + return nil + } + private func parseHotspots(_ value: Any?) -> [CapgoTemplateResolvedHotspot] { guard let hotspots = value as? [[String: Any]] else { return [] diff --git a/ios/Sources/CapgoWidgetKitPlugin/CapgoTemplateWidgetViews.swift b/ios/Sources/CapgoWidgetKitPlugin/CapgoTemplateWidgetViews.swift new file mode 100644 index 0000000..297b01b --- /dev/null +++ b/ios/Sources/CapgoWidgetKitPlugin/CapgoTemplateWidgetViews.swift @@ -0,0 +1,288 @@ +#if canImport(SwiftUI) +import Foundation +import OSLog +import SwiftUI +#if canImport(AppIntents) +import AppIntents +#endif + +@available(iOS 16.0, *) +public struct CapgoTemplateClearHotspotLabel: View { + public init() {} + + public var body: some View { + Rectangle() + .fill(Color.clear) + .contentShape(Rectangle()) + } +} + +@available(iOS 16.0, *) +public struct CapgoTemplateHotspotButton: View { + public let activityId: String + public let hotspot: CapgoTemplateResolvedHotspot + private let label: () -> Label + + public init( + activityId: String, + hotspot: CapgoTemplateResolvedHotspot, + @ViewBuilder label: @escaping () -> Label + ) { + self.activityId = activityId + self.hotspot = hotspot + self.label = label + } + + public var body: some View { + Group { + #if canImport(AppIntents) + if #available(iOS 17.0, *) { + Button( + intent: CapgoTemplateActionIntent( + activityId: activityId, + actionId: hotspot.actionId, + sourceId: hotspot.id, + payloadJSON: hotspot.payloadJSON + ) + ) { + label() + } + .buttonStyle(.plain) + } else { + fallbackLabel + } + #else + fallbackLabel + #endif + } + .accessibilityLabel(Text(hotspot.label ?? hotspot.actionId)) + } + + private var fallbackLabel: some View { + label() + .allowsHitTesting(false) + } +} + +@available(iOS 16.0, *) +public extension CapgoTemplateHotspotButton where Label == CapgoTemplateClearHotspotLabel { + init(activityId: String, hotspot: CapgoTemplateResolvedHotspot) { + self.init(activityId: activityId, hotspot: hotspot) { + CapgoTemplateClearHotspotLabel() + } + } +} + +@available(iOS 16.0, *) +public struct CapgoTemplateSurfaceView: View { + public let layout: CapgoResolvedTemplateLayout? + public let showsHotspots: Bool + private let svgContent: (CapgoResolvedTemplateLayout) -> SVGContent + private let placeholder: () -> Placeholder + + public init( + layout: CapgoResolvedTemplateLayout?, + showsHotspots: Bool = true, + @ViewBuilder svgContent: @escaping (CapgoResolvedTemplateLayout) -> SVGContent, + @ViewBuilder placeholder: @escaping () -> Placeholder + ) { + self.layout = layout + self.showsHotspots = showsHotspots + self.svgContent = svgContent + self.placeholder = placeholder + } + + public var body: some View { + Group { + if let layout { + surface(layout) + } else { + placeholder() + } + } + } + + private func surface(_ layout: CapgoResolvedTemplateLayout) -> some View { + GeometryReader { proxy in + ZStack(alignment: .topLeading) { + svgContent(layout) + .frame(maxWidth: .infinity, maxHeight: .infinity) + + if showsHotspots { + ForEach(layout.hotspots, id: \.id) { hotspot in + CapgoTemplateHotspotButton(activityId: layout.activityId, hotspot: hotspot) + .frame( + width: hotspotWidth(hotspot, in: proxy.size, layout: layout), + height: hotspotHeight(hotspot, in: proxy.size, layout: layout) + ) + .position( + x: hotspotCenterX(hotspot, in: proxy.size, layout: layout), + y: hotspotCenterY(hotspot, in: proxy.size, layout: layout) + ) + } + } + } + } + .aspectRatio(CGFloat(layout.width / max(layout.height, 1)), contentMode: .fit) + } + + private func hotspotWidth( + _ hotspot: CapgoTemplateResolvedHotspot, + in size: CGSize, + layout: CapgoResolvedTemplateLayout + ) -> CGFloat { + max(1, size.width * CGFloat(hotspot.width / max(layout.width, 1))) + } + + private func hotspotHeight( + _ hotspot: CapgoTemplateResolvedHotspot, + in size: CGSize, + layout: CapgoResolvedTemplateLayout + ) -> CGFloat { + max(1, size.height * CGFloat(hotspot.height / max(layout.height, 1))) + } + + private func hotspotCenterX( + _ hotspot: CapgoTemplateResolvedHotspot, + in size: CGSize, + layout: CapgoResolvedTemplateLayout + ) -> CGFloat { + size.width * CGFloat((hotspot.x + hotspot.width / 2) / max(layout.width, 1)) + } + + private func hotspotCenterY( + _ hotspot: CapgoTemplateResolvedHotspot, + in size: CGSize, + layout: CapgoResolvedTemplateLayout + ) -> CGFloat { + size.height * CGFloat((hotspot.y + hotspot.height / 2) / max(layout.height, 1)) + } +} + +@available(iOS 16.0, *) +public struct CapgoTemplateWidgetSurface: View { + private let activityId: String + private let surface: CapgoTemplateSurface + private let bundle: Bundle + private let showsHotspots: Bool + private let now: () -> Date + private let svgContent: (CapgoResolvedTemplateLayout) -> SVGContent + private let placeholder: () -> Placeholder + + public init( + activityId: String, + surface: CapgoTemplateSurface = .lockScreen, + bundle: Bundle = .main, + showsHotspots: Bool = true, + now: @escaping () -> Date = { Date() }, + @ViewBuilder svgContent: @escaping (CapgoResolvedTemplateLayout) -> SVGContent, + @ViewBuilder placeholder: @escaping () -> Placeholder + ) { + self.activityId = activityId + self.surface = surface + self.bundle = bundle + self.showsHotspots = showsHotspots + self.now = now + self.svgContent = svgContent + self.placeholder = placeholder + } + + public var body: some View { + CapgoTemplateSurfaceView( + layout: resolvedLayout, + showsHotspots: showsHotspots, + svgContent: svgContent, + placeholder: placeholder + ) + } + + private var resolvedLayout: CapgoResolvedTemplateLayout? { + do { + return try CapgoTemplateWidgetBridge().resolveLayout( + activityId: activityId, + surface: surface, + bundle: bundle, + now: now() + ) + } catch { + logTemplateWidgetResolutionFailure(error, surface: surface, bundle: bundle, activityId: activityId) + return nil + } + } +} + +@available(iOS 16.0, *) +public struct CapgoTemplateLatestWidgetSurface: View { + private let surface: CapgoTemplateSurface + private let status: String? + private let bundle: Bundle + private let showsHotspots: Bool + private let now: () -> Date + private let svgContent: (CapgoResolvedTemplateLayout) -> SVGContent + private let placeholder: () -> Placeholder + + public init( + surface: CapgoTemplateSurface = .lockScreen, + status: String? = "active", + bundle: Bundle = .main, + showsHotspots: Bool = true, + now: @escaping () -> Date = { Date() }, + @ViewBuilder svgContent: @escaping (CapgoResolvedTemplateLayout) -> SVGContent, + @ViewBuilder placeholder: @escaping () -> Placeholder + ) { + self.surface = surface + self.status = status + self.bundle = bundle + self.showsHotspots = showsHotspots + self.now = now + self.svgContent = svgContent + self.placeholder = placeholder + } + + public var body: some View { + CapgoTemplateSurfaceView( + layout: resolvedLayout, + showsHotspots: showsHotspots, + svgContent: svgContent, + placeholder: placeholder + ) + } + + private var resolvedLayout: CapgoResolvedTemplateLayout? { + do { + return try CapgoTemplateWidgetBridge().resolveLatestLayout( + surface: surface, + status: status, + bundle: bundle, + now: now() + ) + } catch { + logTemplateWidgetResolutionFailure(error, surface: surface, bundle: bundle, status: status) + return nil + } + } +} + +@available(iOS 16.0, *) +private enum CapgoTemplateWidgetSurfaceLog { + static let logger = Logger(subsystem: "app.capgo.widgetkit", category: "TemplateWidgetSurface") +} + +@available(iOS 16.0, *) +private func logTemplateWidgetResolutionFailure( + _ error: Error, + surface: CapgoTemplateSurface, + bundle: Bundle, + activityId: String? = nil, + status: String? = nil +) { + let activityContext = activityId ?? "latest" + let statusContext = status ?? "any" + let bundleIdentifier = bundle.bundleIdentifier ?? "unknown" + let context = + "activityId=\(activityContext) status=\(statusContext) surface=\(surface.rawValue) bundle=\(bundleIdentifier)" + CapgoTemplateWidgetSurfaceLog.logger.error( + "Failed to resolve template widget surface \(context, privacy: .public): \(error.localizedDescription, privacy: .public)" + ) +} +#endif diff --git a/ios/Sources/CapgoWidgetKitPlugin/CapgoWidgetKit.swift b/ios/Sources/CapgoWidgetKitPlugin/CapgoWidgetKit.swift index bc9e32a..35cc0a2 100644 --- a/ios/Sources/CapgoWidgetKitPlugin/CapgoWidgetKit.swift +++ b/ios/Sources/CapgoWidgetKitPlugin/CapgoWidgetKit.swift @@ -1,4 +1,7 @@ import Foundation +#if canImport(WidgetKit) +import WidgetKit +#endif enum CapgoWidgetKitBridgeError: LocalizedError { case missingObject(String) @@ -30,13 +33,15 @@ public final class CapgoWidgetKit { activityId: String?, definitionObject: [String: Any], stateObject: [String: Any], - openUrl: String? + openUrl: String?, + startLiveActivity: Bool ) async throws -> [String: Any] { let envelope = try await TemplateLiveActivityManager.shared.start( activityId: activityId, definitionObject: definitionObject, stateObject: stateObject, - openUrl: openUrl + openUrl: openUrl, + startLiveActivity: startLiveActivity ) return ["activity": try TemplateRuntime.serializeActivity(envelope)] } @@ -120,6 +125,7 @@ public final class CapgoWidgetKit { stateObject: stateObject ?? [:], metadataObject: metadataObject ) + reloadWidgets(kind: nil) return ["session": try CapgoNativeWidgetBridge.serializeSession(session)] } @@ -138,11 +144,13 @@ public final class CapgoWidgetKit { guard let session else { return ["session": NSNull()] } + reloadWidgets(kind: nil) return ["session": try CapgoNativeWidgetBridge.serializeSession(session)] } public func stopWidgetSession(widgetId: String, stateObject: [String: Any]?) throws { try CapgoNativeWidgetBridge().stopSession(widgetId: widgetId, stateObject: stateObject) + reloadWidgets(kind: nil) } public func getWidgetSession(widgetId: String) throws -> [String: Any] { @@ -172,6 +180,7 @@ public final class CapgoWidgetKit { payloadObject: payloadObject, expectsResponse: expectsResponse ) + reloadWidgets(kind: nil) return ["message": try CapgoNativeWidgetBridge.serializeMessage(message)] } @@ -196,6 +205,7 @@ public final class CapgoWidgetKit { widgetId: widgetId, direction: direction.flatMap(CapgoWidgetMessageDirection.init(rawValue:)) ) + reloadWidgets(kind: nil) } public func completeWidgetMessage(messageId: String, responseObject: [String: Any]?, error: String?) throws -> [String: Any] { @@ -207,9 +217,24 @@ public final class CapgoWidgetKit { guard let message else { return ["message": NSNull()] } + reloadWidgets(kind: nil) return ["message": try CapgoNativeWidgetBridge.serializeMessage(message)] } + public func reloadWidgets(kind: String?) { + #if canImport(WidgetKit) + if #available(iOS 14.0, *) { + if let kind, !kind.isEmpty { + WidgetCenter.shared.reloadTimelines(ofKind: kind) + } else { + WidgetCenter.shared.reloadAllTimelines() + } + } + #else + _ = kind + #endif + } + public func getPluginVersion() -> [String: Any] { [ "version": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "ios" diff --git a/ios/Sources/CapgoWidgetKitPlugin/CapgoWidgetKitPlugin.swift b/ios/Sources/CapgoWidgetKitPlugin/CapgoWidgetKitPlugin.swift index bd75aef..45b70f2 100644 --- a/ios/Sources/CapgoWidgetKitPlugin/CapgoWidgetKitPlugin.swift +++ b/ios/Sources/CapgoWidgetKitPlugin/CapgoWidgetKitPlugin.swift @@ -8,6 +8,7 @@ public class CapgoWidgetKitPlugin: CAPPlugin, CAPBridgedPlugin { public let pluginMethods: [CAPPluginMethod] = [ CAPPluginMethod(name: "areActivitiesSupported", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "startTemplateActivity", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "startTemplateWidget", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "updateTemplateActivity", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "endTemplateActivity", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "performTemplateAction", returnType: CAPPluginReturnPromise), @@ -24,6 +25,7 @@ public class CapgoWidgetKitPlugin: CAPPlugin, CAPBridgedPlugin { CAPPluginMethod(name: "listWidgetMessages", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "acknowledgeWidgetMessages", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "completeWidgetMessage", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "reloadWidgets", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "getPluginVersion", returnType: CAPPluginReturnPromise) ] @@ -34,6 +36,14 @@ public class CapgoWidgetKitPlugin: CAPPlugin, CAPBridgedPlugin { } @objc func startTemplateActivity(_ call: CAPPluginCall) { + startTemplateRecord(call, startLiveActivity: call.getBool("startLiveActivity") ?? true) + } + + @objc func startTemplateWidget(_ call: CAPPluginCall) { + startTemplateRecord(call, startLiveActivity: false) + } + + private func startTemplateRecord(_ call: CAPPluginCall, startLiveActivity: Bool) { guard let definition = call.getObject("definition") else { call.reject(CapgoWidgetKitBridgeError.missingObject("definition").localizedDescription) return @@ -50,7 +60,8 @@ public class CapgoWidgetKitPlugin: CAPPlugin, CAPBridgedPlugin { activityId: call.getString("activityId"), definitionObject: definition, stateObject: state, - openUrl: call.getString("openUrl") + openUrl: call.getString("openUrl"), + startLiveActivity: startLiveActivity ) call.resolve(payload) } catch { @@ -316,6 +327,11 @@ public class CapgoWidgetKitPlugin: CAPPlugin, CAPBridgedPlugin { } } + @objc func reloadWidgets(_ call: CAPPluginCall) { + implementation.reloadWidgets(kind: call.getString("kind")) + call.resolve() + } + @objc func getPluginVersion(_ call: CAPPluginCall) { call.resolve(implementation.getPluginVersion()) } diff --git a/ios/Sources/CapgoWidgetKitPlugin/TemplateLiveActivityManager.swift b/ios/Sources/CapgoWidgetKitPlugin/TemplateLiveActivityManager.swift index 69ae32d..0aae210 100644 --- a/ios/Sources/CapgoWidgetKitPlugin/TemplateLiveActivityManager.swift +++ b/ios/Sources/CapgoWidgetKitPlugin/TemplateLiveActivityManager.swift @@ -2,6 +2,9 @@ import ActivityKit #endif import Foundation +#if canImport(WidgetKit) +import WidgetKit +#endif public struct TemplateActivityInfo: Hashable, Sendable { public let activityId: String @@ -55,7 +58,8 @@ public final class TemplateLiveActivityManager { activityId requestedActivityId: String?, definitionObject: [String: Any], stateObject: [String: Any], - openUrl: String? + openUrl: String?, + startLiveActivity: Bool = true ) async throws -> StoredTemplateActivityEnvelope { let nowMs = Int64(Date().timeIntervalSince1970 * 1000) let activityId: String @@ -74,7 +78,7 @@ public final class TemplateLiveActivityManager { let store = try TemplateActivityStore.make() #if canImport(ActivityKit) - if #available(iOS 16.2, *) { + if #available(iOS 16.2, *), startLiveActivity { let activity = try Activity.request( attributes: CapgoTemplateActivityAttributes( activityId: activityId, @@ -91,6 +95,7 @@ public final class TemplateLiveActivityManager { let envelope = try record.toEnvelope() try store.saveEnvelope(envelope) + reloadWidgetTimelines() return envelope } @@ -123,6 +128,7 @@ public final class TemplateLiveActivityManager { let envelope = try record.toEnvelope() try store.saveEnvelope(envelope) try await updateNativeActivity(for: envelope) + reloadWidgetTimelines() return envelope } @@ -150,6 +156,7 @@ public final class TemplateLiveActivityManager { #endif store.removeNativeActivityId(for: activityId) + reloadWidgetTimelines() } public func performAction( @@ -177,6 +184,7 @@ public final class TemplateLiveActivityManager { try store.saveEnvelope(envelope) try store.appendEvent(event) try await updateNativeActivity(for: envelope) + reloadWidgetTimelines() return (envelope, event) } @@ -209,6 +217,15 @@ public final class TemplateLiveActivityManager { activityId: activityId, acknowledgedAtMs: Int64(Date().timeIntervalSince1970 * 1000) ) + reloadWidgetTimelines() + } + + private func reloadWidgetTimelines() { + #if canImport(WidgetKit) + if #available(iOS 14.0, *) { + WidgetCenter.shared.reloadAllTimelines() + } + #endif } #if canImport(ActivityKit) diff --git a/package.json b/package.json index 5d3fcea..76423c3 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@capgo/capacitor-widget-kit", "version": "8.1.2", "private": false, - "description": "Capacitor plugin for generic iOS WidgetKit and Live Activities using SVG templates, declarative actions, and shared App Group persistence.", + "description": "Capacitor plugin for generic iOS Home Screen widgets, WidgetKit, and Live Activities using SVG templates, declarative actions, and shared App Group persistence.", "main": "dist/plugin.cjs.js", "module": "dist/esm/index.js", "types": "dist/esm/index.d.ts", @@ -30,6 +30,7 @@ "capacitor", "plugin", "widgetkit", + "home-screen-widgets", "live-activities", "activitykit", "ios" diff --git a/src/definitions.ts b/src/definitions.ts index dfb062f..5830397 100644 --- a/src/definitions.ts +++ b/src/definitions.ts @@ -222,9 +222,16 @@ export type SvgTemplateLayout = SvgTemplateLayoutWithSvg | SvgTemplateLayoutWith */ export interface SvgTemplateLayouts { /** - * Primary lock-screen / banner layout. + * Optional Home Screen / SpringBoard widget layout. + * + * When omitted, native Home Screen widgets may fall back to `lockScreen`. + */ + homeScreen?: SvgTemplateLayout; + + /** + * Optional lock-screen / Live Activity banner layout. */ - lockScreen: SvgTemplateLayout; + lockScreen?: SvgTemplateLayout; /** * Optional expanded Dynamic Island layout. @@ -251,6 +258,7 @@ export interface SvgTemplateLayouts { * Named WidgetKit surface for one SVG layout variant. */ export type SvgTemplateSurface = + | 'homeScreen' | 'lockScreen' | 'dynamicIslandExpanded' | 'dynamicIslandCompactLeading' @@ -703,6 +711,42 @@ export interface StartTemplateActivityOptions { * Optional deep link used when the widget body is tapped. */ openUrl?: string; + + /** + * Whether iOS should also start a native Live Activity. + * + * Defaults to `true`. Set to `false` when the same SVG template should only back + * a home-screen or lock-screen widget surface. + */ + startLiveActivity?: boolean; +} + +/** + * Options for starting or replacing a Home Screen / SpringBoard widget template. + * + * This persists the same SVG template record as `startTemplateActivity`, but native iOS + * implementations do not start an ActivityKit Live Activity. + */ +export interface StartTemplateWidgetOptions { + /** + * Optional explicit activity identifier. When omitted, the native runtime creates one. + */ + activityId?: string; + + /** + * Generic SVG template definition. + */ + definition: SvgTemplateDefinition; + + /** + * Initial JSON state exposed under `state.*`. + */ + state: SvgTemplateState; + + /** + * Optional deep link used when the widget body is tapped. + */ + openUrl?: string; } /** @@ -1217,17 +1261,28 @@ export interface CompleteWidgetMessageOptions { error?: string; } +/** + * Options for forcing installed native widgets to reload their timeline. + */ +export interface ReloadWidgetsOptions { + /** + * Optional native widget kind to reload on iOS. When omitted, every widget timeline is reloaded. + */ + kind?: string; +} + /** * Capacitor bridge for an iOS-first WidgetKit / Live Activities plugin. * - * The core abstraction is a generic SVG template activity: + * The core abstraction is a generic SVG template record: * - raw SVG templates with binding placeholders * - declarative action patches * - timer bindings exposed to the template scope * - event logging so the host app can process button results later * * The plugin owns shared persistence, declarative action execution, and event retrieval. - * The host widget extension keeps full freedom over actual WidgetKit rendering. + * The host widget extension keeps full freedom over actual WidgetKit rendering, including + * Home Screen / SpringBoard widgets backed by WidgetKit timelines. * * Full-native widgets can use widget sessions for synchronous shared state and widget messages * for asynchronous app/widget jobs without adopting the SVG template renderer. @@ -1243,6 +1298,11 @@ export interface CapgoWidgetKitPlugin { */ startTemplateActivity(options: StartTemplateActivityOptions): Promise; + /** + * Persist a generic SVG template for Home Screen / SpringBoard widgets without starting a Live Activity. + */ + startTemplateWidget(options: StartTemplateWidgetOptions): Promise; + /** * Replace part or all of the stored activity definition/state. */ @@ -1323,6 +1383,11 @@ export interface CapgoWidgetKitPlugin { */ completeWidgetMessage(options: CompleteWidgetMessageOptions): Promise; + /** + * Ask native widgets to reload after external app state changes. + */ + reloadWidgets(options?: ReloadWidgetsOptions): Promise; + /** * Return the platform implementation version marker. */ diff --git a/src/helpers/workout.ts b/src/helpers/workout.ts index 441c563..d0a2355 100644 --- a/src/helpers/workout.ts +++ b/src/helpers/workout.ts @@ -125,6 +125,34 @@ export function createWorkoutTemplateActivity(session: WorkoutTemplateSession): }, ], layouts: { + homeScreen: { + width: 160, + height: 160, + hotspots: [ + { + id: 'complete-button', + actionId: 'complete-set', + x: 104, + y: 116, + width: 38, + height: 28, + label: 'Complete active set', + role: 'button', + }, + ], + svg: ` + + + + {{state.session.title}} + {{state.currentExercise.title}} + {{state.currentExercise.subtitle}} + {{state.currentSet.title}} + {{timers.rest.remainingText}} + + +`.trim(), + }, lockScreen: { width: 100, height: 40, diff --git a/src/runtime.ts b/src/runtime.ts index bb6393e..1c93272 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -543,7 +543,7 @@ function frameIdsForMutation(activity: SvgTemplateActivityRecord, mutation: SvgT return []; } - return activity.definition.layouts[surface]?.frames?.map((frame) => frame.id) ?? []; + return getTemplateLayout(activity, surface)?.frames?.map((frame) => frame.id) ?? []; } function normalizeFrameMutationId(frameId: string | undefined, frameIds: string[]): string | undefined { @@ -745,7 +745,10 @@ export function getTemplateLayout( activity: SvgTemplateActivityRecord, surface: SvgTemplateSurface, ): SvgTemplateLayout | null { - return activity.definition.layouts[surface] ?? null; + return ( + activity.definition.layouts[surface] ?? + (surface === 'homeScreen' ? (activity.definition.layouts.lockScreen ?? null) : null) + ); } export function resolveTemplateSurface( diff --git a/src/web.ts b/src/web.ts index 26d891b..00a4d6a 100644 --- a/src/web.ts +++ b/src/web.ts @@ -19,9 +19,11 @@ import type { PerformTemplateActionOptions, PerformTemplateActionResult, PluginVersionResult, + ReloadWidgetsOptions, SendWidgetMessageOptions, StartTemplateActivityOptions, StartTemplateActivityResult, + StartTemplateWidgetOptions, StartWidgetSessionOptions, StartWidgetSessionResult, StopWidgetSessionOptions, @@ -136,7 +138,7 @@ export class CapgoWidgetKitWeb extends WebPlugin implements CapgoWidgetKitPlugin return { supported: false, reason: - 'WidgetKit preview mode only. The generic template runtime works in the browser, but native ActivityKit support is available on iOS 16.2+.', + 'WidgetKit preview mode only. The generic template runtime works in the browser, but Home Screen widgets and ActivityKit require native iOS or Android runtimes.', }; } @@ -151,6 +153,10 @@ export class CapgoWidgetKitWeb extends WebPlugin implements CapgoWidgetKitPlugin return { activity: cloneJson(activity) }; } + async startTemplateWidget(options: StartTemplateWidgetOptions): Promise { + return this.startTemplateActivity(options); + } + async updateTemplateActivity(options: UpdateTemplateActivityOptions): Promise { const store = loadStore(); const current = getActivityOrThrow(store, options.activityId); @@ -413,6 +419,11 @@ export class CapgoWidgetKitWeb extends WebPlugin implements CapgoWidgetKitPlugin return { message: cloneJson(nextMessage) }; } + async reloadWidgets(options?: ReloadWidgetsOptions): Promise { + void options; + return; + } + async getPluginVersion(): Promise { return { version: 'web-preview',