diff --git a/apps/ios/ActivityWidget/Assets.xcassets/Contents.json b/apps/ios/ActivityWidget/Assets.xcassets/Contents.json new file mode 100644 index 00000000000..73c00596a7f --- /dev/null +++ b/apps/ios/ActivityWidget/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/ActivityWidget/Info.plist b/apps/ios/ActivityWidget/Info.plist new file mode 100644 index 00000000000..4e12dc4f884 --- /dev/null +++ b/apps/ios/ActivityWidget/Info.plist @@ -0,0 +1,31 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + OpenClaw Activity + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + 2026.3.2 + CFBundleVersion + 20260301 + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + NSSupportsLiveActivities + + + diff --git a/apps/ios/ActivityWidget/OpenClawActivityWidgetBundle.swift b/apps/ios/ActivityWidget/OpenClawActivityWidgetBundle.swift new file mode 100644 index 00000000000..424a97c1982 --- /dev/null +++ b/apps/ios/ActivityWidget/OpenClawActivityWidgetBundle.swift @@ -0,0 +1,9 @@ +import SwiftUI +import WidgetKit + +@main +struct OpenClawActivityWidgetBundle: WidgetBundle { + var body: some Widget { + OpenClawLiveActivity() + } +} diff --git a/apps/ios/ActivityWidget/OpenClawLiveActivity.swift b/apps/ios/ActivityWidget/OpenClawLiveActivity.swift new file mode 100644 index 00000000000..836803f403f --- /dev/null +++ b/apps/ios/ActivityWidget/OpenClawLiveActivity.swift @@ -0,0 +1,84 @@ +import ActivityKit +import SwiftUI +import WidgetKit + +struct OpenClawLiveActivity: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: OpenClawActivityAttributes.self) { context in + lockScreenView(context: context) + } dynamicIsland: { context in + DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + statusDot(state: context.state) + } + DynamicIslandExpandedRegion(.center) { + Text(context.state.statusText) + .font(.subheadline) + .lineLimit(1) + } + DynamicIslandExpandedRegion(.trailing) { + trailingView(state: context.state) + } + } compactLeading: { + statusDot(state: context.state) + } compactTrailing: { + Text(context.state.statusText) + .font(.caption2) + .lineLimit(1) + .frame(maxWidth: 64) + } minimal: { + statusDot(state: context.state) + } + } + } + + @ViewBuilder + private func lockScreenView(context: ActivityViewContext) -> some View { + HStack(spacing: 8) { + statusDot(state: context.state) + .frame(width: 10, height: 10) + VStack(alignment: .leading, spacing: 2) { + Text("OpenClaw") + .font(.subheadline.bold()) + Text(context.state.statusText) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + trailingView(state: context.state) + } + .padding(.vertical, 4) + } + + @ViewBuilder + private func trailingView(state: OpenClawActivityAttributes.ContentState) -> some View { + if state.isConnecting { + ProgressView().controlSize(.small) + } else if state.isDisconnected { + Image(systemName: "wifi.slash") + .foregroundStyle(.red) + } else if state.isIdle { + Image(systemName: "antenna.radiowaves.left.and.right") + .foregroundStyle(.green) + } else { + Text(state.startedAt, style: .timer) + .font(.caption) + .monospacedDigit() + .foregroundStyle(.secondary) + } + } + + @ViewBuilder + private func statusDot(state: OpenClawActivityAttributes.ContentState) -> some View { + Circle() + .fill(dotColor(state: state)) + .frame(width: 6, height: 6) + } + + private func dotColor(state: OpenClawActivityAttributes.ContentState) -> Color { + if state.isDisconnected { return .red } + if state.isConnecting { return .gray } + if state.isIdle { return .green } + return .blue + } +} diff --git a/apps/ios/Config/Signing.xcconfig b/apps/ios/Config/Signing.xcconfig index e0afd46aa7e..1285d2a38a4 100644 --- a/apps/ios/Config/Signing.xcconfig +++ b/apps/ios/Config/Signing.xcconfig @@ -4,6 +4,7 @@ OPENCLAW_IOS_SELECTED_TEAM = $(OPENCLAW_IOS_DEFAULT_TEAM) OPENCLAW_APP_BUNDLE_ID = ai.openclaw.ios OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclaw.ios.watchkitapp OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclaw.ios.watchkitapp.extension +OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclaw.ios.activitywidget // Local contributors can override this by running scripts/ios-configure-signing.sh. // Keep include after defaults: xcconfig is evaluated top-to-bottom. diff --git a/apps/ios/Sources/Info.plist b/apps/ios/Sources/Info.plist index 86556e094b0..b4d6ed3109a 100644 --- a/apps/ios/Sources/Info.plist +++ b/apps/ios/Sources/Info.plist @@ -54,6 +54,8 @@ OpenClaw needs microphone access for voice wake. NSSpeechRecognitionUsageDescription OpenClaw uses on-device speech recognition for voice wake. + NSSupportsLiveActivities + UIApplicationSceneManifest UIApplicationSupportsMultipleScenes diff --git a/apps/ios/Sources/LiveActivity/LiveActivityManager.swift b/apps/ios/Sources/LiveActivity/LiveActivityManager.swift new file mode 100644 index 00000000000..b7be7597e35 --- /dev/null +++ b/apps/ios/Sources/LiveActivity/LiveActivityManager.swift @@ -0,0 +1,125 @@ +import ActivityKit +import Foundation +import os + +/// Minimal Live Activity lifecycle focused on connection health + stale cleanup. +@MainActor +final class LiveActivityManager { + static let shared = LiveActivityManager() + + private let logger = Logger(subsystem: "ai.openclaw.ios", category: "LiveActivity") + private var currentActivity: Activity? + private var activityStartDate: Date = .now + + private init() { + self.hydrateCurrentAndPruneDuplicates() + } + + var isActive: Bool { + guard let activity = self.currentActivity else { return false } + guard activity.activityState == .active else { + self.currentActivity = nil + return false + } + return true + } + + func startActivity(agentName: String, sessionKey: String) { + self.hydrateCurrentAndPruneDuplicates() + + if self.currentActivity != nil { + self.handleConnecting() + return + } + + let authInfo = ActivityAuthorizationInfo() + guard authInfo.areActivitiesEnabled else { + self.logger.info("Live Activities disabled; skipping start") + return + } + + self.activityStartDate = .now + let attributes = OpenClawActivityAttributes(agentName: agentName, sessionKey: sessionKey) + + do { + let activity = try Activity.request( + attributes: attributes, + content: ActivityContent(state: self.connectingState(), staleDate: nil), + pushType: nil) + self.currentActivity = activity + self.logger.info("started live activity id=\(activity.id, privacy: .public)") + } catch { + self.logger.error("failed to start live activity: \(error.localizedDescription, privacy: .public)") + } + } + + func handleConnecting() { + self.updateCurrent(state: self.connectingState()) + } + + func handleReconnect() { + self.updateCurrent(state: self.idleState()) + } + + func handleDisconnect() { + self.updateCurrent(state: self.disconnectedState()) + } + + private func hydrateCurrentAndPruneDuplicates() { + let active = Activity.activities + guard !active.isEmpty else { + self.currentActivity = nil + return + } + + let keeper = active.max { lhs, rhs in + lhs.content.state.startedAt < rhs.content.state.startedAt + } ?? active[0] + + self.currentActivity = keeper + self.activityStartDate = keeper.content.state.startedAt + + let stale = active.filter { $0.id != keeper.id } + for activity in stale { + Task { + await activity.end( + ActivityContent(state: self.disconnectedState(), staleDate: nil), + dismissalPolicy: .immediate) + } + } + } + + private func updateCurrent(state: OpenClawActivityAttributes.ContentState) { + guard let activity = self.currentActivity else { return } + Task { + await activity.update(ActivityContent(state: state, staleDate: nil)) + } + } + + private func connectingState() -> OpenClawActivityAttributes.ContentState { + OpenClawActivityAttributes.ContentState( + statusText: "Connecting...", + isIdle: false, + isDisconnected: false, + isConnecting: true, + startedAt: self.activityStartDate) + } + + private func idleState() -> OpenClawActivityAttributes.ContentState { + OpenClawActivityAttributes.ContentState( + statusText: "Idle", + isIdle: true, + isDisconnected: false, + isConnecting: false, + startedAt: self.activityStartDate) + } + + private func disconnectedState() -> OpenClawActivityAttributes.ContentState { + OpenClawActivityAttributes.ContentState( + statusText: "Disconnected", + isIdle: false, + isDisconnected: true, + isConnecting: false, + startedAt: self.activityStartDate) + } +} diff --git a/apps/ios/Sources/LiveActivity/OpenClawActivityAttributes.swift b/apps/ios/Sources/LiveActivity/OpenClawActivityAttributes.swift new file mode 100644 index 00000000000..d9d879c84b5 --- /dev/null +++ b/apps/ios/Sources/LiveActivity/OpenClawActivityAttributes.swift @@ -0,0 +1,45 @@ +import ActivityKit +import Foundation + +/// Shared schema used by iOS app + Live Activity widget extension. +struct OpenClawActivityAttributes: ActivityAttributes { + var agentName: String + var sessionKey: String + + struct ContentState: Codable, Hashable { + var statusText: String + var isIdle: Bool + var isDisconnected: Bool + var isConnecting: Bool + var startedAt: Date + } +} + +#if DEBUG +extension OpenClawActivityAttributes { + static let preview = OpenClawActivityAttributes(agentName: "main", sessionKey: "main") +} + +extension OpenClawActivityAttributes.ContentState { + static let connecting = OpenClawActivityAttributes.ContentState( + statusText: "Connecting...", + isIdle: false, + isDisconnected: false, + isConnecting: true, + startedAt: .now) + + static let idle = OpenClawActivityAttributes.ContentState( + statusText: "Idle", + isIdle: true, + isDisconnected: false, + isConnecting: false, + startedAt: .now) + + static let disconnected = OpenClawActivityAttributes.ContentState( + statusText: "Disconnected", + isIdle: false, + isDisconnected: true, + isConnecting: false, + startedAt: .now) +} +#endif diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index 54548eb8d96..34826aefeaf 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -1695,6 +1695,7 @@ extension NodeAppModel { self.operatorGatewayTask = nil self.voiceWakeSyncTask?.cancel() self.voiceWakeSyncTask = nil + LiveActivityManager.shared.handleDisconnect() self.gatewayHealthMonitor.stop() Task { await self.operatorGateway.disconnect() @@ -1731,6 +1732,7 @@ private extension NodeAppModel { self.operatorConnected = false self.voiceWakeSyncTask?.cancel() self.voiceWakeSyncTask = nil + LiveActivityManager.shared.handleDisconnect() self.gatewayDefaultAgentId = nil self.gatewayAgents = [] self.selectedAgentId = GatewaySettingsStore.loadGatewaySelectedAgentId(stableID: stableID) @@ -1811,6 +1813,7 @@ private extension NodeAppModel { await self.refreshAgentsFromGateway() await self.refreshShareRouteFromGateway() await self.startVoiceWakeSync() + await MainActor.run { LiveActivityManager.shared.handleReconnect() } await MainActor.run { self.startGatewayHealthMonitor() } }, onDisconnected: { [weak self] reason in @@ -1818,6 +1821,7 @@ private extension NodeAppModel { await MainActor.run { self.operatorConnected = false self.talkMode.updateGatewayConnected(false) + LiveActivityManager.shared.handleDisconnect() } GatewayDiagnostics.log("operator gateway disconnected reason=\(reason)") await MainActor.run { self.stopGatewayHealthMonitor() } @@ -1882,6 +1886,14 @@ private extension NodeAppModel { self.gatewayStatusText = (attempt == 0) ? "Connecting…" : "Reconnecting…" self.gatewayServerName = nil self.gatewayRemoteAddress = nil + let liveActivity = LiveActivityManager.shared + if liveActivity.isActive { + liveActivity.handleConnecting() + } else { + liveActivity.startActivity( + agentName: self.selectedAgentId ?? "main", + sessionKey: self.mainSessionKey) + } } do { diff --git a/apps/ios/SwiftSources.input.xcfilelist b/apps/ios/SwiftSources.input.xcfilelist index 514ca732673..c94ef48fa32 100644 --- a/apps/ios/SwiftSources.input.xcfilelist +++ b/apps/ios/SwiftSources.input.xcfilelist @@ -62,3 +62,7 @@ Sources/Voice/VoiceWakePreferences.swift ../../Swabble/Sources/SwabbleKit/WakeWordGate.swift Sources/Voice/TalkModeManager.swift Sources/Voice/TalkOrbOverlay.swift +Sources/LiveActivity/OpenClawActivityAttributes.swift +Sources/LiveActivity/LiveActivityManager.swift +ActivityWidget/OpenClawActivityWidgetBundle.swift +ActivityWidget/OpenClawLiveActivity.swift diff --git a/apps/ios/project.yml b/apps/ios/project.yml index 1f3cad955bf..3cc4444ce09 100644 --- a/apps/ios/project.yml +++ b/apps/ios/project.yml @@ -38,6 +38,8 @@ targets: dependencies: - target: OpenClawShareExtension embed: true + - target: OpenClawActivityWidget + embed: true - target: OpenClawWatchApp - package: OpenClawKit - package: OpenClawKit @@ -84,6 +86,7 @@ targets: TARGETED_DEVICE_FAMILY: "1" SWIFT_VERSION: "6.0" SWIFT_STRICT_CONCURRENCY: complete + SUPPORTS_LIVE_ACTIVITIES: YES ENABLE_APPINTENTS_METADATA: NO ENABLE_APP_INTENTS_METADATA_GENERATION: NO info: @@ -115,6 +118,7 @@ targets: NSLocationAlwaysAndWhenInUseUsageDescription: OpenClaw can share your location in the background when you enable Always. NSMicrophoneUsageDescription: OpenClaw needs microphone access for voice wake. NSSpeechRecognitionUsageDescription: OpenClaw uses on-device speech recognition for voice wake. + NSSupportsLiveActivities: true UISupportedInterfaceOrientations: - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown @@ -164,6 +168,37 @@ targets: NSExtensionActivationSupportsImageWithMaxCount: 10 NSExtensionActivationSupportsMovieWithMaxCount: 1 + OpenClawActivityWidget: + type: app-extension + platform: iOS + configFiles: + Debug: Signing.xcconfig + Release: Signing.xcconfig + sources: + - path: ActivityWidget + - path: Sources/LiveActivity/OpenClawActivityAttributes.swift + dependencies: + - sdk: WidgetKit.framework + - sdk: ActivityKit.framework + settings: + base: + CODE_SIGN_IDENTITY: "Apple Development" + CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)" + DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)" + PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID)" + SWIFT_VERSION: "6.0" + SWIFT_STRICT_CONCURRENCY: complete + SUPPORTS_LIVE_ACTIVITIES: YES + info: + path: ActivityWidget/Info.plist + properties: + CFBundleDisplayName: OpenClaw Activity + CFBundleShortVersionString: "2026.3.2" + CFBundleVersion: "20260301" + NSSupportsLiveActivities: true + NSExtension: + NSExtensionPointIdentifier: com.apple.widgetkit-extension + OpenClawWatchApp: type: application.watchapp2 platform: watchOS diff --git a/changelog/fragments/ios-live-activity-status-cleanup.md b/changelog/fragments/ios-live-activity-status-cleanup.md new file mode 100644 index 00000000000..06a6004080f --- /dev/null +++ b/changelog/fragments/ios-live-activity-status-cleanup.md @@ -0,0 +1 @@ +- iOS: add Live Activity connection status (connecting/idle/disconnected) on Lock Screen and Dynamic Island, and clean up duplicate/stale activities before starting a new one (#33591) (thanks @mbelinky, @leepokai)