Aligns the iOS app with the Clawnet refactor by implementing proper role separation for gateway connections. Uses separate operator and node sessions to match the gateway's authorization requirements. Changes: - New GatewayOperatorSession: Wraps GatewayChannelActor for operator-role RPC requests (chat.*, health, sessions.list) without invoke handling - Dual-connection architecture: Operator session for requests, node session for node.event calls (e.g., chat.subscribe) - Separate websocket sessions: Each connection gets its own URLSession to prevent response cross-talk - Updated chat transport: IOSGatewayChatTransport uses operator session for requests, node session for subscriptions ClawdbotKit (shared): - Deadlock fix in GatewayChannel.swift: Moved connection finalization (listen(), connected=true, isConnecting=false, waiter resumption) to occur before calling pushHandler. This fixes a latent bug where requests made from onConnected callbacks would deadlock. Does not affect macOS (its callback doesn't make requests). - Package.swift: Fixed argument order for Swift 6.2 compatibility iOS chat is now working. This is the base PR to unlock further work on the iOS app.
374 lines
15 KiB
Swift
374 lines
15 KiB
Swift
import SwiftUI
|
|
import UIKit
|
|
|
|
struct RootCanvas: View {
|
|
@Environment(NodeAppModel.self) private var appModel
|
|
@Environment(VoiceWakeManager.self) private var voiceWake
|
|
@Environment(\.colorScheme) private var systemColorScheme
|
|
@Environment(\.scenePhase) private var scenePhase
|
|
@AppStorage(VoiceWakePreferences.enabledKey) private var voiceWakeEnabled: Bool = false
|
|
@AppStorage("screen.preventSleep") private var preventSleep: Bool = true
|
|
@AppStorage("canvas.debugStatusEnabled") private var canvasDebugStatusEnabled: Bool = false
|
|
@State private var presentedSheet: PresentedSheet?
|
|
@State private var voiceWakeToastText: String?
|
|
@State private var toastDismissTask: Task<Void, Never>?
|
|
|
|
private enum PresentedSheet: Identifiable {
|
|
case settings
|
|
case chat
|
|
|
|
var id: Int {
|
|
switch self {
|
|
case .settings: 0
|
|
case .chat: 1
|
|
}
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
CanvasContent(
|
|
systemColorScheme: self.systemColorScheme,
|
|
gatewayStatus: self.gatewayStatus,
|
|
voiceWakeEnabled: self.voiceWakeEnabled,
|
|
voiceWakeToastText: self.voiceWakeToastText,
|
|
cameraHUDText: self.appModel.cameraHUDText,
|
|
cameraHUDKind: self.appModel.cameraHUDKind,
|
|
openChat: {
|
|
self.presentedSheet = .chat
|
|
},
|
|
openSettings: {
|
|
self.presentedSheet = .settings
|
|
})
|
|
.preferredColorScheme(.dark)
|
|
|
|
if self.appModel.cameraFlashNonce != 0 {
|
|
CameraFlashOverlay(nonce: self.appModel.cameraFlashNonce)
|
|
}
|
|
}
|
|
.sheet(item: self.$presentedSheet) { sheet in
|
|
switch sheet {
|
|
case .settings:
|
|
SettingsTab()
|
|
case .chat:
|
|
ChatSheet(
|
|
gateway: self.appModel.gatewaySession,
|
|
nodeSession: self.appModel.gatewayNodeSession,
|
|
sessionKey: self.appModel.mainSessionKey,
|
|
userAccent: self.appModel.seamColor)
|
|
}
|
|
}
|
|
.onAppear { self.updateIdleTimer() }
|
|
.onChange(of: self.preventSleep) { _, _ in self.updateIdleTimer() }
|
|
.onChange(of: self.scenePhase) { _, _ in self.updateIdleTimer() }
|
|
.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.gatewayRemoteAddress) { _, _ in self.updateCanvasDebugStatus() }
|
|
.onChange(of: self.voiceWake.lastTriggeredCommand) { _, newValue in
|
|
guard let newValue else { return }
|
|
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return }
|
|
|
|
self.toastDismissTask?.cancel()
|
|
withAnimation(.spring(response: 0.25, dampingFraction: 0.85)) {
|
|
self.voiceWakeToastText = trimmed
|
|
}
|
|
|
|
self.toastDismissTask = Task {
|
|
try? await Task.sleep(nanoseconds: 2_300_000_000)
|
|
await MainActor.run {
|
|
withAnimation(.easeOut(duration: 0.25)) {
|
|
self.voiceWakeToastText = nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.onDisappear {
|
|
UIApplication.shared.isIdleTimerDisabled = false
|
|
self.toastDismissTask?.cancel()
|
|
self.toastDismissTask = nil
|
|
}
|
|
}
|
|
|
|
private var pairingRole: StatusPill.PairingRole? {
|
|
switch self.appModel.gatewayPairingState {
|
|
case .none:
|
|
return nil
|
|
case .operatorPending:
|
|
return .operator
|
|
case .nodePending:
|
|
return .node
|
|
case .bothPending:
|
|
return .both
|
|
}
|
|
}
|
|
|
|
private var connectionRole: StatusPill.ConnectionRole? {
|
|
switch (self.appModel.operatorConnected, self.appModel.nodeConnected) {
|
|
case (true, true):
|
|
return .both
|
|
case (true, false):
|
|
return .operatorOnly
|
|
case (false, true):
|
|
return .nodeOnly
|
|
case (false, false):
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private var gatewayStatus: StatusPill.GatewayState {
|
|
if let pairingRole {
|
|
return .pairingPending(pairingRole)
|
|
}
|
|
if let connectionRole {
|
|
return .connected(connectionRole)
|
|
}
|
|
|
|
let text = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if text.localizedCaseInsensitiveContains("connecting") ||
|
|
text.localizedCaseInsensitiveContains("reconnecting")
|
|
{
|
|
return .connecting
|
|
}
|
|
|
|
if text.localizedCaseInsensitiveContains("error") {
|
|
return .error
|
|
}
|
|
|
|
return .disconnected
|
|
}
|
|
|
|
private func updateIdleTimer() {
|
|
UIApplication.shared.isIdleTimerDisabled = (self.scenePhase == .active && self.preventSleep)
|
|
}
|
|
|
|
private func updateCanvasDebugStatus() {
|
|
self.appModel.screen.setDebugStatusEnabled(self.canvasDebugStatusEnabled)
|
|
guard self.canvasDebugStatusEnabled else { return }
|
|
let title = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let subtitle = self.appModel.gatewayServerName ?? self.appModel.gatewayRemoteAddress
|
|
self.appModel.screen.updateDebugStatus(title: title, subtitle: subtitle)
|
|
}
|
|
}
|
|
|
|
private struct CanvasContent: View {
|
|
@Environment(NodeAppModel.self) private var appModel
|
|
@AppStorage("talk.enabled") private var talkEnabled: Bool = false
|
|
@AppStorage("talk.button.enabled") private var talkButtonEnabled: Bool = true
|
|
var systemColorScheme: ColorScheme
|
|
var gatewayStatus: StatusPill.GatewayState
|
|
var voiceWakeEnabled: Bool
|
|
var voiceWakeToastText: String?
|
|
var cameraHUDText: String?
|
|
var cameraHUDKind: NodeAppModel.CameraHUDKind?
|
|
var openChat: () -> Void
|
|
var openSettings: () -> Void
|
|
|
|
private var brightenButtons: Bool { self.systemColorScheme == .light }
|
|
|
|
var body: some View {
|
|
ZStack(alignment: .topTrailing) {
|
|
ScreenTab()
|
|
|
|
VStack(spacing: 10) {
|
|
OverlayButton(systemImage: "text.bubble.fill", brighten: self.brightenButtons) {
|
|
self.openChat()
|
|
}
|
|
.accessibilityLabel("Chat")
|
|
|
|
if self.talkButtonEnabled {
|
|
// Talk mode lives on a side bubble so it doesn't get buried in settings.
|
|
OverlayButton(
|
|
systemImage: self.appModel.talkMode.isEnabled ? "waveform.circle.fill" : "waveform.circle",
|
|
brighten: self.brightenButtons,
|
|
tint: self.appModel.seamColor,
|
|
isActive: self.appModel.talkMode.isEnabled)
|
|
{
|
|
let next = !self.appModel.talkMode.isEnabled
|
|
self.talkEnabled = next
|
|
self.appModel.setTalkEnabled(next)
|
|
}
|
|
.accessibilityLabel("Talk Mode")
|
|
}
|
|
|
|
OverlayButton(systemImage: "gearshape.fill", brighten: self.brightenButtons) {
|
|
self.openSettings()
|
|
}
|
|
.accessibilityLabel("Settings")
|
|
}
|
|
.padding(.top, 10)
|
|
.padding(.trailing, 10)
|
|
}
|
|
.overlay(alignment: .center) {
|
|
if self.appModel.talkMode.isEnabled {
|
|
TalkOrbOverlay()
|
|
.transition(.opacity)
|
|
}
|
|
}
|
|
.overlay(alignment: .topLeading) {
|
|
StatusPill(
|
|
gateway: self.gatewayStatus,
|
|
voiceWakeEnabled: self.voiceWakeEnabled,
|
|
activity: self.statusActivity,
|
|
brighten: self.brightenButtons,
|
|
onTap: {
|
|
self.openSettings()
|
|
})
|
|
.padding(.leading, 10)
|
|
.safeAreaPadding(.top, 10)
|
|
}
|
|
.overlay(alignment: .topLeading) {
|
|
if let voiceWakeToastText, !voiceWakeToastText.isEmpty {
|
|
VoiceWakeToast(
|
|
command: voiceWakeToastText,
|
|
brighten: self.brightenButtons)
|
|
.padding(.leading, 10)
|
|
.safeAreaPadding(.top, 58)
|
|
.transition(.move(edge: .top).combined(with: .opacity))
|
|
}
|
|
}
|
|
}
|
|
|
|
private var statusActivity: StatusPill.Activity? {
|
|
// Status pill owns transient activity state so it doesn't overlap the connection indicator.
|
|
if self.appModel.isBackgrounded {
|
|
return StatusPill.Activity(
|
|
title: "Foreground required",
|
|
systemImage: "exclamationmark.triangle.fill",
|
|
tint: .orange)
|
|
}
|
|
|
|
let gatewayStatus = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let gatewayLower = gatewayStatus.lowercased()
|
|
if gatewayLower.contains("repair") {
|
|
return StatusPill.Activity(title: "Repairing…", systemImage: "wrench.and.screwdriver", tint: .orange)
|
|
}
|
|
if gatewayLower.contains("approval") || gatewayLower.contains("pairing") {
|
|
return StatusPill.Activity(title: "Approval pending", systemImage: "person.crop.circle.badge.clock")
|
|
}
|
|
// Avoid duplicating the primary gateway status ("Connecting…") in the activity slot.
|
|
|
|
if self.appModel.screenRecordActive {
|
|
return StatusPill.Activity(title: "Recording screen…", systemImage: "record.circle.fill", tint: .red)
|
|
}
|
|
|
|
if let cameraHUDText, !cameraHUDText.isEmpty, let cameraHUDKind {
|
|
let systemImage: String
|
|
let tint: Color?
|
|
switch cameraHUDKind {
|
|
case .photo:
|
|
systemImage = "camera.fill"
|
|
tint = nil
|
|
case .recording:
|
|
systemImage = "video.fill"
|
|
tint = .red
|
|
case .success:
|
|
systemImage = "checkmark.circle.fill"
|
|
tint = .green
|
|
case .error:
|
|
systemImage = "exclamationmark.triangle.fill"
|
|
tint = .red
|
|
}
|
|
return StatusPill.Activity(title: cameraHUDText, systemImage: systemImage, tint: tint)
|
|
}
|
|
|
|
if self.voiceWakeEnabled {
|
|
let voiceStatus = self.appModel.voiceWake.statusText
|
|
if voiceStatus.localizedCaseInsensitiveContains("microphone permission") {
|
|
return StatusPill.Activity(title: "Mic permission", systemImage: "mic.slash", tint: .orange)
|
|
}
|
|
if voiceStatus == "Paused" {
|
|
let suffix = self.appModel.isBackgrounded ? " (background)" : ""
|
|
return StatusPill.Activity(title: "Voice Wake paused\(suffix)", systemImage: "pause.circle.fill")
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private struct OverlayButton: View {
|
|
let systemImage: String
|
|
let brighten: Bool
|
|
var tint: Color?
|
|
var isActive: Bool = false
|
|
let action: () -> Void
|
|
|
|
var body: some View {
|
|
Button(action: self.action) {
|
|
Image(systemName: self.systemImage)
|
|
.font(.system(size: 16, weight: .semibold))
|
|
.foregroundStyle(self.isActive ? (self.tint ?? .primary) : .primary)
|
|
.padding(10)
|
|
.background {
|
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
.fill(.ultraThinMaterial)
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
.fill(
|
|
LinearGradient(
|
|
colors: [
|
|
.white.opacity(self.brighten ? 0.26 : 0.18),
|
|
.white.opacity(self.brighten ? 0.08 : 0.04),
|
|
.clear,
|
|
],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing))
|
|
.blendMode(.overlay)
|
|
}
|
|
.overlay {
|
|
if let tint {
|
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
.fill(
|
|
LinearGradient(
|
|
colors: [
|
|
tint.opacity(self.isActive ? 0.22 : 0.14),
|
|
tint.opacity(self.isActive ? 0.10 : 0.06),
|
|
.clear,
|
|
],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing))
|
|
.blendMode(.overlay)
|
|
}
|
|
}
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
.strokeBorder(
|
|
(self.tint ?? .white).opacity(self.isActive ? 0.34 : (self.brighten ? 0.24 : 0.18)),
|
|
lineWidth: self.isActive ? 0.7 : 0.5)
|
|
}
|
|
.shadow(color: .black.opacity(0.35), radius: 12, y: 6)
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
|
|
private struct CameraFlashOverlay: View {
|
|
var nonce: Int
|
|
|
|
@State private var opacity: CGFloat = 0
|
|
@State private var task: Task<Void, Never>?
|
|
|
|
var body: some View {
|
|
Color.white
|
|
.opacity(self.opacity)
|
|
.ignoresSafeArea()
|
|
.allowsHitTesting(false)
|
|
.onChange(of: self.nonce) { _, _ in
|
|
self.task?.cancel()
|
|
self.task = Task { @MainActor in
|
|
withAnimation(.easeOut(duration: 0.08)) {
|
|
self.opacity = 0.85
|
|
}
|
|
try? await Task.sleep(nanoseconds: 110_000_000)
|
|
withAnimation(.easeOut(duration: 0.32)) {
|
|
self.opacity = 0
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|