Merge 1bb3a64dc1940705c6505e352223c5236705036c into 43513cd1df63af0704dfb351ee7864607f955dcc
This commit is contained in:
commit
9f93eb9730
@ -5,81 +5,231 @@ import WidgetKit
|
||||
struct OpenClawLiveActivity: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
ActivityConfiguration(for: OpenClawActivityAttributes.self) { context in
|
||||
lockScreenView(context: context)
|
||||
LockScreenView(context: context)
|
||||
} dynamicIsland: { context in
|
||||
DynamicIsland {
|
||||
// MARK: Expanded
|
||||
DynamicIslandExpandedRegion(.leading) {
|
||||
statusDot(state: context.state)
|
||||
AgentLabel(agentName: context.attributes.agentName)
|
||||
}
|
||||
DynamicIslandExpandedRegion(.center) {
|
||||
Text(context.state.statusText)
|
||||
.font(.subheadline)
|
||||
.lineLimit(1)
|
||||
ExpandedStatusView(state: context.state)
|
||||
}
|
||||
DynamicIslandExpandedRegion(.trailing) {
|
||||
trailingView(state: context.state)
|
||||
TrailingView(state: context.state)
|
||||
}
|
||||
DynamicIslandExpandedRegion(.bottom) {
|
||||
if let task = context.state.taskDescription {
|
||||
Text(task)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
}
|
||||
} compactLeading: {
|
||||
statusDot(state: context.state)
|
||||
// MARK: Compact Leading
|
||||
CompactLeadingView(state: context.state)
|
||||
} compactTrailing: {
|
||||
Text(context.state.statusText)
|
||||
.font(.caption2)
|
||||
.lineLimit(1)
|
||||
.frame(maxWidth: 64)
|
||||
// MARK: Compact Trailing
|
||||
CompactTrailingView(state: context.state)
|
||||
} minimal: {
|
||||
statusDot(state: context.state)
|
||||
// MARK: Minimal
|
||||
MinimalView(state: context.state)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func lockScreenView(context: ActivityViewContext<OpenClawActivityAttributes>) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
statusDot(state: context.state)
|
||||
// MARK: - Lock Screen
|
||||
|
||||
private struct LockScreenView: View {
|
||||
let context: ActivityViewContext<OpenClawActivityAttributes>
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
StatusDot(state: context.state)
|
||||
.frame(width: 10, height: 10)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("OpenClaw")
|
||||
Text(context.attributes.agentName)
|
||||
.font(.subheadline.bold())
|
||||
Text(context.state.statusText)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
if let task = context.state.taskDescription {
|
||||
Text(task)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
} else {
|
||||
Text(context.state.statusText)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
trailingView(state: context.state)
|
||||
TrailingView(state: context.state)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 4)
|
||||
.padding(.vertical, 6)
|
||||
.padding(.horizontal, 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)
|
||||
// MARK: - Expanded views
|
||||
|
||||
private struct AgentLabel: View {
|
||||
let agentName: String
|
||||
|
||||
var body: some View {
|
||||
Label(agentName, systemImage: "cpu")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ExpandedStatusView: View {
|
||||
let state: OpenClawActivityAttributes.ContentState
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if state.isWorking {
|
||||
HStack(spacing: 6) {
|
||||
ProgressView()
|
||||
.controlSize(.mini)
|
||||
.tint(.blue)
|
||||
Text(state.taskDescription ?? "Working…")
|
||||
.font(.subheadline.weight(.medium))
|
||||
.lineLimit(1)
|
||||
}
|
||||
} else {
|
||||
Text(state.statusText)
|
||||
.font(.subheadline)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.95)))
|
||||
.animation(.easeInOut(duration: 0.2), value: state.isWorking)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Compact views
|
||||
|
||||
private struct CompactLeadingView: View {
|
||||
let state: OpenClawActivityAttributes.ContentState
|
||||
|
||||
var body: some View {
|
||||
if state.isWorking {
|
||||
ProgressView()
|
||||
.controlSize(.mini)
|
||||
.tint(.blue)
|
||||
} else {
|
||||
Text(state.startedAt, style: .timer)
|
||||
.font(.caption)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.secondary)
|
||||
StatusDot(state: state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func statusDot(state: OpenClawActivityAttributes.ContentState) -> some View {
|
||||
private struct CompactTrailingView: View {
|
||||
let state: OpenClawActivityAttributes.ContentState
|
||||
|
||||
var body: some View {
|
||||
if state.isWorking, let task = state.taskDescription {
|
||||
Text(task)
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
.frame(maxWidth: 80)
|
||||
} else {
|
||||
Text(state.statusText)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.frame(maxWidth: 64)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct MinimalView: View {
|
||||
let state: OpenClawActivityAttributes.ContentState
|
||||
|
||||
var body: some View {
|
||||
if state.isWorking {
|
||||
ProgressView()
|
||||
.controlSize(.mini)
|
||||
.tint(.blue)
|
||||
} else {
|
||||
StatusDot(state: state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Shared subviews
|
||||
|
||||
private struct TrailingView: View {
|
||||
let state: OpenClawActivityAttributes.ContentState
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if state.isConnecting {
|
||||
ProgressView().controlSize(.small)
|
||||
} else if state.isDisconnected {
|
||||
Image(systemName: "wifi.slash")
|
||||
.foregroundStyle(.red)
|
||||
} else if state.isWorking {
|
||||
// Elapsed timer shows how long the agent has been working.
|
||||
Text(state.startedAt, style: .timer)
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
} else if state.isIdle {
|
||||
Image(systemName: "antenna.radiowaves.left.and.right")
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct StatusDot: View {
|
||||
let state: OpenClawActivityAttributes.ContentState
|
||||
|
||||
var body: some View {
|
||||
Circle()
|
||||
.fill(dotColor(state: state))
|
||||
.fill(dotColor)
|
||||
.frame(width: 6, height: 6)
|
||||
}
|
||||
|
||||
private func dotColor(state: OpenClawActivityAttributes.ContentState) -> Color {
|
||||
private var dotColor: Color {
|
||||
if state.isDisconnected { return .red }
|
||||
if state.isConnecting { return .gray }
|
||||
if state.isWorking { return .blue }
|
||||
if state.isIdle { return .green }
|
||||
return .blue
|
||||
return .secondary
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
#Preview("Compact — Idle", as: .dynamicIsland(.compact), using: OpenClawActivityAttributes.preview) {
|
||||
OpenClawLiveActivity()
|
||||
} contentStates: {
|
||||
OpenClawActivityAttributes.ContentState.idle
|
||||
}
|
||||
|
||||
#Preview("Compact — Working", as: .dynamicIsland(.compact), using: OpenClawActivityAttributes.preview) {
|
||||
OpenClawLiveActivity()
|
||||
} contentStates: {
|
||||
OpenClawActivityAttributes.ContentState.working(task: "Building iOS app…")
|
||||
}
|
||||
|
||||
#Preview("Expanded — Working", as: .dynamicIsland(.expanded), using: OpenClawActivityAttributes.preview) {
|
||||
OpenClawLiveActivity()
|
||||
} contentStates: {
|
||||
OpenClawActivityAttributes.ContentState.working(task: "Running tests…")
|
||||
}
|
||||
|
||||
#Preview("Lock Screen", as: .content, using: OpenClawActivityAttributes.preview) {
|
||||
OpenClawLiveActivity()
|
||||
} contentStates: {
|
||||
OpenClawActivityAttributes.ContentState.working(task: "Capturing screenshot…")
|
||||
OpenClawActivityAttributes.ContentState.idle
|
||||
OpenClawActivityAttributes.ContentState.disconnected
|
||||
}
|
||||
|
||||
@ -434,6 +434,7 @@ enum GatewayDiagnostics {
|
||||
private static let keepLogBytes: Int64 = 256 * 1024
|
||||
private static let logSizeCheckEveryWrites = 50
|
||||
private static let logWritesSinceCheck = OSAllocatedUnfairLock(initialState: 0)
|
||||
|
||||
private static func isoTimestamp() -> String {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
|
||||
@ -11,6 +11,11 @@ final class LiveActivityManager {
|
||||
private var currentActivity: Activity<OpenClawActivityAttributes>?
|
||||
private var activityStartDate: Date = .now
|
||||
|
||||
/// Tracks the last known non-working connection state so `handleWorking(nil)`
|
||||
/// restores the correct state rather than blindly resetting to idle.
|
||||
private enum ConnectionState { case idle, connecting, disconnected }
|
||||
private var lastConnectionState: ConnectionState = .idle
|
||||
|
||||
private init() {
|
||||
self.hydrateCurrentAndPruneDuplicates()
|
||||
}
|
||||
@ -47,6 +52,7 @@ final class LiveActivityManager {
|
||||
content: ActivityContent(state: self.connectingState(), staleDate: nil),
|
||||
pushType: nil)
|
||||
self.currentActivity = activity
|
||||
self.lastConnectionState = .connecting
|
||||
self.logger.info("started live activity id=\(activity.id, privacy: .public)")
|
||||
} catch {
|
||||
self.logger.error("failed to start live activity: \(error.localizedDescription, privacy: .public)")
|
||||
@ -54,17 +60,44 @@ final class LiveActivityManager {
|
||||
}
|
||||
|
||||
func handleConnecting() {
|
||||
self.lastConnectionState = .connecting
|
||||
self.updateCurrent(state: self.connectingState())
|
||||
}
|
||||
|
||||
func handleReconnect() {
|
||||
self.lastConnectionState = .idle
|
||||
self.updateCurrent(state: self.idleState())
|
||||
}
|
||||
|
||||
func handleDisconnect() {
|
||||
self.lastConnectionState = .disconnected
|
||||
self.updateCurrent(state: self.disconnectedState())
|
||||
}
|
||||
|
||||
/// Call when the agent begins processing a task.
|
||||
/// - Parameter task: Short human-readable description (e.g. "Building iOS app…").
|
||||
/// Pass `nil` to complete the task and restore the previous connection state.
|
||||
func handleWorking(task: String?) {
|
||||
if let task {
|
||||
self.updateCurrent(state: self.workingState(task: task))
|
||||
self.logger.info("live activity → working task=\(task, privacy: .public)")
|
||||
} else {
|
||||
// Restore the last known connection state rather than blindly going to idle.
|
||||
// This prevents overwriting a disconnected/connecting state if the connection
|
||||
// changed while the task was running.
|
||||
let restored: OpenClawActivityAttributes.ContentState
|
||||
switch self.lastConnectionState {
|
||||
case .idle: restored = self.idleState()
|
||||
case .connecting: restored = self.connectingState()
|
||||
case .disconnected: restored = self.disconnectedState()
|
||||
}
|
||||
self.updateCurrent(state: restored)
|
||||
self.logger.info("live activity → \(String(describing: self.lastConnectionState)) (task completed)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private helpers
|
||||
|
||||
private func hydrateCurrentAndPruneDuplicates() {
|
||||
let active = Activity<OpenClawActivityAttributes>.activities
|
||||
guard !active.isEmpty else {
|
||||
@ -78,6 +111,12 @@ final class LiveActivityManager {
|
||||
|
||||
self.currentActivity = keeper
|
||||
self.activityStartDate = keeper.content.state.startedAt
|
||||
// Restore lastConnectionState from the hydrated state so handleWorking(nil)
|
||||
// reverts to the correct state after an app restart.
|
||||
let s = keeper.content.state
|
||||
if s.isDisconnected { self.lastConnectionState = .disconnected }
|
||||
else if s.isConnecting { self.lastConnectionState = .connecting }
|
||||
else { self.lastConnectionState = .idle }
|
||||
|
||||
let stale = active.filter { $0.id != keeper.id }
|
||||
for activity in stale {
|
||||
@ -102,15 +141,19 @@ final class LiveActivityManager {
|
||||
isIdle: false,
|
||||
isDisconnected: false,
|
||||
isConnecting: true,
|
||||
isWorking: false,
|
||||
taskDescription: nil,
|
||||
startedAt: self.activityStartDate)
|
||||
}
|
||||
|
||||
private func idleState() -> OpenClawActivityAttributes.ContentState {
|
||||
OpenClawActivityAttributes.ContentState(
|
||||
statusText: "Idle",
|
||||
statusText: "Connected",
|
||||
isIdle: true,
|
||||
isDisconnected: false,
|
||||
isConnecting: false,
|
||||
isWorking: false,
|
||||
taskDescription: nil,
|
||||
startedAt: self.activityStartDate)
|
||||
}
|
||||
|
||||
@ -120,6 +163,21 @@ final class LiveActivityManager {
|
||||
isIdle: false,
|
||||
isDisconnected: true,
|
||||
isConnecting: false,
|
||||
isWorking: false,
|
||||
taskDescription: nil,
|
||||
startedAt: self.activityStartDate)
|
||||
}
|
||||
|
||||
private func workingState(task: String) -> OpenClawActivityAttributes.ContentState {
|
||||
OpenClawActivityAttributes.ContentState(
|
||||
statusText: task,
|
||||
isIdle: false,
|
||||
isDisconnected: false,
|
||||
isConnecting: false,
|
||||
isWorking: true,
|
||||
taskDescription: task,
|
||||
// Use `.now` so the elapsed-time timer in the Dynamic Island measures
|
||||
// task duration, not time since the Live Activity was created.
|
||||
startedAt: .now)
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,18 +6,60 @@ struct OpenClawActivityAttributes: ActivityAttributes {
|
||||
var agentName: String
|
||||
var sessionKey: String
|
||||
|
||||
struct ContentState: Codable, Hashable {
|
||||
struct ContentState: Hashable {
|
||||
var statusText: String
|
||||
var isIdle: Bool
|
||||
var isDisconnected: Bool
|
||||
var isConnecting: Bool
|
||||
/// `true` when the agent is actively processing a task.
|
||||
var isWorking: Bool
|
||||
/// Short description of the current task (e.g. "Building iOS app…", "Searching…").
|
||||
/// Non-nil only when `isWorking` is `true`.
|
||||
var taskDescription: String?
|
||||
var startedAt: Date
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Codable
|
||||
|
||||
/// Custom Codable conformance so new fields (`isWorking`, `taskDescription`) default
|
||||
/// gracefully when decoding persisted Live Activity state from an older app version.
|
||||
extension OpenClawActivityAttributes.ContentState: Codable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case statusText, isIdle, isDisconnected, isConnecting
|
||||
case isWorking, taskDescription, startedAt
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
statusText = try c.decode(String.self, forKey: .statusText)
|
||||
isIdle = try c.decode(Bool.self, forKey: .isIdle)
|
||||
isDisconnected = try c.decode(Bool.self, forKey: .isDisconnected)
|
||||
isConnecting = try c.decode(Bool.self, forKey: .isConnecting)
|
||||
startedAt = try c.decode(Date.self, forKey: .startedAt)
|
||||
// Default to false/nil when decoding persisted state from an older build
|
||||
// that pre-dates these fields — prevents Live Activity decode failures on update.
|
||||
isWorking = try c.decodeIfPresent(Bool.self, forKey: .isWorking) ?? false
|
||||
taskDescription = try c.decodeIfPresent(String.self, forKey: .taskDescription)
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var c = encoder.container(keyedBy: CodingKeys.self)
|
||||
try c.encode(statusText, forKey: .statusText)
|
||||
try c.encode(isIdle, forKey: .isIdle)
|
||||
try c.encode(isDisconnected, forKey: .isDisconnected)
|
||||
try c.encode(isConnecting, forKey: .isConnecting)
|
||||
try c.encode(isWorking, forKey: .isWorking)
|
||||
try c.encode(startedAt, forKey: .startedAt)
|
||||
try c.encodeIfPresent(taskDescription, forKey: .taskDescription)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Debug previews
|
||||
|
||||
#if DEBUG
|
||||
extension OpenClawActivityAttributes {
|
||||
static let preview = OpenClawActivityAttributes(agentName: "main", sessionKey: "main")
|
||||
static let preview = OpenClawActivityAttributes(agentName: "J.A.R.V.I.S.", sessionKey: "main")
|
||||
}
|
||||
|
||||
extension OpenClawActivityAttributes.ContentState {
|
||||
@ -26,13 +68,17 @@ extension OpenClawActivityAttributes.ContentState {
|
||||
isIdle: false,
|
||||
isDisconnected: false,
|
||||
isConnecting: true,
|
||||
isWorking: false,
|
||||
taskDescription: nil,
|
||||
startedAt: .now)
|
||||
|
||||
static let idle = OpenClawActivityAttributes.ContentState(
|
||||
statusText: "Idle",
|
||||
statusText: "Connected",
|
||||
isIdle: true,
|
||||
isDisconnected: false,
|
||||
isConnecting: false,
|
||||
isWorking: false,
|
||||
taskDescription: nil,
|
||||
startedAt: .now)
|
||||
|
||||
static let disconnected = OpenClawActivityAttributes.ContentState(
|
||||
@ -40,6 +86,19 @@ extension OpenClawActivityAttributes.ContentState {
|
||||
isIdle: false,
|
||||
isDisconnected: true,
|
||||
isConnecting: false,
|
||||
isWorking: false,
|
||||
taskDescription: nil,
|
||||
startedAt: .now)
|
||||
|
||||
static func working(task: String) -> OpenClawActivityAttributes.ContentState {
|
||||
OpenClawActivityAttributes.ContentState(
|
||||
statusText: task,
|
||||
isIdle: false,
|
||||
isDisconnected: false,
|
||||
isConnecting: false,
|
||||
isWorking: true,
|
||||
taskDescription: task,
|
||||
startedAt: .now)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@ -1054,7 +1054,11 @@ final class NodeAppModel {
|
||||
}
|
||||
// Status pill mirrors screen recording state so it stays visible without overlay stacking.
|
||||
self.screenRecordActive = true
|
||||
defer { self.screenRecordActive = false }
|
||||
LiveActivityManager.shared.handleWorking(task: "Recording screen…")
|
||||
defer {
|
||||
self.screenRecordActive = false
|
||||
LiveActivityManager.shared.handleWorking(task: nil)
|
||||
}
|
||||
let path = try await self.screenRecorder.record(
|
||||
screenIndex: params.screenIndex,
|
||||
durationMs: params.durationMs,
|
||||
@ -1637,13 +1641,23 @@ private extension NodeAppModel {
|
||||
self.cameraHUDKind = kind
|
||||
}
|
||||
|
||||
// Mirror transient camera/recording activity to the Dynamic Island.
|
||||
LiveActivityManager.shared.handleWorking(task: text)
|
||||
|
||||
guard let autoHideSeconds else { return }
|
||||
self.cameraHUDDismissTask = Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: UInt64(autoHideSeconds * 1_000_000_000))
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: UInt64(autoHideSeconds * 1_000_000_000))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
guard !Task.isCancelled else { return }
|
||||
withAnimation(.easeOut(duration: 0.25)) {
|
||||
self.cameraHUDText = nil
|
||||
self.cameraHUDKind = nil
|
||||
}
|
||||
// Task complete — return Dynamic Island to idle.
|
||||
LiveActivityManager.shared.handleWorking(task: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1941,7 +1955,7 @@ private extension NodeAppModel {
|
||||
liveActivity.handleConnecting()
|
||||
} else {
|
||||
liveActivity.startActivity(
|
||||
agentName: self.selectedAgentId ?? "main",
|
||||
agentName: self.activeAgentName,
|
||||
sessionKey: self.mainSessionKey)
|
||||
}
|
||||
}
|
||||
|
||||
@ -506,6 +506,12 @@ struct OpenClawApp: App {
|
||||
init() {
|
||||
Self.installUncaughtExceptionLogger()
|
||||
GatewaySettingsStore.bootstrapPersistence()
|
||||
// Register App Shortcuts so the system can discover OpenTalkModeIntent
|
||||
// for Siri and the Action Button. Must be called at launch.
|
||||
// Guarded by @available — AppShortcutsProvider requires iOS 16+.
|
||||
if #available(iOS 16, *) {
|
||||
OpenClawShortcuts.updateAppShortcutParameters()
|
||||
}
|
||||
let appModel = NodeAppModel()
|
||||
_appModel = State(initialValue: appModel)
|
||||
_gatewayController = State(initialValue: GatewayConnectionController(appModel: appModel))
|
||||
|
||||
@ -18,6 +18,8 @@ struct RootCanvas: View {
|
||||
@AppStorage("gateway.manual.enabled") private var manualGatewayEnabled: Bool = false
|
||||
@AppStorage("gateway.manual.host") private var manualGatewayHost: String = ""
|
||||
@AppStorage("onboarding.quickSetupDismissed") private var quickSetupDismissed: Bool = false
|
||||
@AppStorage(OpenTalkModeIntent.pendingTalkModeKey) private var pendingTalkMode: Bool = false
|
||||
@AppStorage("talk.enabled") private var talkEnabled: Bool = false
|
||||
@State private var presentedSheet: PresentedSheet?
|
||||
@State private var voiceWakeToastText: String?
|
||||
@State private var toastDismissTask: Task<Void, Never>?
|
||||
@ -191,6 +193,15 @@ struct RootCanvas: View {
|
||||
.onChange(of: self.appModel.openChatRequestID) { _, _ in
|
||||
self.presentedSheet = .chat
|
||||
}
|
||||
.onAppear {
|
||||
// Handle cold-launch: Action Button intent may have set the flag before UI rendered.
|
||||
self.consumePendingTalkMode()
|
||||
}
|
||||
.onChange(of: self.pendingTalkMode) { _, isPending in
|
||||
// Handle warm-launch: flag changed while the app is already running.
|
||||
guard isPending else { return }
|
||||
self.consumePendingTalkMode()
|
||||
}
|
||||
.onChange(of: self.voiceWake.lastTriggeredCommand) { _, newValue in
|
||||
guard let newValue else { return }
|
||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@ -221,6 +232,15 @@ struct RootCanvas: View {
|
||||
GatewayStatusBuilder.build(appModel: self.appModel)
|
||||
}
|
||||
|
||||
private func consumePendingTalkMode() {
|
||||
guard self.pendingTalkMode else { return }
|
||||
self.pendingTalkMode = false
|
||||
// Enable Talk Mode (voice) — not just the chat sheet.
|
||||
self.talkEnabled = true
|
||||
self.appModel.setTalkEnabled(true)
|
||||
self.presentedSheet = .chat
|
||||
}
|
||||
|
||||
private func updateIdleTimer() {
|
||||
UIApplication.shared.isIdleTimerDisabled = (self.scenePhase == .active && self.preventSleep)
|
||||
}
|
||||
|
||||
@ -1,6 +1,12 @@
|
||||
import SwiftUI
|
||||
|
||||
struct RootTabs: View {
|
||||
private enum Tab: Int {
|
||||
case screen = 0
|
||||
case voice = 1
|
||||
case settings = 2
|
||||
}
|
||||
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
@Environment(VoiceWakeManager.self) private var voiceWake
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
@ -33,7 +39,7 @@ struct RootTabs: View {
|
||||
if self.gatewayStatus == .connected {
|
||||
self.showGatewayActions = true
|
||||
} else {
|
||||
self.selectedTab = 2
|
||||
self.selectedTab = Tab.settings.rawValue
|
||||
}
|
||||
})
|
||||
.padding(.leading, 10)
|
||||
@ -73,7 +79,7 @@ struct RootTabs: View {
|
||||
.gatewayActionsDialog(
|
||||
isPresented: self.$showGatewayActions,
|
||||
onDisconnect: { self.appModel.disconnectGateway() },
|
||||
onOpenSettings: { self.selectedTab = 2 })
|
||||
onOpenSettings: { self.selectedTab = Tab.settings.rawValue })
|
||||
}
|
||||
|
||||
private var gatewayStatus: StatusPill.GatewayState {
|
||||
|
||||
56
apps/ios/Sources/Shortcuts/OpenTalkModeIntent.swift
Normal file
56
apps/ios/Sources/Shortcuts/OpenTalkModeIntent.swift
Normal file
@ -0,0 +1,56 @@
|
||||
import AppIntents
|
||||
|
||||
// MARK: - Intent
|
||||
|
||||
/// An App Intent that opens OpenClaw directly in Talk Mode.
|
||||
///
|
||||
/// Assign this intent to the iPhone Action Button via:
|
||||
/// Settings → Action Button → Custom Action → OpenClaw → "Open Talk Mode"
|
||||
///
|
||||
/// It also appears in the Shortcuts app and is accessible via Siri:
|
||||
/// "Hey Siri, open Talk Mode in OpenClaw"
|
||||
///
|
||||
struct OpenTalkModeIntent: AppIntent {
|
||||
static let title: LocalizedStringResource = "Open Talk Mode"
|
||||
static let description = IntentDescription(
|
||||
"Opens OpenClaw and activates Talk Mode for voice interaction.",
|
||||
categoryName: "Communication"
|
||||
)
|
||||
|
||||
/// Bring OpenClaw to the foreground when this intent runs.
|
||||
static let openAppWhenRun: Bool = true
|
||||
|
||||
/// Shared `UserDefaults` key used to signal a pending Talk Mode navigation.
|
||||
/// Follows the app's dot-separated lowercase key convention (e.g. "talk.enabled").
|
||||
/// Referenced by `RootCanvas` via `OpenTalkModeIntent.pendingTalkModeKey`.
|
||||
static let pendingTalkModeKey = "talk.pending-talk-mode"
|
||||
|
||||
func perform() async throws -> some IntentResult {
|
||||
// Signal the app to navigate to Talk Mode.
|
||||
// RootCanvas observes this via @AppStorage and reacts immediately,
|
||||
// including when the app is already in the foreground.
|
||||
await MainActor.run {
|
||||
UserDefaults.standard.set(true, forKey: Self.pendingTalkModeKey)
|
||||
}
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - App Shortcuts
|
||||
|
||||
/// Registers OpenClaw shortcuts so Siri and the Action Button can discover them.
|
||||
/// Requires `ENABLE_APP_INTENTS_METADATA_GENERATION = YES` in build settings.
|
||||
struct OpenClawShortcuts: AppShortcutsProvider {
|
||||
static var appShortcuts: [AppShortcut] {
|
||||
AppShortcut(
|
||||
intent: OpenTalkModeIntent(),
|
||||
phrases: [
|
||||
"Open Talk Mode in \(.applicationName)",
|
||||
"Talk to \(.applicationName)",
|
||||
"Activate \(.applicationName) voice",
|
||||
],
|
||||
shortTitle: "Open Talk Mode",
|
||||
systemImageName: "mic.fill"
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -1,8 +1,52 @@
|
||||
import SwiftUI
|
||||
|
||||
private struct StatusGlassCardModifier: ViewModifier {
|
||||
@Environment(\.colorSchemeContrast) private var contrast
|
||||
// MARK: - Background modifier (OS-adaptive)
|
||||
|
||||
/// Applies Liquid Glass on iOS 26+ or a manual material/stroke/shadow on iOS 18–25.
|
||||
/// Kept separate so the padding in `StatusGlassCardModifier` is written exactly once.
|
||||
///
|
||||
/// Note: `glassEffect(_:in:)` is available in the iOS 26 SDK (Xcode 26+). The
|
||||
/// `#available(iOS 26, *)` check is a *runtime* gate; at compile time the symbol must
|
||||
/// exist in the SDK being used. This file builds correctly when the project is built
|
||||
/// with Xcode 26+ (which ships the iOS 26 SDK). Building with Xcode 16 / iOS 18 SDK
|
||||
/// would require removing or wrapping the iOS 26 branch — document this as a
|
||||
/// minimum toolchain requirement for this feature.
|
||||
private struct StatusGlassBackgroundModifier: ViewModifier {
|
||||
@Environment(\.colorSchemeContrast) private var contrast
|
||||
let brighten: Bool
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if #available(iOS 26, *) {
|
||||
// iOS 26+: native Liquid Glass — the framework handles translucency,
|
||||
// vibrancy, and color adaptation automatically.
|
||||
// The `brighten` hint is not needed on iOS 26.
|
||||
content
|
||||
.glassEffect(
|
||||
.regular,
|
||||
in: RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
)
|
||||
} else {
|
||||
// iOS 18–25: manual material + stroke border + shadow.
|
||||
content
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.fill(.ultraThinMaterial)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.strokeBorder(
|
||||
.white.opacity(self.contrast == .increased ? 0.5 : (self.brighten ? 0.24 : 0.18)),
|
||||
lineWidth: self.contrast == .increased ? 1.0 : 0.5
|
||||
)
|
||||
}
|
||||
.shadow(color: .black.opacity(0.25), radius: 12, y: 6)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Card modifier
|
||||
|
||||
private struct StatusGlassCardModifier: ViewModifier {
|
||||
let brighten: Bool
|
||||
let verticalPadding: CGFloat
|
||||
let horizontalPadding: CGFloat
|
||||
@ -11,21 +55,12 @@ private struct StatusGlassCardModifier: ViewModifier {
|
||||
content
|
||||
.padding(.vertical, self.verticalPadding)
|
||||
.padding(.horizontal, self.horizontalPadding)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.fill(.ultraThinMaterial)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.strokeBorder(
|
||||
.white.opacity(self.contrast == .increased ? 0.5 : (self.brighten ? 0.24 : 0.18)),
|
||||
lineWidth: self.contrast == .increased ? 1.0 : 0.5
|
||||
)
|
||||
}
|
||||
.shadow(color: .black.opacity(0.25), radius: 12, y: 6)
|
||||
}
|
||||
.modifier(StatusGlassBackgroundModifier(brighten: self.brighten))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - View extension
|
||||
|
||||
extension View {
|
||||
func statusGlassCard(brighten: Bool, verticalPadding: CGFloat, horizontalPadding: CGFloat = 12) -> some View {
|
||||
self.modifier(
|
||||
|
||||
@ -95,7 +95,7 @@ struct StatusPill: View {
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Connection Status")
|
||||
.accessibilityValue(self.accessibilityValue)
|
||||
.accessibilityHint("Double tap to open settings")
|
||||
.accessibilityHint(self.gateway == .connected ? "Double tap for connection options" : "Double tap to open settings")
|
||||
.onAppear { self.updatePulse(for: self.gateway, scenePhase: self.scenePhase, reduceMotion: self.reduceMotion) }
|
||||
.onDisappear { self.pulse = false }
|
||||
.onChange(of: self.gateway) { _, newValue in
|
||||
|
||||
@ -1,5 +1,34 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Status Capsule
|
||||
|
||||
/// Applies Liquid Glass (iOS 26+) or a manual dark capsule (iOS 18–25).
|
||||
/// Note: `glassEffect` requires building with Xcode 26+ (iOS 26 SDK).
|
||||
/// `#available(iOS 26, *)` is a runtime gate only — the symbol must exist at compile time.
|
||||
private struct TalkStatusCapsuleModifier: ViewModifier {
|
||||
let seam: Color
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if #available(iOS 26, *) {
|
||||
// iOS 26+: native Liquid Glass capsule — tinted with the gateway seam color.
|
||||
content
|
||||
.glassEffect(.regular.tint(seam.opacity(0.18)), in: Capsule())
|
||||
} else {
|
||||
// iOS 18–25: manual dark capsule with seam-colored border.
|
||||
content
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(Color.black.opacity(0.40))
|
||||
.overlay(
|
||||
Capsule().stroke(seam.opacity(0.22), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Orb
|
||||
|
||||
struct TalkOrbOverlay: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
@State private var pulse: Bool = false
|
||||
@ -62,11 +91,7 @@ struct TalkOrbOverlay: View {
|
||||
.foregroundStyle(Color.white.opacity(0.92))
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(Color.black.opacity(0.40))
|
||||
.overlay(
|
||||
Capsule().stroke(seam.opacity(0.22), lineWidth: 1)))
|
||||
.modifier(TalkStatusCapsuleModifier(seam: seam))
|
||||
}
|
||||
|
||||
if self.appModel.talkMode.isListening {
|
||||
|
||||
@ -96,8 +96,8 @@ targets:
|
||||
SWIFT_VERSION: "6.0"
|
||||
SWIFT_STRICT_CONCURRENCY: complete
|
||||
SUPPORTS_LIVE_ACTIVITIES: YES
|
||||
ENABLE_APPINTENTS_METADATA: NO
|
||||
ENABLE_APP_INTENTS_METADATA_GENERATION: NO
|
||||
ENABLE_APPINTENTS_METADATA: YES
|
||||
ENABLE_APP_INTENTS_METADATA_GENERATION: YES
|
||||
configs:
|
||||
Debug:
|
||||
OPENCLAW_PUSH_TRANSPORT: direct
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user