mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 15:50:46 +00:00
fix: harden ios app build hygiene
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -128,7 +128,9 @@ enum OpenClawChatTheme {
|
||||
#endif
|
||||
}
|
||||
|
||||
static var userText: Color { .white }
|
||||
static var userText: Color {
|
||||
.white
|
||||
}
|
||||
|
||||
static var assistantText: Color {
|
||||
#if os(macOS)
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import OpenClawProtocol
|
||||
|
||||
public typealias AnyCodable = OpenClawProtocol.AnyCodable
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,4 +36,3 @@ public enum GatewayDiscoveryStatusText {
|
||||
return "Searching…"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import OpenClawProtocol
|
||||
import Foundation
|
||||
import OpenClawProtocol
|
||||
|
||||
public enum GatewayPayloadDecoding {
|
||||
public static func decode<T: Decodable>(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 []
|
||||
[]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user