mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
iOS: add welcome home canvas
This commit is contained in:
committed by
Nimrod Gutman
parent
8ba1b6eff1
commit
67746a12de
@@ -34,18 +34,11 @@ extension NodeAppModel {
|
||||
}
|
||||
|
||||
func showA2UIOnConnectIfNeeded() async {
|
||||
let current = self.screen.urlString.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if current.isEmpty || current == self.lastAutoA2uiURL {
|
||||
if let canvasUrl = await self.resolveCanvasHostURLWithCapabilityRefresh(),
|
||||
let url = URL(string: canvasUrl),
|
||||
await Self.probeTCP(url: url, timeoutSeconds: 2.5)
|
||||
{
|
||||
self.screen.navigate(to: canvasUrl)
|
||||
self.lastAutoA2uiURL = canvasUrl
|
||||
} else {
|
||||
self.lastAutoA2uiURL = nil
|
||||
self.screen.showDefaultCanvas()
|
||||
}
|
||||
await MainActor.run {
|
||||
// Keep the bundled home canvas as the default connected view.
|
||||
// Agents can still explicitly present a remote or local canvas later.
|
||||
self.lastAutoA2uiURL = nil
|
||||
self.screen.showDefaultCanvas()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -88,6 +88,7 @@ final class NodeAppModel {
|
||||
var selectedAgentId: String?
|
||||
var gatewayDefaultAgentId: String?
|
||||
var gatewayAgents: [AgentSummary] = []
|
||||
var homeCanvasRevision: Int = 0
|
||||
var lastShareEventText: String = "No share events yet."
|
||||
var openChatRequestID: Int = 0
|
||||
private(set) var pendingAgentDeepLinkPrompt: AgentDeepLinkPrompt?
|
||||
@@ -548,6 +549,7 @@ final class NodeAppModel {
|
||||
self.seamColorHex = raw.isEmpty ? nil : raw
|
||||
self.mainSessionBaseKey = mainKey
|
||||
self.talkMode.updateMainSessionKey(self.mainSessionKey)
|
||||
self.homeCanvasRevision &+= 1
|
||||
}
|
||||
} catch {
|
||||
if let gatewayError = error as? GatewayResponseError {
|
||||
@@ -574,12 +576,19 @@ final class NodeAppModel {
|
||||
self.selectedAgentId = nil
|
||||
}
|
||||
self.talkMode.updateMainSessionKey(self.mainSessionKey)
|
||||
self.homeCanvasRevision &+= 1
|
||||
}
|
||||
} catch {
|
||||
// Best-effort only.
|
||||
}
|
||||
}
|
||||
|
||||
func refreshGatewayOverviewIfConnected() async {
|
||||
guard await self.isOperatorConnected() else { return }
|
||||
await self.refreshBrandingFromGateway()
|
||||
await self.refreshAgentsFromGateway()
|
||||
}
|
||||
|
||||
func setSelectedAgentId(_ agentId: String?) {
|
||||
let trimmed = (agentId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let stableID = (self.connectedGatewayID ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -590,6 +599,7 @@ final class NodeAppModel {
|
||||
GatewaySettingsStore.saveGatewaySelectedAgentId(stableID: stableID, agentId: self.selectedAgentId)
|
||||
}
|
||||
self.talkMode.updateMainSessionKey(self.mainSessionKey)
|
||||
self.homeCanvasRevision &+= 1
|
||||
if let relay = ShareGatewayRelaySettings.loadConfig() {
|
||||
ShareGatewayRelaySettings.saveConfig(
|
||||
ShareGatewayRelayConfig(
|
||||
@@ -1749,6 +1759,7 @@ private extension NodeAppModel {
|
||||
self.gatewayDefaultAgentId = nil
|
||||
self.gatewayAgents = []
|
||||
self.selectedAgentId = GatewaySettingsStore.loadGatewaySelectedAgentId(stableID: stableID)
|
||||
self.homeCanvasRevision &+= 1
|
||||
self.apnsLastRegisteredTokenHex = nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import OpenClawProtocol
|
||||
|
||||
struct RootCanvas: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
@@ -137,16 +138,33 @@ struct RootCanvas: View {
|
||||
.environment(self.gatewayController)
|
||||
}
|
||||
.onAppear { self.updateIdleTimer() }
|
||||
.onAppear { self.updateHomeCanvasState() }
|
||||
.onAppear { self.evaluateOnboardingPresentation(force: false) }
|
||||
.onAppear { self.maybeAutoOpenSettings() }
|
||||
.onChange(of: self.preventSleep) { _, _ in self.updateIdleTimer() }
|
||||
.onChange(of: self.scenePhase) { _, _ in self.updateIdleTimer() }
|
||||
.onChange(of: self.scenePhase) { _, newValue in
|
||||
self.updateIdleTimer()
|
||||
self.updateHomeCanvasState()
|
||||
guard newValue == .active else { return }
|
||||
Task {
|
||||
await self.appModel.refreshGatewayOverviewIfConnected()
|
||||
await MainActor.run {
|
||||
self.updateHomeCanvasState()
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear { self.maybeShowQuickSetup() }
|
||||
.onChange(of: self.gatewayController.gateways.count) { _, _ in self.maybeShowQuickSetup() }
|
||||
.onAppear { self.updateCanvasDebugStatus() }
|
||||
.onChange(of: self.canvasDebugStatusEnabled) { _, _ in self.updateCanvasDebugStatus() }
|
||||
.onChange(of: self.appModel.gatewayStatusText) { _, _ in self.updateCanvasDebugStatus() }
|
||||
.onChange(of: self.appModel.gatewayServerName) { _, _ in self.updateCanvasDebugStatus() }
|
||||
.onChange(of: self.appModel.gatewayStatusText) { _, _ in
|
||||
self.updateCanvasDebugStatus()
|
||||
self.updateHomeCanvasState()
|
||||
}
|
||||
.onChange(of: self.appModel.gatewayServerName) { _, _ in
|
||||
self.updateCanvasDebugStatus()
|
||||
self.updateHomeCanvasState()
|
||||
}
|
||||
.onChange(of: self.appModel.gatewayServerName) { _, newValue in
|
||||
if newValue != nil {
|
||||
self.showOnboarding = false
|
||||
@@ -155,7 +173,13 @@ struct RootCanvas: View {
|
||||
.onChange(of: self.onboardingRequestID) { _, _ in
|
||||
self.evaluateOnboardingPresentation(force: true)
|
||||
}
|
||||
.onChange(of: self.appModel.gatewayRemoteAddress) { _, _ in self.updateCanvasDebugStatus() }
|
||||
.onChange(of: self.appModel.gatewayRemoteAddress) { _, _ in
|
||||
self.updateCanvasDebugStatus()
|
||||
self.updateHomeCanvasState()
|
||||
}
|
||||
.onChange(of: self.appModel.homeCanvasRevision) { _, _ in
|
||||
self.updateHomeCanvasState()
|
||||
}
|
||||
.onChange(of: self.appModel.gatewayServerName) { _, newValue in
|
||||
if newValue != nil {
|
||||
self.onboardingComplete = true
|
||||
@@ -209,6 +233,134 @@ struct RootCanvas: View {
|
||||
self.appModel.screen.updateDebugStatus(title: title, subtitle: subtitle)
|
||||
}
|
||||
|
||||
private func updateHomeCanvasState() {
|
||||
let payload = self.makeHomeCanvasPayload()
|
||||
guard let data = try? JSONEncoder().encode(payload),
|
||||
let json = String(data: data, encoding: .utf8)
|
||||
else {
|
||||
self.appModel.screen.updateHomeCanvasState(json: nil)
|
||||
return
|
||||
}
|
||||
self.appModel.screen.updateHomeCanvasState(json: json)
|
||||
}
|
||||
|
||||
private func makeHomeCanvasPayload() -> HomeCanvasPayload {
|
||||
let gatewayName = self.normalized(self.appModel.gatewayServerName)
|
||||
let gatewayAddress = self.normalized(self.appModel.gatewayRemoteAddress)
|
||||
let gatewayLabel = gatewayName ?? gatewayAddress ?? "Gateway"
|
||||
let activeAgentID = self.resolveActiveAgentID()
|
||||
let agents = self.homeCanvasAgents(activeAgentID: activeAgentID)
|
||||
|
||||
switch self.gatewayStatus {
|
||||
case .connected:
|
||||
return HomeCanvasPayload(
|
||||
gatewayState: "connected",
|
||||
eyebrow: "Connected to \(gatewayLabel)",
|
||||
title: "Your agents are ready",
|
||||
subtitle:
|
||||
"This phone stays dormant until the gateway needs it, then wakes, syncs, and goes back to sleep.",
|
||||
gatewayLabel: gatewayLabel,
|
||||
activeAgentName: self.appModel.activeAgentName,
|
||||
activeAgentBadge: agents.first(where: { $0.isActive })?.badge ?? "OC",
|
||||
activeAgentCaption: "Selected on this phone",
|
||||
agentCount: agents.count,
|
||||
agents: Array(agents.prefix(6)),
|
||||
footer: "The overview refreshes on reconnect and when the app returns to foreground.")
|
||||
case .connecting:
|
||||
return HomeCanvasPayload(
|
||||
gatewayState: "connecting",
|
||||
eyebrow: "Reconnecting",
|
||||
title: "OpenClaw is syncing back up",
|
||||
subtitle:
|
||||
"The gateway session is coming back online. "
|
||||
+ "Agent shortcuts should settle automatically in a moment.",
|
||||
gatewayLabel: gatewayLabel,
|
||||
activeAgentName: self.appModel.activeAgentName,
|
||||
activeAgentBadge: "OC",
|
||||
activeAgentCaption: "Gateway session in progress",
|
||||
agentCount: agents.count,
|
||||
agents: Array(agents.prefix(4)),
|
||||
footer: "If the gateway is reachable, reconnect should complete without intervention.")
|
||||
case .error, .disconnected:
|
||||
return HomeCanvasPayload(
|
||||
gatewayState: self.gatewayStatus == .error ? "error" : "offline",
|
||||
eyebrow: "Welcome to OpenClaw",
|
||||
title: "Your phone stays quiet until it is needed",
|
||||
subtitle:
|
||||
"Pair this device to your gateway to wake it only for real work, "
|
||||
+ "keep a live agent overview handy, and avoid battery-draining background loops.",
|
||||
gatewayLabel: gatewayLabel,
|
||||
activeAgentName: "Main",
|
||||
activeAgentBadge: "OC",
|
||||
activeAgentCaption: "Connect to load your agents",
|
||||
agentCount: agents.count,
|
||||
agents: Array(agents.prefix(4)),
|
||||
footer:
|
||||
"When connected, the gateway can wake the phone with a silent push "
|
||||
+ "instead of holding an always-on session.")
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveActiveAgentID() -> String {
|
||||
let selected = self.normalized(self.appModel.selectedAgentId) ?? ""
|
||||
if !selected.isEmpty {
|
||||
return selected
|
||||
}
|
||||
return self.resolveDefaultAgentID()
|
||||
}
|
||||
|
||||
private func resolveDefaultAgentID() -> String {
|
||||
self.normalized(self.appModel.gatewayDefaultAgentId) ?? ""
|
||||
}
|
||||
|
||||
private func homeCanvasAgents(activeAgentID: String) -> [HomeCanvasAgentCard] {
|
||||
let defaultAgentID = self.resolveDefaultAgentID()
|
||||
let cards = self.appModel.gatewayAgents.map { agent -> HomeCanvasAgentCard in
|
||||
let isActive = !activeAgentID.isEmpty && agent.id == activeAgentID
|
||||
let isDefault = !defaultAgentID.isEmpty && agent.id == defaultAgentID
|
||||
return HomeCanvasAgentCard(
|
||||
id: agent.id,
|
||||
name: self.homeCanvasName(for: agent),
|
||||
badge: self.homeCanvasBadge(for: agent),
|
||||
caption: isActive ? "Active on this phone" : (isDefault ? "Default agent" : "Ready"),
|
||||
isActive: isActive)
|
||||
}
|
||||
|
||||
return cards.sorted { lhs, rhs in
|
||||
if lhs.isActive != rhs.isActive {
|
||||
return lhs.isActive
|
||||
}
|
||||
return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
|
||||
}
|
||||
}
|
||||
|
||||
private func homeCanvasName(for agent: AgentSummary) -> String {
|
||||
self.normalized(agent.name) ?? agent.id
|
||||
}
|
||||
|
||||
private func homeCanvasBadge(for agent: AgentSummary) -> String {
|
||||
if let identity = agent.identity,
|
||||
let emoji = identity["emoji"]?.value as? String,
|
||||
let normalizedEmoji = self.normalized(emoji)
|
||||
{
|
||||
return normalizedEmoji
|
||||
}
|
||||
let words = self.homeCanvasName(for: agent)
|
||||
.split(whereSeparator: { $0.isWhitespace || $0 == "-" || $0 == "_" })
|
||||
.prefix(2)
|
||||
let initials = words.compactMap { $0.first }.map(String.init).joined()
|
||||
if !initials.isEmpty {
|
||||
return initials.uppercased()
|
||||
}
|
||||
return "OC"
|
||||
}
|
||||
|
||||
private func normalized(_ value: String?) -> String? {
|
||||
guard let value else { return nil }
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private func evaluateOnboardingPresentation(force: Bool) {
|
||||
if force {
|
||||
self.onboardingAllowSkip = true
|
||||
@@ -274,6 +426,28 @@ struct RootCanvas: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct HomeCanvasPayload: Codable {
|
||||
var gatewayState: String
|
||||
var eyebrow: String
|
||||
var title: String
|
||||
var subtitle: String
|
||||
var gatewayLabel: String
|
||||
var activeAgentName: String
|
||||
var activeAgentBadge: String
|
||||
var activeAgentCaption: String
|
||||
var agentCount: Int
|
||||
var agents: [HomeCanvasAgentCard]
|
||||
var footer: String
|
||||
}
|
||||
|
||||
private struct HomeCanvasAgentCard: Codable {
|
||||
var id: String
|
||||
var name: String
|
||||
var badge: String
|
||||
var caption: String
|
||||
var isActive: Bool
|
||||
}
|
||||
|
||||
private struct CanvasContent: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
@AppStorage("talk.enabled") private var talkEnabled: Bool = false
|
||||
@@ -301,53 +475,33 @@ private struct CanvasContent: View {
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .topLeading) {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
StatusPill(
|
||||
gateway: self.gatewayStatus,
|
||||
voiceWakeEnabled: self.voiceWakeEnabled,
|
||||
activity: self.statusActivity,
|
||||
brighten: self.brightenButtons,
|
||||
onTap: {
|
||||
if self.gatewayStatus == .connected {
|
||||
self.showGatewayActions = true
|
||||
} else {
|
||||
self.openSettings()
|
||||
}
|
||||
})
|
||||
.layoutPriority(1)
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
OverlayButton(systemImage: "text.bubble.fill", brighten: self.brightenButtons) {
|
||||
self.openChat()
|
||||
}
|
||||
.accessibilityLabel("Chat")
|
||||
|
||||
if self.talkButtonEnabled {
|
||||
// Keep Talk mode near status controls while freeing right-side screen real estate.
|
||||
OverlayButton(
|
||||
systemImage: self.talkActive ? "waveform.circle.fill" : "waveform.circle",
|
||||
brighten: self.brightenButtons,
|
||||
tint: self.appModel.seamColor,
|
||||
isActive: self.talkActive)
|
||||
{
|
||||
let next = !self.talkActive
|
||||
self.talkEnabled = next
|
||||
self.appModel.setTalkEnabled(next)
|
||||
}
|
||||
.accessibilityLabel("Talk Mode")
|
||||
}
|
||||
|
||||
OverlayButton(systemImage: "gearshape.fill", brighten: self.brightenButtons) {
|
||||
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||
HomeToolbar(
|
||||
gateway: self.gatewayStatus,
|
||||
voiceWakeEnabled: self.voiceWakeEnabled,
|
||||
activity: self.statusActivity,
|
||||
brighten: self.brightenButtons,
|
||||
talkButtonEnabled: self.talkButtonEnabled,
|
||||
talkActive: self.talkActive,
|
||||
talkTint: self.appModel.seamColor,
|
||||
onStatusTap: {
|
||||
if self.gatewayStatus == .connected {
|
||||
self.showGatewayActions = true
|
||||
} else {
|
||||
self.openSettings()
|
||||
}
|
||||
.accessibilityLabel("Settings")
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.safeAreaPadding(.top, 10)
|
||||
},
|
||||
onChatTap: {
|
||||
self.openChat()
|
||||
},
|
||||
onTalkTap: {
|
||||
let next = !self.talkActive
|
||||
self.talkEnabled = next
|
||||
self.appModel.setTalkEnabled(next)
|
||||
},
|
||||
onSettingsTap: {
|
||||
self.openSettings()
|
||||
})
|
||||
}
|
||||
.overlay(alignment: .topLeading) {
|
||||
if let voiceWakeToastText, !voiceWakeToastText.isEmpty {
|
||||
|
||||
@@ -20,6 +20,7 @@ final class ScreenController {
|
||||
private var debugStatusEnabled: Bool = false
|
||||
private var debugStatusTitle: String?
|
||||
private var debugStatusSubtitle: String?
|
||||
private var homeCanvasStateJSON: String?
|
||||
|
||||
init() {
|
||||
self.reload()
|
||||
@@ -94,6 +95,26 @@ final class ScreenController {
|
||||
subtitle: self.debugStatusSubtitle)
|
||||
}
|
||||
|
||||
func updateHomeCanvasState(json: String?) {
|
||||
self.homeCanvasStateJSON = json
|
||||
self.applyHomeCanvasStateIfNeeded()
|
||||
}
|
||||
|
||||
func applyHomeCanvasStateIfNeeded() {
|
||||
guard let webView = self.activeWebView else { return }
|
||||
let payload = self.homeCanvasStateJSON ?? "null"
|
||||
let js = """
|
||||
(() => {
|
||||
try {
|
||||
const api = globalThis.__openclaw;
|
||||
if (!api || typeof api.renderHome !== 'function') return;
|
||||
api.renderHome(\(payload));
|
||||
} catch (_) {}
|
||||
})()
|
||||
"""
|
||||
webView.evaluateJavaScript(js) { _, _ in }
|
||||
}
|
||||
|
||||
func waitForA2UIReady(timeoutMs: Int) async -> Bool {
|
||||
let clock = ContinuousClock()
|
||||
let deadline = clock.now.advanced(by: .milliseconds(timeoutMs))
|
||||
@@ -191,6 +212,7 @@ final class ScreenController {
|
||||
self.activeWebView = webView
|
||||
self.reload()
|
||||
self.applyDebugStatusIfNeeded()
|
||||
self.applyHomeCanvasStateIfNeeded()
|
||||
}
|
||||
|
||||
func detachWebView(_ webView: WKWebView) {
|
||||
|
||||
@@ -161,6 +161,7 @@ private final class ScreenNavigationDelegate: NSObject, WKNavigationDelegate {
|
||||
func webView(_: WKWebView, didFinish _: WKNavigation?) {
|
||||
self.controller?.errorText = nil
|
||||
self.controller?.applyDebugStatusIfNeeded()
|
||||
self.controller?.applyHomeCanvasStateIfNeeded()
|
||||
}
|
||||
|
||||
func webView(_: WKWebView, didFail _: WKNavigation?, withError error: any Error) {
|
||||
|
||||
@@ -38,6 +38,7 @@ struct StatusPill: View {
|
||||
var gateway: GatewayState
|
||||
var voiceWakeEnabled: Bool
|
||||
var activity: Activity?
|
||||
var compact: Bool = false
|
||||
var brighten: Bool = false
|
||||
var onTap: () -> Void
|
||||
|
||||
@@ -45,11 +46,11 @@ struct StatusPill: View {
|
||||
|
||||
var body: some View {
|
||||
Button(action: self.onTap) {
|
||||
HStack(spacing: 10) {
|
||||
HStack(spacing: 8) {
|
||||
HStack(spacing: self.compact ? 8 : 10) {
|
||||
HStack(spacing: self.compact ? 6 : 8) {
|
||||
Circle()
|
||||
.fill(self.gateway.color)
|
||||
.frame(width: 9, height: 9)
|
||||
.frame(width: self.compact ? 8 : 9, height: self.compact ? 8 : 9)
|
||||
.scaleEffect(
|
||||
self.gateway == .connecting && !self.reduceMotion
|
||||
? (self.pulse ? 1.15 : 0.85)
|
||||
@@ -58,34 +59,38 @@ struct StatusPill: View {
|
||||
.opacity(self.gateway == .connecting && !self.reduceMotion ? (self.pulse ? 1.0 : 0.6) : 1.0)
|
||||
|
||||
Text(self.gateway.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.font((self.compact ? Font.footnote : Font.subheadline).weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
|
||||
Divider()
|
||||
.frame(height: 14)
|
||||
.opacity(0.35)
|
||||
|
||||
if let activity {
|
||||
HStack(spacing: 6) {
|
||||
if !self.compact {
|
||||
Divider()
|
||||
.frame(height: 14)
|
||||
.opacity(0.35)
|
||||
}
|
||||
|
||||
HStack(spacing: self.compact ? 4 : 6) {
|
||||
Image(systemName: activity.systemImage)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.font((self.compact ? Font.footnote : Font.subheadline).weight(.semibold))
|
||||
.foregroundStyle(activity.tint ?? .primary)
|
||||
Text(activity.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
if !self.compact {
|
||||
Text(activity.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
} else {
|
||||
Image(systemName: self.voiceWakeEnabled ? "mic.fill" : "mic.slash")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.font((self.compact ? Font.footnote : Font.subheadline).weight(.semibold))
|
||||
.foregroundStyle(self.voiceWakeEnabled ? .primary : .secondary)
|
||||
.accessibilityLabel(self.voiceWakeEnabled ? "Voice Wake enabled" : "Voice Wake disabled")
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
}
|
||||
}
|
||||
.statusGlassCard(brighten: self.brighten, verticalPadding: 8)
|
||||
.statusGlassCard(brighten: self.brighten, verticalPadding: self.compact ? 6 : 8)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Connection Status")
|
||||
|
||||
Reference in New Issue
Block a user