fix: harden ios app build hygiene

This commit is contained in:
Peter Steinberger
2026-04-28 01:41:59 +01:00
parent 2fe213ebf2
commit b294f7c467
97 changed files with 1150 additions and 1044 deletions

View File

@@ -281,9 +281,9 @@ struct OpenClawChatComposer: View {
onPasteImageAttachment: { data, fileName, mimeType in
self.viewModel.addImageAttachment(data: data, fileName: fileName, mimeType: mimeType)
})
.frame(minHeight: self.textMinHeight, idealHeight: self.textMinHeight, maxHeight: self.textMaxHeight)
.padding(.horizontal, 4)
.padding(.vertical, 3)
.frame(minHeight: self.textMinHeight, idealHeight: self.textMinHeight, maxHeight: self.textMaxHeight)
.padding(.horizontal, 4)
.padding(.vertical, 3)
#else
TextEditor(text: self.$viewModel.input)
.font(.system(size: 15))
@@ -441,7 +441,9 @@ private struct ChatComposerTextView: NSViewRepresentable {
var onSend: () -> Void
var onPasteImageAttachment: (_ data: Data, _ fileName: String, _ mimeType: String) -> Void
func makeCoordinator() -> Coordinator { Coordinator(self) }
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeNSView(context: Context) -> NSScrollView {
let textView = ChatComposerTextViewFactory.makeConfiguredTextView()
@@ -495,7 +497,9 @@ private struct ChatComposerTextView: NSViewRepresentable {
var parent: ChatComposerTextView
var isProgrammaticUpdate = false
init(_ parent: ChatComposerTextView) { self.parent = parent }
init(_ parent: ChatComposerTextView) {
self.parent = parent
}
func textDidChange(_ notification: Notification) {
guard !self.isProgrammaticUpdate else { return }
@@ -507,7 +511,7 @@ private struct ChatComposerTextView: NSViewRepresentable {
}
enum ChatComposerTextViewFactory {
// Internal for @testable import coverage of composer text view defaults.
/// Internal for @testable import coverage of composer text view defaults.
@MainActor
static func makeConfiguredTextView() -> NSTextView {
let textView = ChatComposerNSTextView()
@@ -751,7 +755,10 @@ enum ChatComposerPasteSupport {
(NSPasteboard.PasteboardType("public.heif"), "heif", "image/heif"),
]
private static func matches(_ preferredType: NSPasteboard.PasteboardType?, candidate: NSPasteboard.PasteboardType) -> Bool {
private static func matches(
_ preferredType: NSPasteboard.PasteboardType?,
candidate: NSPasteboard.PasteboardType) -> Bool
{
guard let preferredType else { return true }
return preferredType == candidate
}

View File

@@ -1,9 +1,9 @@
import Foundation
enum ChatMarkdownPreprocessor {
// Keep in sync with `src/auto-reply/reply/strip-inbound-meta.ts`
// (`INBOUND_META_SENTINELS`), and extend parser expectations in
// `ChatMarkdownPreprocessorTests` when sentinels change.
/// Keep in sync with `src/auto-reply/reply/strip-inbound-meta.ts`
/// (`INBOUND_META_SENTINELS`), and extend parser expectations in
/// `ChatMarkdownPreprocessorTests` when sentinels change.
private static let inboundContextHeaders = [
"Conversation info (untrusted metadata):",
"Sender (untrusted metadata):",
@@ -152,11 +152,13 @@ enum ChatMarkdownPreprocessor {
for index in lines.indices {
let currentLine = lines[index]
if !inMetaBlock && self.shouldStripTrailingUntrustedContext(lines: lines, index: index) {
if !inMetaBlock, self.shouldStripTrailingUntrustedContext(lines: lines, index: index) {
break
}
if !inMetaBlock && self.inboundContextHeaders.contains(currentLine.trimmingCharacters(in: .whitespacesAndNewlines)) {
if !inMetaBlock,
self.inboundContextHeaders.contains(currentLine.trimmingCharacters(in: .whitespacesAndNewlines))
{
let nextLine = index + 1 < lines.count ? lines[index + 1] : nil
if nextLine?.trimmingCharacters(in: .whitespacesAndNewlines) != "```json" {
outputLines.append(currentLine)
@@ -168,7 +170,7 @@ enum ChatMarkdownPreprocessor {
}
if inMetaBlock {
if !inFencedJson && currentLine.trimmingCharacters(in: .whitespacesAndNewlines) == "```json" {
if !inFencedJson, currentLine.trimmingCharacters(in: .whitespacesAndNewlines) == "```json" {
inFencedJson = true
continue
}

View File

@@ -70,7 +70,7 @@ private struct InlineImageList: View {
let images: [ChatMarkdownPreprocessor.InlineImage]
var body: some View {
ForEach(images, id: \.id) { item in
ForEach(self.images, id: \.id) { item in
if let img = item.image {
OpenClawPlatformImageFactory.image(img)
.resizable()

View File

@@ -1,5 +1,5 @@
import OpenClawKit
import Foundation
import OpenClawKit
import SwiftUI
private enum ChatUIConstants {
@@ -70,7 +70,12 @@ private struct ChatBubbleShape: InsettableShape {
to: baseBottom,
control1: CGPoint(x: bubbleMaxX + self.tailWidth * 0.95, y: midY + baseH * 0.15),
control2: CGPoint(x: bubbleMaxX + self.tailWidth * 0.2, y: baseBottomY - baseH * 0.05))
self.addBottomEdge(path: &path, bubbleMinX: bubbleMinX, bubbleMaxX: bubbleMaxX, bubbleMaxY: bubbleMaxY, radius: r)
self.addBottomEdge(
path: &path,
bubbleMinX: bubbleMinX,
bubbleMaxX: bubbleMaxX,
bubbleMaxY: bubbleMaxY,
radius: r)
path.addLine(to: CGPoint(x: bubbleMinX, y: bubbleMinY + r))
path.addQuadCurve(
to: CGPoint(x: bubbleMinX + r, y: bubbleMinY),
@@ -102,7 +107,12 @@ private struct ChatBubbleShape: InsettableShape {
to: CGPoint(x: bubbleMaxX, y: bubbleMinY + r),
control: CGPoint(x: bubbleMaxX, y: bubbleMinY))
path.addLine(to: CGPoint(x: bubbleMaxX, y: bubbleMaxY - r))
self.addBottomEdge(path: &path, bubbleMinX: bubbleMinX, bubbleMaxX: bubbleMaxX, bubbleMaxY: bubbleMaxY, radius: r)
self.addBottomEdge(
path: &path,
bubbleMinX: bubbleMinX,
bubbleMaxX: bubbleMaxX,
bubbleMaxY: bubbleMaxY,
radius: r)
path.addLine(to: baseBottom)
path.addCurve(
to: tip,
@@ -158,7 +168,9 @@ struct ChatMessageBubble: View {
.padding(.horizontal, 2)
}
private var isUser: Bool { self.message.role.lowercased() == "user" }
private var isUser: Bool {
self.message.role.lowercased() == "user"
}
}
@MainActor
@@ -498,8 +510,8 @@ extension ChatTypingIndicatorBubble: @MainActor Equatable {
}
}
private extension View {
func assistantBubbleContainerStyle() -> some View {
extension View {
fileprivate func assistantBubbleContainerStyle() -> some View {
self
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)

View File

@@ -1,5 +1,5 @@
import OpenClawKit
import Foundation
import OpenClawKit
// NOTE: keep this file lightweight; decode must be resilient to varying transcript formats.
@@ -270,7 +270,10 @@ public struct OpenClawChatEventPayload: Codable, Sendable {
}
public struct OpenClawAgentEventPayload: Codable, Sendable, Identifiable {
public var id: String { "\(self.runId)-\(self.seq ?? -1)" }
public var id: String {
"\(self.runId)-\(self.seq ?? -1)"
}
public let runId: String
public let seq: Int?
public let stream: String
@@ -279,7 +282,10 @@ public struct OpenClawAgentEventPayload: Codable, Sendable, Identifiable {
}
public struct OpenClawChatPendingToolCall: Identifiable, Hashable, Sendable {
public var id: String { self.toolCallId }
public var id: String {
self.toolCallId
}
public let toolCallId: String
public let name: String
public let args: AnyCodable?

View File

@@ -1,5 +1,5 @@
import OpenClawKit
import Foundation
import OpenClawKit
enum ChatPayloadDecoding {
static func decode<T: Decodable>(_ payload: AnyCodable, as _: T.Type = T.self) throws -> T {

View File

@@ -1,7 +1,9 @@
import Foundation
public struct OpenClawChatModelChoice: Identifiable, Codable, Sendable, Hashable {
public var id: String { self.selectionID }
public var id: String {
self.selectionID
}
public let modelID: String
public let name: String
@@ -44,7 +46,9 @@ public struct OpenClawChatSessionsDefaults: Codable, Sendable {
}
public struct OpenClawChatSessionEntry: Codable, Identifiable, Sendable, Hashable {
public var id: String { self.key }
public var id: String {
self.key
}
public let key: String
public let kind: String?

View File

@@ -128,7 +128,9 @@ enum OpenClawChatTheme {
#endif
}
static var userText: Color { .white }
static var userText: Color {
.white
}
static var assistantText: Color {
#if os(macOS)

View File

@@ -86,8 +86,6 @@ public struct OpenClawChatView: View {
.sheet(isPresented: self.$showSessions) {
if self.showsSessionSwitcher {
ChatSessionsSheet(viewModel: self.viewModel)
} else {
EmptyView()
}
}
}
@@ -99,11 +97,11 @@ public struct OpenClawChatView: View {
self.messageListRows
Color.clear
#if os(macOS)
#if os(macOS)
.frame(height: Layout.messageListPaddingBottom)
#else
#else
.frame(height: Layout.messageListPaddingBottom + 1)
#endif
#endif
.id(self.scrollerBottomID)
}
// Use scroll targets for stable auto-scroll without ScrollViewReader relayout glitches.
@@ -115,11 +113,11 @@ public struct OpenClawChatView: View {
.scrollDismissesKeyboard(.interactively)
#endif
// Keep the scroll pinned to the bottom for new messages.
.scrollPosition(id: self.$scrollPosition, anchor: .bottom)
.onChange(of: self.scrollPosition) { _, position in
guard let position else { return }
self.isPinnedToBottom = position == self.scrollerBottomID
}
.scrollPosition(id: self.$scrollPosition, anchor: .bottom)
.onChange(of: self.scrollPosition) { _, position in
guard let position else { return }
self.isPinnedToBottom = position == self.scrollerBottomID
}
if self.viewModel.isLoading {
ProgressView()
@@ -158,7 +156,8 @@ public struct OpenClawChatView: View {
guard self.hasPerformedInitialScroll else { return }
if let lastMessage = self.viewModel.messages.last,
lastMessage.role.lowercased() == "user",
lastMessage.id != self.lastUserMessageID {
lastMessage.id != self.lastUserMessageID
{
self.lastUserMessageID = lastMessage.id
self.isPinnedToBottom = true
withAnimation(.snappy(duration: 0.22)) {

View File

@@ -1,6 +1,6 @@
import OpenClawKit
import Foundation
import Observation
import OpenClawKit
import OSLog
import UniformTypeIdentifiers
@@ -14,6 +14,7 @@ private let chatUILogger = Logger(subsystem: "ai.openclaw", category: "OpenClawC
@MainActor
@Observable
// swiftlint:disable:next type_body_length
public final class OpenClawChatViewModel {
public static let defaultModelSelectionID = "__default__"
@@ -659,8 +660,8 @@ public final class OpenClawChatViewModel {
self.errorText = "Unable to compact the session. Please try again."
let nsError = error as NSError
chatUILogger.error(
"session compact failed domain=\(nsError.domain, privacy: .public) code=\(nsError.code, privacy: .public) details=\(String(describing: error), privacy: .private)"
)
// swiftlint:disable:next line_length
"session compact failed domain=\(nsError.domain, privacy: .public) code=\(nsError.code, privacy: .public) details=\(String(describing: error), privacy: .private)")
return
}
@@ -733,7 +734,10 @@ public final class OpenClawChatViewModel {
self.latestModelSelectionRequestIDsBySession.removeValue(forKey: sessionKey)
}
if self.lastSuccessfulModelSelectionIDsBySession[sessionKey] == previous {
self.applySuccessfulModelSelection(previous, sessionKey: sessionKey, syncSelection: sessionKey == self.sessionKey)
self.applySuccessfulModelSelection(
previous,
sessionKey: sessionKey,
syncSelection: sessionKey == self.sessionKey)
}
guard sessionKey == self.sessionKey else { return }
self.modelSelectionID = previous
@@ -856,7 +860,8 @@ public final class OpenClawChatViewModel {
syncSelection: syncSelection)
}
private func resolvedSessionModelIdentity(forSelectionID selectionID: String) -> (modelID: String?, modelProvider: String?) {
private func resolvedSessionModelIdentity(forSelectionID selectionID: String)
-> (modelID: String?, modelProvider: String?) {
guard let modelRef = self.modelRef(forSelectionID: selectionID) else {
return (nil, nil)
}

View File

@@ -1,11 +1,11 @@
import Foundation
public extension AnyCodable {
var stringValue: String? {
extension AnyCodable {
public var stringValue: String? {
self.value as? String
}
var boolValue: Bool? {
public var boolValue: Bool? {
if let value = self.value as? Bool {
return value
}
@@ -15,7 +15,7 @@ public extension AnyCodable {
return nil
}
var intValue: Int? {
public var intValue: Int? {
if let value = self.value as? Int {
return value
}
@@ -28,7 +28,7 @@ public extension AnyCodable {
return nil
}
var doubleValue: Double? {
public var doubleValue: Double? {
if let value = self.value as? Double {
return value
}
@@ -41,7 +41,7 @@ public extension AnyCodable {
return nil
}
var dictionaryValue: [String: AnyCodable]? {
public var dictionaryValue: [String: AnyCodable]? {
if let value = self.value as? [String: AnyCodable] {
return value
}
@@ -58,7 +58,7 @@ public extension AnyCodable {
return nil
}
var arrayValue: [AnyCodable]? {
public var arrayValue: [AnyCodable]? {
if let value = self.value as? [AnyCodable] {
return value
}
@@ -71,7 +71,7 @@ public extension AnyCodable {
return nil
}
var foundationValue: Any {
public var foundationValue: Any {
switch self.value {
case let dict as [String: AnyCodable]:
dict.mapValues(\.foundationValue)

View File

@@ -1,4 +1,3 @@
import OpenClawProtocol
public typealias AnyCodable = OpenClawProtocol.AnyCodable

View File

@@ -20,8 +20,8 @@ public enum OpenClawBonjour {
private static func resolveWideAreaDomain(_ raw: String?) -> String? {
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return nil }
let normalized = normalizeServiceDomain(trimmed)
return normalized == gatewayServiceDomain ? nil : normalized
let normalized = self.normalizeServiceDomain(trimmed)
return normalized == self.gatewayServiceDomain ? nil : normalized
}
public static func normalizeServiceDomain(_ raw: String?) -> String {

View File

@@ -3,9 +3,9 @@ import Foundation
public enum CaptureRateLimits {
public static func clampDurationMs(
_ ms: Int?,
defaultMs: Int = 10_000,
defaultMs: Int = 10000,
minMs: Int = 250,
maxMs: Int = 60_000) -> Int
maxMs: Int = 60000) -> Int
{
let value = ms ?? defaultMs
return min(maxMs, max(minMs, value))

View File

@@ -29,7 +29,7 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
/// Parse a device-pair setup code (base64url-encoded JSON: `{url, bootstrapToken?, token?, password?}`).
public static func fromSetupCode(_ code: String) -> GatewayConnectDeepLink? {
guard let data = Self.decodeBase64Url(code) else { return nil }
guard let data = decodeBase64Url(code) else { return nil }
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
guard let urlString = json["url"] as? String,
let parsed = URLComponents(string: urlString),

View File

@@ -16,8 +16,8 @@ public enum GatewayDeviceAuthPayload {
{
let scopeString = scopes.joined(separator: ",")
let authToken = token ?? ""
let normalizedPlatform = normalizeMetadataField(platform)
let normalizedDeviceFamily = normalizeMetadataField(deviceFamily)
let normalizedPlatform = self.normalizeMetadataField(platform)
let normalizedDeviceFamily = self.normalizeMetadataField(deviceFamily)
return [
"v3",
deviceId,

View File

@@ -25,7 +25,7 @@ public enum DeviceAuthStore {
public static func loadToken(deviceId: String, role: String) -> DeviceAuthEntry? {
guard let store = readStore(), store.deviceId == deviceId else { return nil }
let role = normalizeRole(role)
let role = self.normalizeRole(role)
return store.tokens[role]
}
@@ -33,10 +33,10 @@ public enum DeviceAuthStore {
deviceId: String,
role: String,
token: String,
scopes: [String] = []
) -> DeviceAuthEntry {
let normalizedRole = normalizeRole(role)
var next = readStore()
scopes: [String] = []) -> DeviceAuthEntry
{
let normalizedRole = self.normalizeRole(role)
var next = self.readStore()
if next?.deviceId != deviceId {
next = DeviceAuthStoreFile(version: 1, deviceId: deviceId, tokens: [:])
}
@@ -44,24 +44,23 @@ public enum DeviceAuthStore {
token: token,
role: normalizedRole,
scopes: normalizeScopes(scopes),
updatedAtMs: Int(Date().timeIntervalSince1970 * 1000)
)
updatedAtMs: Int(Date().timeIntervalSince1970 * 1000))
if next == nil {
next = DeviceAuthStoreFile(version: 1, deviceId: deviceId, tokens: [:])
}
next?.tokens[normalizedRole] = entry
if let store = next {
writeStore(store)
self.writeStore(store)
}
return entry
}
public static func clearToken(deviceId: String, role: String) {
guard var store = readStore(), store.deviceId == deviceId else { return }
let normalizedRole = normalizeRole(role)
let normalizedRole = self.normalizeRole(role)
guard store.tokens[normalizedRole] != nil else { return }
store.tokens.removeValue(forKey: normalizedRole)
writeStore(store)
self.writeStore(store)
}
private static func normalizeRole(_ role: String) -> String {
@@ -78,11 +77,11 @@ public enum DeviceAuthStore {
private static func fileURL() -> URL {
DeviceIdentityPaths.stateDirURL()
.appendingPathComponent("identity", isDirectory: true)
.appendingPathComponent(fileName, isDirectory: false)
.appendingPathComponent(self.fileName, isDirectory: false)
}
private static func readStore() -> DeviceAuthStoreFile? {
let url = fileURL()
let url = self.fileURL()
guard let data = try? Data(contentsOf: url) else { return nil }
guard let decoded = try? JSONDecoder().decode(DeviceAuthStoreFile.self, from: data) else {
return nil
@@ -92,7 +91,7 @@ public enum DeviceAuthStore {
}
private static func writeStore(_ store: DeviceAuthStoreFile) {
let url = fileURL()
let url = self.fileURL()
do {
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(),

View File

@@ -45,7 +45,8 @@ public enum DeviceIdentityStore {
let decoded = try? JSONDecoder().decode(DeviceIdentity.self, from: data),
!decoded.deviceId.isEmpty,
!decoded.publicKey.isEmpty,
!decoded.privateKey.isEmpty {
!decoded.privateKey.isEmpty
{
return decoded
}
let identity = self.generate()
@@ -107,6 +108,6 @@ public enum DeviceIdentityStore {
let base = DeviceIdentityPaths.stateDirURL()
return base
.appendingPathComponent("identity", isDirectory: true)
.appendingPathComponent(fileName, isDirectory: false)
.appendingPathComponent(self.fileName, isDirectory: false)
}
}

View File

@@ -1,5 +1,5 @@
import OpenClawProtocol
import Foundation
import OpenClawProtocol
import OSLog
public protocol WebSocketTasking: AnyObject {
@@ -20,9 +20,13 @@ public struct WebSocketTaskBox: @unchecked Sendable {
self.task = task
}
public var state: URLSessionTask.State { self.task.state }
public var state: URLSessionTask.State {
self.task.state
}
public func resume() { self.task.resume() }
public func resume() {
self.task.resume()
}
public func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
self.task.cancel(with: closeCode, reason: reason)
@@ -81,9 +85,9 @@ public struct GatewayConnectOptions: Sendable {
public var clientId: String
public var clientMode: String
public var clientDisplayName: String?
// When false, the connection omits the signed device identity payload and cannot use
// device-scoped auth (role/scope upgrades will require pairing). Keep this true for
// role/scoped sessions such as operator UI clients.
/// When false, the connection omits the signed device identity payload and cannot use
/// device-scoped auth (role/scope upgrades will require pairing). Keep this true for
/// role/scoped sessions such as operator UI clients.
public var includeDeviceIdentity: Bool
public init(
@@ -113,11 +117,11 @@ public enum GatewayAuthSource: String, Sendable {
case deviceToken = "device-token"
case sharedToken = "shared-token"
case bootstrapToken = "bootstrap-token"
case password = "password"
case none = "none"
case password
case none
}
// Avoid ambiguity with the app's own AnyCodable type.
/// Avoid ambiguity with the app's own AnyCodable type.
private typealias ProtoAnyCodable = OpenClawProtocol.AnyCodable
private enum ConnectChallengeError: Error {
@@ -132,13 +136,13 @@ private let defaultOperatorConnectScopes: [String] = [
"operator.pairing",
]
private extension String {
var nilIfEmpty: String? {
extension String {
fileprivate var nilIfEmpty: String? {
self.isEmpty ? nil : self
}
}
private struct SelectedConnectAuth: Sendable {
private struct SelectedConnectAuth {
let authToken: String?
let authBootstrapToken: String?
let authDeviceToken: String?
@@ -223,7 +227,9 @@ public actor GatewayChannelActor {
}
}
public func authSource() -> GatewayAuthSource { self.lastAuthSource }
public func authSource() -> GatewayAuthSource {
self.lastAuthSource
}
public func shutdown() async {
self.shouldReconnect = false
@@ -277,8 +283,7 @@ public actor GatewayChannelActor {
if self.shouldPauseReconnectAfterAuthFailure(error) {
self.reconnectPausedForAuthFailure = true
self.logger.error(
"gateway watchdog reconnect paused for non-recoverable auth failure \(error.localizedDescription, privacy: .public)"
)
"gateway watchdog reconnect paused for non-recoverable auth failure \(error.localizedDescription, privacy: .public)")
continue
}
let wrapped = self.wrap(error, context: "gateway watchdog reconnect")
@@ -312,11 +317,10 @@ public actor GatewayChannelActor {
},
operation: { try await self.sendConnect() })
} catch {
let wrapped: Error
if let authError = error as? GatewayConnectAuthError {
wrapped = authError
let wrapped: Error = if let authError = error as? GatewayConnectAuthError {
authError
} else {
wrapped = self.wrap(error, context: "connect to gateway @ \(self.url.absoluteString)")
self.wrap(error, context: "connect to gateway @ \(self.url.absoluteString)")
}
self.connected = false
self.task?.cancel(with: .goingAway, reason: nil)
@@ -422,7 +426,7 @@ public actor GatewayChannelActor {
role: role,
includeDeviceIdentity: includeDeviceIdentity,
deviceId: identity?.deviceId)
if selectedAuth.authDeviceToken != nil && self.pendingDeviceTokenRetry {
if selectedAuth.authDeviceToken != nil, self.pendingDeviceTokenRetry {
self.pendingDeviceTokenRetry = false
}
self.lastAuthSource = selectedAuth.authSource
@@ -485,8 +489,8 @@ public actor GatewayChannelActor {
self.deviceTokenRetryBudgetUsed = true
self.backoffMs = min(self.backoffMs, 250)
} else if selectedAuth.authDeviceToken != nil,
let identity,
self.shouldClearStoredDeviceTokenAfterRetry(error)
let identity,
self.shouldClearStoredDeviceTokenAfterRetry(error)
{
// Retry failed with an explicit device-token mismatch; clear stale local token.
DeviceAuthStore.clearToken(deviceId: identity.deviceId, role: role)
@@ -498,39 +502,38 @@ public actor GatewayChannelActor {
private func selectConnectAuth(
role: String,
includeDeviceIdentity: Bool,
deviceId: String?
) -> SelectedConnectAuth {
deviceId: String?) -> SelectedConnectAuth
{
let explicitToken = self.token?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
let explicitBootstrapToken =
self.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
let explicitPassword = self.password?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
let storedToken =
(includeDeviceIdentity && deviceId != nil)
? DeviceAuthStore.loadToken(deviceId: deviceId!, role: role)?.token
: nil
? DeviceAuthStore.loadToken(deviceId: deviceId!, role: role)?.token
: nil
let shouldUseDeviceRetryToken =
includeDeviceIdentity && self.pendingDeviceTokenRetry &&
storedToken != nil && explicitToken != nil && self.isTrustedDeviceRetryEndpoint()
let authToken =
explicitToken ??
// A freshly scanned setup code should force the bootstrap pairing path instead of
// silently reusing an older stored device token.
(includeDeviceIdentity && explicitPassword == nil && explicitBootstrapToken == nil
? storedToken
: nil)
// A freshly scanned setup code should force the bootstrap pairing path instead of
// silently reusing an older stored device token.
(includeDeviceIdentity && explicitPassword == nil && explicitBootstrapToken == nil
? storedToken
: nil)
let authBootstrapToken = authToken == nil ? explicitBootstrapToken : nil
let authDeviceToken = shouldUseDeviceRetryToken ? storedToken : nil
let authSource: GatewayAuthSource
if authDeviceToken != nil || (explicitToken == nil && authToken != nil) {
authSource = .deviceToken
let authSource: GatewayAuthSource = if authDeviceToken != nil || (explicitToken == nil && authToken != nil) {
.deviceToken
} else if authToken != nil {
authSource = .sharedToken
.sharedToken
} else if authBootstrapToken != nil {
authSource = .bootstrapToken
.bootstrapToken
} else if explicitPassword != nil {
authSource = .password
.password
} else {
authSource = .none
.none
}
return SelectedConnectAuth(
authToken: authToken,
@@ -560,7 +563,7 @@ public actor GatewayChannelActor {
case "node":
return []
case "operator":
let allowedOperatorScopes: Set<String> = [
let allowedOperatorScopes: Set = [
"operator.approvals",
"operator.read",
"operator.talk.secrets",
@@ -576,8 +579,8 @@ public actor GatewayChannelActor {
deviceId: String,
role: String,
token: String,
scopes: [String]
) {
scopes: [String])
{
guard let filteredScopes = self.filteredBootstrapHandoffScopes(role: role, scopes: scopes) else {
return
}
@@ -593,8 +596,8 @@ public actor GatewayChannelActor {
deviceId: String,
role: String,
token: String,
scopes: [String]
) {
scopes: [String])
{
if authSource == .bootstrapToken {
guard self.shouldPersistBootstrapHandoffTokens() else {
return
@@ -616,8 +619,8 @@ public actor GatewayChannelActor {
private func handleConnectResponse(
_ res: ResponseFrame,
identity: DeviceIdentity?,
role: String
) async throws {
role: String) async throws
{
if res.ok == false {
let msg = (res.error?["message"]?.value as? String) ?? "gateway connect failed"
let details = res.error?["details"]?.value as? [String: ProtoAnyCodable]
@@ -809,12 +812,11 @@ public actor GatewayChannelActor {
}
private nonisolated func decodeMessageData(_ msg: URLSessionWebSocketTask.Message) -> Data? {
let data: Data? = switch msg {
return switch msg {
case let .data(data): data
case let .string(text): text.data(using: .utf8)
@unknown default: nil
}
return data
}
private func watchTicks() async {
@@ -853,8 +855,7 @@ public actor GatewayChannelActor {
if self.shouldPauseReconnectAfterAuthFailure(error) {
self.reconnectPausedForAuthFailure = true
self.logger.error(
"gateway reconnect paused for non-recoverable auth failure \(error.localizedDescription, privacy: .public)"
)
"gateway reconnect paused for non-recoverable auth failure \(error.localizedDescription, privacy: .public)")
return
}
let wrapped = self.wrap(error, context: "gateway reconnect")
@@ -867,8 +868,8 @@ public actor GatewayChannelActor {
error: Error,
explicitGatewayToken: String?,
storedToken: String?,
attemptedDeviceTokenRetry: Bool
) -> Bool {
attemptedDeviceTokenRetry: Bool) -> Bool
{
if self.deviceTokenRetryBudgetUsed {
return false
}
@@ -895,8 +896,8 @@ public actor GatewayChannelActor {
if authError.isNonRecoverable {
return true
}
if authError.detail == .authTokenMismatch &&
self.deviceTokenRetryBudgetUsed && !self.pendingDeviceTokenRetry
if authError.detail == .authTokenMismatch,
self.deviceTokenRetryBudgetUsed, !self.pendingDeviceTokenRetry
{
return true
}
@@ -1007,7 +1008,7 @@ public actor GatewayChannelActor {
}
}
// Wrap low-level URLSession/WebSocket errors with context so UI can surface them.
/// Wrap low-level URLSession/WebSocket errors with context so UI can surface them.
private func wrap(_ error: Error, context: String) -> Error {
if error is GatewayConnectAuthError || error is GatewayResponseError || error is GatewayDecodingError {
return error
@@ -1055,8 +1056,7 @@ public actor GatewayChannelActor {
return (id: id, data: data)
} catch {
self.logger.error(
"gateway \(kind) encode failed \(method, privacy: .public) error=\(error.localizedDescription, privacy: .public)"
)
"gateway \(kind) encode failed \(method, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
throw error
}
}

View File

@@ -9,9 +9,9 @@ public enum GatewayConnectChallengeSupport {
return trimmed
}
public static func waitForNonce<E: Error>(
public static func waitForNonce(
timeoutSeconds: Double,
onTimeout: @escaping @Sendable () -> E,
onTimeout: @escaping @Sendable () -> some Error,
receiveNonce: @escaping @Sendable () async throws -> String?) async throws -> String
{
try await AsyncTimeout.withTimeout(

View File

@@ -81,32 +81,34 @@ public struct GatewayConnectionProblem: Equatable, Sendable {
public var needsPairingApproval: Bool {
switch self.kind {
case .pairingRequired, .pairingRoleUpgradeRequired, .pairingScopeUpgradeRequired, .pairingMetadataUpgradeRequired:
return true
case .pairingRequired, .pairingRoleUpgradeRequired, .pairingScopeUpgradeRequired,
.pairingMetadataUpgradeRequired:
true
default:
return false
false
}
}
public var needsCredentialUpdate: Bool {
switch self.kind {
case .gatewayAuthTokenMissing,
.gatewayAuthTokenMismatch,
.gatewayAuthTokenNotConfigured,
.gatewayAuthPasswordMissing,
.gatewayAuthPasswordMismatch,
.gatewayAuthPasswordNotConfigured,
.bootstrapTokenInvalid,
.deviceTokenMismatch:
return true
.gatewayAuthTokenMismatch,
.gatewayAuthTokenNotConfigured,
.gatewayAuthPasswordMissing,
.gatewayAuthPasswordMismatch,
.gatewayAuthPasswordNotConfigured,
.bootstrapTokenInvalid,
.deviceTokenMismatch:
true
default:
return false
false
}
}
public var statusText: String {
switch self.kind {
case .pairingRequired, .pairingRoleUpgradeRequired, .pairingScopeUpgradeRequired, .pairingMetadataUpgradeRequired:
case .pairingRequired, .pairingRoleUpgradeRequired, .pairingScopeUpgradeRequired,
.pairingMetadataUpgradeRequired:
if let requestId {
return "\(self.title) (request ID: \(requestId))"
}
@@ -123,7 +125,10 @@ public struct GatewayConnectionProblem: Equatable, Sendable {
}
public enum GatewayConnectionProblemMapper {
public static func map(error: Error, preserving previousProblem: GatewayConnectionProblem? = nil) -> GatewayConnectionProblem? {
public static func map(
error: Error,
preserving previousProblem: GatewayConnectionProblem? = nil) -> GatewayConnectionProblem?
{
guard let nextProblem = self.rawMap(error) else {
return nil
}
@@ -136,14 +141,20 @@ public enum GatewayConnectionProblemMapper {
return nextProblem
}
public static func shouldPreserve(previousProblem: GatewayConnectionProblem, over nextProblem: GatewayConnectionProblem) -> Bool {
public static func shouldPreserve(
previousProblem: GatewayConnectionProblem,
over nextProblem: GatewayConnectionProblem) -> Bool
{
if nextProblem.kind == .websocketCancelled {
return previousProblem.pauseReconnect || previousProblem.requestId != nil
}
return false
}
public static func shouldPreserve(previousProblem: GatewayConnectionProblem, overDisconnectReason reason: String) -> Bool {
public static func shouldPreserve(
previousProblem: GatewayConnectionProblem,
overDisconnectReason reason: String) -> Bool
{
let normalized = reason.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !normalized.isEmpty else { return false }
if normalized.contains("cancelled") || normalized.contains("canceled") {
@@ -175,7 +186,9 @@ public enum GatewayConnectionProblemMapper {
?? "This gateway requires an auth token, but this iPhone did not send one.",
actionLabel: authError.actionLabel ?? "Open Settings",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/authentication"),
docsURL: self.docsURL(
authError.docsURLString,
fallback: "https://docs.openclaw.ai/gateway/authentication"),
requestId: authError.requestId,
retryable: false,
pauseReconnect: true,
@@ -187,9 +200,12 @@ public enum GatewayConnectionProblemMapper {
title: authError.titleOverride ?? "Gateway token is out of date",
message: authError.userMessageOverride
?? "The token on this iPhone does not match the gateway token.",
actionLabel: authError.actionLabel ?? (authError.canRetryWithDeviceToken ? "Retry once" : "Update gateway token"),
actionLabel: authError
.actionLabel ?? (authError.canRetryWithDeviceToken ? "Retry once" : "Update gateway token"),
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/authentication"),
docsURL: self.docsURL(
authError.docsURLString,
fallback: "https://docs.openclaw.ai/gateway/authentication"),
requestId: authError.requestId,
retryable: authError.retryableOverride ?? authError.canRetryWithDeviceToken,
pauseReconnect: authError.pauseReconnectOverride ?? !authError.canRetryWithDeviceToken,
@@ -203,7 +219,9 @@ public enum GatewayConnectionProblemMapper {
?? "This gateway is set to token auth, but no gateway token is configured on the gateway.",
actionLabel: authError.actionLabel ?? "Fix on gateway",
actionCommand: authError.actionCommand ?? "openclaw config set gateway.auth.token <new-token>",
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/authentication"),
docsURL: self.docsURL(
authError.docsURLString,
fallback: "https://docs.openclaw.ai/gateway/authentication"),
requestId: authError.requestId,
retryable: false,
pauseReconnect: true,
@@ -217,7 +235,9 @@ public enum GatewayConnectionProblemMapper {
?? "This gateway requires a password, but this iPhone did not send one.",
actionLabel: authError.actionLabel ?? "Open Settings",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/authentication"),
docsURL: self.docsURL(
authError.docsURLString,
fallback: "https://docs.openclaw.ai/gateway/authentication"),
requestId: authError.requestId,
retryable: false,
pauseReconnect: true,
@@ -231,7 +251,9 @@ public enum GatewayConnectionProblemMapper {
?? "The saved password on this iPhone does not match the gateway password.",
actionLabel: authError.actionLabel ?? "Update password",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/authentication"),
docsURL: self.docsURL(
authError.docsURLString,
fallback: "https://docs.openclaw.ai/gateway/authentication"),
requestId: authError.requestId,
retryable: false,
pauseReconnect: true,
@@ -245,7 +267,9 @@ public enum GatewayConnectionProblemMapper {
?? "This gateway is set to password auth, but no gateway password is configured on the gateway.",
actionLabel: authError.actionLabel ?? "Fix on gateway",
actionCommand: authError.actionCommand ?? "openclaw config set gateway.auth.password <new-password>",
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/authentication"),
docsURL: self.docsURL(
authError.docsURLString,
fallback: "https://docs.openclaw.ai/gateway/authentication"),
requestId: authError.requestId,
retryable: false,
pauseReconnect: true,
@@ -286,7 +310,8 @@ public enum GatewayConnectionProblemMapper {
owner: .iphone,
title: authError.titleOverride ?? "Secure device identity is required",
message: authError.userMessageOverride
?? "This connection must include a signed device identity before the gateway can bind permissions to this iPhone.",
??
"This connection must include a signed device identity before the gateway can bind permissions to this iPhone.",
actionLabel: authError.actionLabel ?? "Retry from the app",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/platforms/ios"),
@@ -302,7 +327,9 @@ public enum GatewayConnectionProblemMapper {
message: authError.userMessageOverride ?? "The device signature is too old to use.",
actionLabel: authError.actionLabel ?? "Check iPhone time",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/troubleshooting"),
docsURL: self.docsURL(
authError.docsURLString,
fallback: "https://docs.openclaw.ai/gateway/troubleshooting"),
requestId: authError.requestId,
retryable: true,
pauseReconnect: true,
@@ -316,7 +343,9 @@ public enum GatewayConnectionProblemMapper {
?? "The gateway expected a one-time challenge response, but the nonce was missing.",
actionLabel: authError.actionLabel ?? "Retry",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/troubleshooting"),
docsURL: self.docsURL(
authError.docsURLString,
fallback: "https://docs.openclaw.ai/gateway/troubleshooting"),
requestId: authError.requestId,
retryable: true,
pauseReconnect: true,
@@ -329,7 +358,9 @@ public enum GatewayConnectionProblemMapper {
message: authError.userMessageOverride ?? "The challenge response was stale or mismatched.",
actionLabel: authError.actionLabel ?? "Retry",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/troubleshooting"),
docsURL: self.docsURL(
authError.docsURLString,
fallback: "https://docs.openclaw.ai/gateway/troubleshooting"),
requestId: authError.requestId,
retryable: true,
pauseReconnect: true,
@@ -441,7 +472,9 @@ public enum GatewayConnectionProblemMapper {
?? "The gateway is temporarily refusing new auth attempts after repeated failures.",
actionLabel: authError.actionLabel ?? "Wait and retry",
actionCommand: authError.actionCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/troubleshooting"),
docsURL: self.docsURL(
authError.docsURLString,
fallback: "https://docs.openclaw.ai/gateway/troubleshooting"),
requestId: authError.requestId,
retryable: false,
pauseReconnect: true,
@@ -520,7 +553,8 @@ public enum GatewayConnectionProblemMapper {
retryable: true,
pauseReconnect: false,
technicalDetails: rawMessage)
case .cannotFindHost, .dnsLookupFailed, .notConnectedToInternet, .networkConnectionLost, .internationalRoamingOff, .callIsActive, .dataNotAllowed:
case .cannotFindHost, .dnsLookupFailed, .notConnectedToInternet, .networkConnectionLost,
.internationalRoamingOff, .callIsActive, .dataNotAllowed:
return GatewayConnectionProblem(
kind: .reachabilityFailed,
owner: .network,
@@ -575,7 +609,9 @@ public enum GatewayConnectionProblemMapper {
pauseReconnect: false,
technicalDetails: rawMessage)
}
if lower.contains("cannot find host") || lower.contains("could not connect") || lower.contains("network is unreachable") {
if lower.contains("cannot find host") || lower.contains("could not connect") || lower
.contains("network is unreachable")
{
return GatewayConnectionProblem(
kind: .reachabilityFailed,
owner: .network,
@@ -615,7 +651,8 @@ public enum GatewayConnectionProblemMapper {
owner: .gateway,
title: authError.titleOverride ?? "Additional approval required",
message: authError.userMessageOverride
?? "This iPhone is already paired, but it is requesting a new role that was not previously approved.",
??
"This iPhone is already paired, but it is requesting a new role that was not previously approved.",
actionLabel: authError.actionLabel ?? "Approve on gateway",
actionCommand: authError.actionCommand ?? pairingCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"),
@@ -643,7 +680,8 @@ public enum GatewayConnectionProblemMapper {
owner: .gateway,
title: authError.titleOverride ?? "Device approval needs refresh",
message: authError.userMessageOverride
?? "The gateway detected a change in this device's approved identity metadata and requires re-approval.",
??
"The gateway detected a change in this device's approved identity metadata and requires re-approval.",
actionLabel: authError.actionLabel ?? "Approve on gateway",
actionCommand: authError.actionCommand ?? pairingCommand,
docsURL: self.docsURL(authError.docsURLString, fallback: "https://docs.openclaw.ai/gateway/pairing"),
@@ -736,17 +774,17 @@ public enum GatewayConnectionProblemMapper {
private static func owner(from raw: String) -> GatewayConnectionProblem.Owner? {
switch raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
case "gateway":
return .gateway
.gateway
case "iphone", "ios", "device":
return .iphone
.iphone
case "both":
return .both
.both
case "network":
return .network
.network
case "unknown", "":
return .unknown
.unknown
default:
return nil
nil
}
}

View File

@@ -36,4 +36,3 @@ public enum GatewayDiscoveryStatusText {
return "Searching…"
}
}

View File

@@ -1,5 +1,5 @@
import OpenClawProtocol
import Foundation
import OpenClawProtocol
public enum GatewayConnectAuthDetailCode: String, Sendable {
case authRequired = "AUTH_REQUIRED"
@@ -129,9 +129,13 @@ public struct GatewayConnectAuthError: LocalizedError, Sendable {
return trimmed.isEmpty ? nil : trimmed
}
public var detailCode: String? { self.detailCodeRaw }
public var detailCode: String? {
self.detailCodeRaw
}
public var recommendedNextStepCode: String? { self.recommendedNextStepRaw }
public var recommendedNextStepCode: String? {
self.recommendedNextStepRaw
}
public var detail: GatewayConnectAuthDetailCode? {
guard let detailCodeRaw else { return nil }
@@ -143,23 +147,25 @@ public struct GatewayConnectAuthError: LocalizedError, Sendable {
return GatewayConnectRecoveryNextStep(rawValue: recommendedNextStepRaw)
}
public var errorDescription: String? { self.message }
public var errorDescription: String? {
self.message
}
public var isNonRecoverable: Bool {
switch self.detail {
case .authTokenMissing,
.authBootstrapTokenInvalid,
.authTokenNotConfigured,
.authPasswordMissing,
.authPasswordMismatch,
.authPasswordNotConfigured,
.authRateLimited,
.pairingRequired,
.controlUiDeviceIdentityRequired,
.deviceIdentityRequired:
return true
.authBootstrapTokenInvalid,
.authTokenNotConfigured,
.authPasswordMissing,
.authPasswordMismatch,
.authPasswordNotConfigured,
.authRateLimited,
.pairingRequired,
.controlUiDeviceIdentityRequired,
.deviceIdentityRequired:
true
default:
return false
false
}
}
}
@@ -203,5 +209,7 @@ public struct GatewayDecodingError: LocalizedError, Sendable {
self.message = message
}
public var errorDescription: String? { "\(self.method): \(self.message)" }
public var errorDescription: String? {
"\(self.method): \(self.message)"
}
}

View File

@@ -1,8 +1,8 @@
import OpenClawProtocol
import Foundation
import OpenClawProtocol
import OSLog
private struct NodeInvokeRequestPayload: Codable, Sendable {
private struct NodeInvokeRequestPayload: Codable {
var id: String
var nodeId: String
var command: String
@@ -19,7 +19,7 @@ private func replaceCanvasCapabilityInScopedHostUrl(scopedUrl: String, capabilit
let nextSlash = suffix.firstIndex(of: "/")
let nextQuery = suffix.firstIndex(of: "?")
let nextFragment = suffix.firstIndex(of: "#")
let capabilityEnd = [nextSlash, nextQuery, nextFragment].compactMap { $0 }.min() ?? scopedUrl.endIndex
let capabilityEnd = [nextSlash, nextQuery, nextFragment].compactMap(\.self).min() ?? scopedUrl.endIndex
guard capabilityStart < capabilityEnd else { return nil }
return String(scopedUrl[..<capabilityStart]) + capability + String(scopedUrl[capabilityEnd...])
}
@@ -55,12 +55,11 @@ func canonicalizeCanvasHostUrl(raw: String?, activeURL: URL?) -> String? {
return parsed.string ?? trimmed
}
public actor GatewayNodeSession {
private let logger = Logger(subsystem: "ai.openclaw", category: "node.gateway")
private let decoder = JSONDecoder()
private let encoder = JSONEncoder()
private static let defaultInvokeTimeoutMs = 30_000
private static let defaultInvokeTimeoutMs = 30000
private var channel: GatewayChannelActor?
private var activeURL: URL?
private var activeToken: String?
@@ -79,8 +78,8 @@ public actor GatewayNodeSession {
static func invokeWithTimeout(
request: BridgeInvokeRequest,
timeoutMs: Int?,
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse
) async -> BridgeInvokeResponse {
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse) async -> BridgeInvokeResponse
{
let timeoutLogger = Logger(subsystem: "ai.openclaw", category: "node.gateway")
let timeout: Int = {
if let timeoutMs { return max(0, timeoutMs) }
@@ -144,13 +143,14 @@ public actor GatewayNodeSession {
ok: false,
error: OpenClawNodeError(
code: .unavailable,
message: "node invoke timed out")
))
message: "node invoke timed out")))
}
}
timeoutLogger.info("node invoke race resolved id=\(request.id, privacy: .public) ok=\(response.ok, privacy: .public)")
timeoutLogger
.info("node invoke race resolved id=\(request.id, privacy: .public) ok=\(response.ok, privacy: .public)")
return response
}
private var serverEventSubscribers: [UUID: AsyncStream<EventFrame>.Continuation] = [:]
private var canvasHostUrl: String?
@@ -201,8 +201,8 @@ public actor GatewayNodeSession {
sessionBox: WebSocketSessionBox?,
onConnected: @escaping @Sendable () async -> Void,
onDisconnected: @escaping @Sendable (String) async -> Void,
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse
) async throws {
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse) async throws
{
let nextOptionsKey = self.connectOptionsKey(connectOptions)
let shouldReconnect = self.activeURL != url ||
self.activeToken != token ||
@@ -273,7 +273,7 @@ public actor GatewayNodeSession {
self.canvasHostUrl
}
public func refreshNodeCanvasCapability(timeoutMs: Int = 8_000) async -> Bool {
public func refreshNodeCanvasCapability(timeoutMs: Int = 8000) async -> Bool {
guard let channel = self.channel else { return false }
do {
let data = try await channel.request(
@@ -455,8 +455,7 @@ public actor GatewayNodeSession {
let response = await Self.invokeWithTimeout(
request: req,
timeoutMs: request.timeoutMs,
onInvoke: onInvoke
)
onInvoke: onInvoke)
self.logger.info(
"node invoke completed id=\(request.id, privacy: .public) ok=\(response.ok, privacy: .public)")
await self.sendInvokeResult(request: request, response: response)

View File

@@ -1,5 +1,5 @@
import OpenClawProtocol
import Foundation
import OpenClawProtocol
public enum GatewayPayloadDecoding {
public static func decode<T: Decodable>(

View File

@@ -66,7 +66,8 @@ public enum GatewayTLSStore {
!existing.isEmpty
else { return }
if GenericPasswordKeychainStore.loadString(service: self.keychainService, account: stableID) == nil {
guard GenericPasswordKeychainStore.saveString(existing, service: self.keychainService, account: stableID) else {
guard GenericPasswordKeychainStore.saveString(existing, service: self.keychainService, account: stableID)
else {
return
}
}
@@ -108,8 +109,8 @@ public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLS
public func urlSession(
_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void)
{
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
let trust = challenge.protectionSpace.serverTrust
else {
@@ -117,7 +118,7 @@ public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLS
return
}
let expected = params.expectedFingerprint.map(normalizeFingerprint)
let expected = self.params.expectedFingerprint.map(normalizeFingerprint)
if let fingerprint = certificateFingerprint(trust) {
if let expected {
if fingerprint == expected {
@@ -127,7 +128,7 @@ public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLS
}
return
}
if params.allowTOFU {
if self.params.allowTOFU {
if let storeKey = params.storeKey {
GatewayTLSStore.saveFingerprint(fingerprint, stableID: storeKey)
}
@@ -137,7 +138,7 @@ public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLS
}
let ok = SecTrustEvaluateWithError(trust, nil)
if ok || !params.required {
if ok || !self.params.required {
completionHandler(.useCredential, URLCredential(trust: trust))
} else {
completionHandler(.cancelAuthenticationChallenge, nil)

View File

@@ -12,8 +12,8 @@ public enum GenericPasswordKeychainStore {
_ value: String,
service: String,
account: String,
accessible: CFString = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
) -> Bool {
accessible: CFString = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly) -> Bool
{
self.saveData(Data(value.utf8), service: service, account: account, accessible: accessible)
}
@@ -40,8 +40,8 @@ public enum GenericPasswordKeychainStore {
_ data: Data,
service: String,
account: String,
accessible: CFString
) -> Bool {
accessible: CFString) -> Bool
{
let query = self.baseQuery(service: service, account: account)
let previousData = self.loadData(service: service, account: account)

View File

@@ -12,7 +12,7 @@ public enum InstanceIdentity {
UserDefaults(suiteName: suiteName) ?? .standard
}
#if canImport(UIKit)
#if canImport(UIKit)
private static func readMainActor<T: Sendable>(_ body: @MainActor () -> T) -> T {
if Thread.isMainThread {
return MainActor.assumeIsolated { body() }
@@ -21,7 +21,7 @@ public enum InstanceIdentity {
MainActor.assumeIsolated { body() }
}
}
#endif
#endif
public static let instanceId: String = {
let defaults = Self.defaults
@@ -38,23 +38,23 @@ public enum InstanceIdentity {
}()
public static let displayName: String = {
#if canImport(UIKit)
#if canImport(UIKit)
let name = Self.readMainActor {
UIDevice.current.name.trimmingCharacters(in: .whitespacesAndNewlines)
}
return name.isEmpty ? "openclaw" : name
#else
#else
if let name = Host.current().localizedName?.trimmingCharacters(in: .whitespacesAndNewlines),
!name.isEmpty
{
return name
}
return "openclaw"
#endif
#endif
}()
public static let modelIdentifier: String? = {
#if canImport(UIKit)
#if canImport(UIKit)
var systemInfo = utsname()
uname(&systemInfo)
let machine = withUnsafeBytes(of: &systemInfo.machine) { ptr in
@@ -62,7 +62,7 @@ public enum InstanceIdentity {
}
let trimmed = machine?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
#else
#else
var size = 0
guard sysctlbyname("hw.model", nil, &size, nil, 0) == 0, size > 1 else { return nil }
@@ -73,36 +73,36 @@ public enum InstanceIdentity {
guard let raw = String(bytes: bytes, encoding: .utf8) else { return nil }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
#endif
#endif
}()
public static let deviceFamily: String = {
#if canImport(UIKit)
#if canImport(UIKit)
return Self.readMainActor {
switch UIDevice.current.userInterfaceIdiom {
case .pad: return "iPad"
case .phone: return "iPhone"
default: return "iOS"
case .pad: "iPad"
case .phone: "iPhone"
default: "iOS"
}
}
#else
#else
return "Mac"
#endif
#endif
}()
public static let platformString: String = {
let v = ProcessInfo.processInfo.operatingSystemVersion
#if canImport(UIKit)
#if canImport(UIKit)
let name = Self.readMainActor {
switch UIDevice.current.userInterfaceIdiom {
case .pad: return "iPadOS"
case .phone: return "iOS"
default: return "iOS"
case .pad: "iPadOS"
case .phone: "iOS"
default: "iOS"
}
}
return "\(name) \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
#else
#else
return "macOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
#endif
#endif
}()
}

View File

@@ -4,8 +4,7 @@ import Foundation
public enum LocationCurrentRequest {
public typealias TimeoutRunner = @Sendable (
_ timeoutMs: Int,
_ operation: @escaping @Sendable () async throws -> CLLocation
) async throws -> CLLocation
_ operation: @escaping @Sendable () async throws -> CLLocation) async throws -> CLLocation
@MainActor
public static func resolve(

View File

@@ -7,21 +7,21 @@ public protocol LocationServiceCommon: AnyObject, CLLocationManagerDelegate {
var locationRequestContinuation: CheckedContinuation<CLLocation, Error>? { get set }
}
public extension LocationServiceCommon {
func configureLocationManager() {
extension LocationServiceCommon {
public func configureLocationManager() {
self.locationManager.delegate = self
self.locationManager.desiredAccuracy = kCLLocationAccuracyBest
}
func authorizationStatus() -> CLAuthorizationStatus {
public func authorizationStatus() -> CLAuthorizationStatus {
self.locationManager.authorizationStatus
}
func accuracyAuthorization() -> CLAccuracyAuthorization {
public func accuracyAuthorization() -> CLAccuracyAuthorization {
LocationServiceSupport.accuracyAuthorization(manager: self.locationManager)
}
func requestLocationOnce() async throws -> CLLocation {
public func requestLocationOnce() async throws -> CLLocation {
try await LocationServiceSupport.requestLocation(manager: self.locationManager) { continuation in
self.locationRequestContinuation = continuation
}

View File

@@ -18,7 +18,7 @@ public enum OpenClawKitResources {
private static func locateBundle() -> Bundle {
// 1. Check inside Bundle.main (packaged apps copy resources here)
if let mainResourceURL = Bundle.main.resourceURL {
let bundleURL = mainResourceURL.appendingPathComponent("\(bundleName).bundle")
let bundleURL = mainResourceURL.appendingPathComponent("\(self.bundleName).bundle")
if let bundle = Bundle(url: bundleURL) {
return bundle
}
@@ -60,7 +60,7 @@ public enum OpenClawKitResources {
roots.append(baseURL.appendingPathComponent("Contents/Resources"))
var current = baseURL
for _ in 0 ..< 5 {
for _ in 0..<5 {
current = current.deletingLastPathComponent()
roots.append(current)
roots.append(current.appendingPathComponent("Resources"))
@@ -68,7 +68,7 @@ public enum OpenClawKitResources {
}
for root in roots {
let bundleURL = root.appendingPathComponent("\(bundleName).bundle")
let bundleURL = root.appendingPathComponent("\(self.bundleName).bundle")
if let bundle = Bundle(url: bundleURL) {
return bundle
}
@@ -79,5 +79,5 @@ public enum OpenClawKitResources {
}
}
// Helper class for bundle lookup via Bundle(for:)
/// Helper class for bundle lookup via Bundle(for:)
private final class BundleLocator {}

View File

@@ -5,8 +5,8 @@ public enum PhotoCapture {
rawData: Data,
maxWidthPx: Int,
quality: Double,
maxPayloadBytes: Int = 5 * 1024 * 1024
) throws -> (data: Data, widthPx: Int, heightPx: Int) {
maxPayloadBytes: Int = 5 * 1024 * 1024) throws -> (data: Data, widthPx: Int, heightPx: Int)
{
// Base64 inflates payloads by ~4/3; cap encoded bytes so the payload stays under maxPayloadBytes (API limit).
let maxEncodedBytes = (maxPayloadBytes / 4) * 3
return try JPEGTranscoder.transcodeToJPEG(
@@ -16,4 +16,3 @@ public enum PhotoCapture {
maxBytes: maxEncodedBytes)
}
}

View File

@@ -33,7 +33,7 @@ public enum ShareToAgentDeepLink {
let urlText = payload.url?.absoluteString.trimmingCharacters(in: .whitespacesAndNewlines)
let resolvedInstruction = self.clean(instruction) ?? ShareToAgentSettings.loadDefaultInstruction()
var lines: [String] = ["Shared from iOS."]
var lines = ["Shared from iOS."]
if let title, !title.isEmpty {
lines.append("Title: \(title)")
}

View File

@@ -20,8 +20,8 @@ public enum TalkConfigParsing {
public static func selectProviderConfig(
_ talk: [String: AnyCodable]?,
defaultProvider: String,
allowLegacyFallback: Bool = true,
) -> TalkProviderConfigSelection? {
allowLegacyFallback: Bool = true) -> TalkProviderConfigSelection?
{
guard let talk else { return nil }
if let resolvedSelection = self.resolvedProviderConfig(talk) {
return resolvedSelection
@@ -63,16 +63,16 @@ public enum TalkConfigParsing {
public static func resolvedSpeechLocaleID(
_ talk: [String: AnyCodable]?,
fallback: String? = nil
) -> String? {
fallback: String? = nil) -> String?
{
self.normalizedSpeechLocaleID(talk?["speechLocale"]?.stringValue)
?? self.normalizedSpeechLocaleID(fallback)
}
public static func normalizedExplicitSpeechLocaleID(
_ value: String?,
automaticID: String = "auto"
) -> String? {
automaticID: String = "auto") -> String?
{
let normalized = self.normalizedSpeechLocaleID(value)
return normalized == automaticID ? nil : normalized
}
@@ -80,8 +80,8 @@ public enum TalkConfigParsing {
public static func resolvedSpeechRecognitionLocaleID(
preferredLocaleIDs: [String?],
fallbackLocaleID: String = "en-US",
supportedLocaleIDs: Set<String>
) -> String? {
supportedLocaleIDs: Set<String>) -> String?
{
let supported = Set(supportedLocaleIDs.compactMap(self.normalizedSpeechLocaleID))
var seen = Set<String>()
let candidates = (preferredLocaleIDs + [fallbackLocaleID])
@@ -102,8 +102,8 @@ public enum TalkConfigParsing {
}
private static func resolvedProviderConfig(
_ talk: [String: AnyCodable]
) -> TalkProviderConfigSelection? {
_ talk: [String: AnyCodable]) -> TalkProviderConfigSelection?
{
guard
let resolved = talk["resolved"]?.dictionaryValue,
let providerID = self.normalizedTalkProviderID(resolved["provider"]?.stringValue)

View File

@@ -2,16 +2,15 @@ public enum TalkPromptBuilder: Sendable {
public static func build(
transcript: String,
interruptedAtSeconds: Double?,
includeVoiceDirectiveHint: Bool = true
) -> String {
includeVoiceDirectiveHint: Bool = true) -> String
{
var lines: [String] = [
"Talk Mode active. Reply in a concise, spoken tone.",
]
if includeVoiceDirectiveHint {
lines.append(
"You may optionally prefix the response with JSON (first line) to set ElevenLabs voice (id or alias), e.g. {\"voice\":\"<id>\",\"once\":true}."
)
"You may optionally prefix the response with JSON (first line) to set ElevenLabs voice (id or alias), e.g. {\"voice\":\"<id>\",\"once\":true}.")
}
if let interruptedAtSeconds {

View File

@@ -16,7 +16,9 @@ public final class TalkSystemSpeechSynthesizer: NSObject {
private var currentToken = UUID()
private var watchdog: Task<Void, Never>?
public var isSpeaking: Bool { self.synth.isSpeaking }
public var isSpeaking: Bool {
self.synth.isSpeaking
}
override private init() {
super.init()
@@ -35,8 +37,8 @@ public final class TalkSystemSpeechSynthesizer: NSObject {
public func speak(
text: String,
language: String? = nil,
onStart: (() -> Void)? = nil
) async throws {
onStart: (() -> Void)? = nil) async throws
{
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
@@ -51,7 +53,9 @@ public final class TalkSystemSpeechSynthesizer: NSObject {
}
self.currentUtterance = utterance
let watchdogTimeout = Self.watchdogTimeoutSeconds(text: trimmed, language: language ?? utterance.voice?.language)
let watchdogTimeout = Self.watchdogTimeoutSeconds(
text: trimmed,
language: language ?? utterance.voice?.language)
self.watchdog?.cancel()
self.watchdog = Task { @MainActor [weak self] in
guard let self else { return }

View File

@@ -6,7 +6,9 @@ import Foundation
public struct AnyCodable: Codable, @unchecked Sendable, Hashable {
public let value: Any
public init(_ value: Any) { self.value = Self.normalize(value) }
public init(_ value: Any) {
self.value = Self.normalize(value)
}
public init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()

View File

@@ -74,11 +74,11 @@ public func anyCodableBool(_ value: AnyCodable?) -> Bool {
public func anyCodableArray(_ value: AnyCodable?) -> [AnyCodable] {
switch value?.value {
case let arr as [AnyCodable]:
return arr
arr
case let arr as [Any]:
return arr.map { AnyCodable($0) }
arr.map { AnyCodable($0) }
default:
return []
[]
}
}