mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 09:58:38 +00:00
iOS: alpha node app + setup-code onboarding (#11756)
This commit is contained in:
@@ -9,6 +9,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
- Gateway: add `agents.create`, `agents.update`, `agents.delete` RPC methods for web UI agent management. (#11045) Thanks @advaitpaliwal.
|
- Gateway: add `agents.create`, `agents.update`, `agents.delete` RPC methods for web UI agent management. (#11045) Thanks @advaitpaliwal.
|
||||||
- Gateway: add node command allowlists (default-deny unknown node commands; configurable via `gateway.nodes.allowCommands` / `gateway.nodes.denyCommands`). (#11755) Thanks @mbelinky.
|
- Gateway: add node command allowlists (default-deny unknown node commands; configurable via `gateway.nodes.allowCommands` / `gateway.nodes.denyCommands`). (#11755) Thanks @mbelinky.
|
||||||
- Plugins: add `device-pair` (Telegram `/pair` flow) and `phone-control` (iOS/Android node controls). (#11755) Thanks @mbelinky.
|
- Plugins: add `device-pair` (Telegram `/pair` flow) and `phone-control` (iOS/Android node controls). (#11755) Thanks @mbelinky.
|
||||||
|
- iOS: add alpha iOS node app (Telegram setup-code pairing + Talk/Chat surfaces). (#11756) Thanks @mbelinky.
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +1,66 @@
|
|||||||
# OpenClaw (iOS)
|
# OpenClaw (iOS)
|
||||||
|
|
||||||
Internal-only SwiftUI app scaffold.
|
This is an **alpha** iOS app that connects to an OpenClaw Gateway as a `role: node`.
|
||||||
|
|
||||||
|
Expect rough edges:
|
||||||
|
|
||||||
|
- UI and onboarding are changing quickly.
|
||||||
|
- Background behavior is not stable yet (foreground app is the supported mode right now).
|
||||||
|
- Permissions are opt-in and the app should be treated as sensitive while we harden it.
|
||||||
|
|
||||||
|
## What It Does
|
||||||
|
|
||||||
|
- Connects to a Gateway over `ws://` / `wss://`
|
||||||
|
- Pairs a new device (approved from your bot)
|
||||||
|
- Exposes phone services as node commands (camera, location, photos, calendar, reminders, etc; gated by iOS permissions)
|
||||||
|
- Provides Talk + Chat surfaces (alpha)
|
||||||
|
|
||||||
|
## Pairing (Recommended Flow)
|
||||||
|
|
||||||
|
If your Gateway has the `device-pair` plugin installed:
|
||||||
|
|
||||||
|
1. In Telegram, message your bot: `/pair`
|
||||||
|
2. Copy the **setup code** message
|
||||||
|
3. On iOS: OpenClaw → Settings → Gateway → paste setup code → Connect
|
||||||
|
4. Back in Telegram: `/pair approve`
|
||||||
|
|
||||||
|
## Build And Run
|
||||||
|
|
||||||
|
Prereqs:
|
||||||
|
|
||||||
|
- Xcode (current stable)
|
||||||
|
- `pnpm`
|
||||||
|
- `xcodegen`
|
||||||
|
|
||||||
|
From the repo root:
|
||||||
|
|
||||||
## Lint/format (required)
|
|
||||||
```bash
|
```bash
|
||||||
brew install swiftformat swiftlint
|
pnpm install
|
||||||
|
pnpm ios:open
|
||||||
```
|
```
|
||||||
|
|
||||||
## Generate the Xcode project
|
Then in Xcode:
|
||||||
|
|
||||||
|
1. Select the `OpenClaw` scheme
|
||||||
|
2. Select a simulator or a connected device
|
||||||
|
3. Run
|
||||||
|
|
||||||
|
If you're using a personal Apple Development team, you may need to change the bundle identifier in Xcode to a unique value so signing succeeds.
|
||||||
|
|
||||||
|
## Build From CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm ios:build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd apps/ios
|
cd apps/ios
|
||||||
xcodegen generate
|
xcodegen generate
|
||||||
open OpenClaw.xcodeproj
|
xcodebuild test -project OpenClaw.xcodeproj -scheme OpenClaw -destination "platform=iOS Simulator,name=iPhone 17"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Shared packages
|
## Shared Code
|
||||||
- `../shared/OpenClawKit` — shared types/constants used by iOS (and later macOS bridge + gateway routing).
|
|
||||||
|
|
||||||
## fastlane
|
- `apps/shared/OpenClawKit` contains the shared transport/types used by the iOS app.
|
||||||
```bash
|
|
||||||
brew install fastlane
|
|
||||||
|
|
||||||
cd apps/ios
|
|
||||||
fastlane lanes
|
|
||||||
```
|
|
||||||
|
|
||||||
See `apps/ios/fastlane/SETUP.md` for App Store Connect auth + upload lanes.
|
|
||||||
|
|||||||
167
apps/ios/Sources/Calendar/CalendarService.swift
Normal file
167
apps/ios/Sources/Calendar/CalendarService.swift
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import EventKit
|
||||||
|
import Foundation
|
||||||
|
import OpenClawKit
|
||||||
|
|
||||||
|
final class CalendarService: CalendarServicing {
|
||||||
|
func events(params: OpenClawCalendarEventsParams) async throws -> OpenClawCalendarEventsPayload {
|
||||||
|
let store = EKEventStore()
|
||||||
|
let status = EKEventStore.authorizationStatus(for: .event)
|
||||||
|
let authorized = await Self.ensureAuthorization(store: store, status: status)
|
||||||
|
guard authorized else {
|
||||||
|
throw NSError(domain: "Calendar", code: 1, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "CALENDAR_PERMISSION_REQUIRED: grant Calendar permission",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
let (start, end) = Self.resolveRange(
|
||||||
|
startISO: params.startISO,
|
||||||
|
endISO: params.endISO)
|
||||||
|
let predicate = store.predicateForEvents(withStart: start, end: end, calendars: nil)
|
||||||
|
let events = store.events(matching: predicate)
|
||||||
|
let limit = max(1, min(params.limit ?? 50, 500))
|
||||||
|
let selected = Array(events.prefix(limit))
|
||||||
|
|
||||||
|
let formatter = ISO8601DateFormatter()
|
||||||
|
let payload = selected.map { event in
|
||||||
|
OpenClawCalendarEventPayload(
|
||||||
|
identifier: event.eventIdentifier ?? UUID().uuidString,
|
||||||
|
title: event.title ?? "(untitled)",
|
||||||
|
startISO: formatter.string(from: event.startDate),
|
||||||
|
endISO: formatter.string(from: event.endDate),
|
||||||
|
isAllDay: event.isAllDay,
|
||||||
|
location: event.location,
|
||||||
|
calendarTitle: event.calendar.title)
|
||||||
|
}
|
||||||
|
|
||||||
|
return OpenClawCalendarEventsPayload(events: payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func add(params: OpenClawCalendarAddParams) async throws -> OpenClawCalendarAddPayload {
|
||||||
|
let store = EKEventStore()
|
||||||
|
let status = EKEventStore.authorizationStatus(for: .event)
|
||||||
|
let authorized = await Self.ensureWriteAuthorization(store: store, status: status)
|
||||||
|
guard authorized else {
|
||||||
|
throw NSError(domain: "Calendar", code: 2, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "CALENDAR_PERMISSION_REQUIRED: grant Calendar permission",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !title.isEmpty else {
|
||||||
|
throw NSError(domain: "Calendar", code: 3, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "CALENDAR_INVALID: title required",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
let formatter = ISO8601DateFormatter()
|
||||||
|
guard let start = formatter.date(from: params.startISO) else {
|
||||||
|
throw NSError(domain: "Calendar", code: 4, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "CALENDAR_INVALID: startISO required",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
guard let end = formatter.date(from: params.endISO) else {
|
||||||
|
throw NSError(domain: "Calendar", code: 5, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "CALENDAR_INVALID: endISO required",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
let event = EKEvent(eventStore: store)
|
||||||
|
event.title = title
|
||||||
|
event.startDate = start
|
||||||
|
event.endDate = end
|
||||||
|
event.isAllDay = params.isAllDay ?? false
|
||||||
|
if let location = params.location?.trimmingCharacters(in: .whitespacesAndNewlines), !location.isEmpty {
|
||||||
|
event.location = location
|
||||||
|
}
|
||||||
|
if let notes = params.notes?.trimmingCharacters(in: .whitespacesAndNewlines), !notes.isEmpty {
|
||||||
|
event.notes = notes
|
||||||
|
}
|
||||||
|
event.calendar = try Self.resolveCalendar(
|
||||||
|
store: store,
|
||||||
|
calendarId: params.calendarId,
|
||||||
|
calendarTitle: params.calendarTitle)
|
||||||
|
|
||||||
|
try store.save(event, span: .thisEvent)
|
||||||
|
|
||||||
|
let payload = OpenClawCalendarEventPayload(
|
||||||
|
identifier: event.eventIdentifier ?? UUID().uuidString,
|
||||||
|
title: event.title ?? title,
|
||||||
|
startISO: formatter.string(from: event.startDate),
|
||||||
|
endISO: formatter.string(from: event.endDate),
|
||||||
|
isAllDay: event.isAllDay,
|
||||||
|
location: event.location,
|
||||||
|
calendarTitle: event.calendar.title)
|
||||||
|
|
||||||
|
return OpenClawCalendarAddPayload(event: payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func ensureAuthorization(store: EKEventStore, status: EKAuthorizationStatus) async -> Bool {
|
||||||
|
switch status {
|
||||||
|
case .authorized:
|
||||||
|
return true
|
||||||
|
case .notDetermined:
|
||||||
|
// Don’t prompt during node.invoke; prompts block the invoke and lead to timeouts.
|
||||||
|
return false
|
||||||
|
case .restricted, .denied:
|
||||||
|
return false
|
||||||
|
case .fullAccess:
|
||||||
|
return true
|
||||||
|
case .writeOnly:
|
||||||
|
return false
|
||||||
|
@unknown default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func ensureWriteAuthorization(store: EKEventStore, status: EKAuthorizationStatus) async -> Bool {
|
||||||
|
switch status {
|
||||||
|
case .authorized, .fullAccess, .writeOnly:
|
||||||
|
return true
|
||||||
|
case .notDetermined:
|
||||||
|
// Don’t prompt during node.invoke; prompts block the invoke and lead to timeouts.
|
||||||
|
return false
|
||||||
|
case .restricted, .denied:
|
||||||
|
return false
|
||||||
|
@unknown default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func resolveCalendar(
|
||||||
|
store: EKEventStore,
|
||||||
|
calendarId: String?,
|
||||||
|
calendarTitle: String?) throws -> EKCalendar
|
||||||
|
{
|
||||||
|
if let id = calendarId?.trimmingCharacters(in: .whitespacesAndNewlines), !id.isEmpty,
|
||||||
|
let calendar = store.calendar(withIdentifier: id)
|
||||||
|
{
|
||||||
|
return calendar
|
||||||
|
}
|
||||||
|
|
||||||
|
if let title = calendarTitle?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty {
|
||||||
|
if let calendar = store.calendars(for: .event).first(where: {
|
||||||
|
$0.title.compare(title, options: [.caseInsensitive, .diacriticInsensitive]) == .orderedSame
|
||||||
|
}) {
|
||||||
|
return calendar
|
||||||
|
}
|
||||||
|
throw NSError(domain: "Calendar", code: 6, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "CALENDAR_NOT_FOUND: no calendar named \(title)",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
if let fallback = store.defaultCalendarForNewEvents {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
throw NSError(domain: "Calendar", code: 7, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "CALENDAR_NOT_FOUND: no default calendar",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func resolveRange(startISO: String?, endISO: String?) -> (Date, Date) {
|
||||||
|
let formatter = ISO8601DateFormatter()
|
||||||
|
let start = startISO.flatMap { formatter.date(from: $0) } ?? Date()
|
||||||
|
let end = endISO.flatMap { formatter.date(from: $0) } ?? start.addingTimeInterval(7 * 24 * 3600)
|
||||||
|
return (start, end)
|
||||||
|
}
|
||||||
|
}
|
||||||
25
apps/ios/Sources/Capabilities/NodeCapabilityRouter.swift
Normal file
25
apps/ios/Sources/Capabilities/NodeCapabilityRouter.swift
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import Foundation
|
||||||
|
import OpenClawKit
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class NodeCapabilityRouter {
|
||||||
|
enum RouterError: Error {
|
||||||
|
case unknownCommand
|
||||||
|
case handlerUnavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
typealias Handler = (BridgeInvokeRequest) async throws -> BridgeInvokeResponse
|
||||||
|
|
||||||
|
private let handlers: [String: Handler]
|
||||||
|
|
||||||
|
init(handlers: [String: Handler]) {
|
||||||
|
self.handlers = handlers
|
||||||
|
}
|
||||||
|
|
||||||
|
func handle(_ request: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||||
|
guard let handler = handlers[request.command] else {
|
||||||
|
throw RouterError.unknownCommand
|
||||||
|
}
|
||||||
|
return try await handler(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,14 +6,16 @@ struct ChatSheet: View {
|
|||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@State private var viewModel: OpenClawChatViewModel
|
@State private var viewModel: OpenClawChatViewModel
|
||||||
private let userAccent: Color?
|
private let userAccent: Color?
|
||||||
|
private let agentName: String?
|
||||||
|
|
||||||
init(gateway: GatewayNodeSession, sessionKey: String, userAccent: Color? = nil) {
|
init(gateway: GatewayNodeSession, sessionKey: String, agentName: String? = nil, userAccent: Color? = nil) {
|
||||||
let transport = IOSGatewayChatTransport(gateway: gateway)
|
let transport = IOSGatewayChatTransport(gateway: gateway)
|
||||||
self._viewModel = State(
|
self._viewModel = State(
|
||||||
initialValue: OpenClawChatViewModel(
|
initialValue: OpenClawChatViewModel(
|
||||||
sessionKey: sessionKey,
|
sessionKey: sessionKey,
|
||||||
transport: transport))
|
transport: transport))
|
||||||
self.userAccent = userAccent
|
self.userAccent = userAccent
|
||||||
|
self.agentName = agentName
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -22,7 +24,7 @@ struct ChatSheet: View {
|
|||||||
viewModel: self.viewModel,
|
viewModel: self.viewModel,
|
||||||
showsSessionSwitcher: true,
|
showsSessionSwitcher: true,
|
||||||
userAccent: self.userAccent)
|
userAccent: self.userAccent)
|
||||||
.navigationTitle("Chat")
|
.navigationTitle(self.chatTitle)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
@@ -36,4 +38,10 @@ struct ChatSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var chatTitle: String {
|
||||||
|
let trimmed = (self.agentName ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if trimmed.isEmpty { return "Chat" }
|
||||||
|
return "Chat (\(trimmed))"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
212
apps/ios/Sources/Contacts/ContactsService.swift
Normal file
212
apps/ios/Sources/Contacts/ContactsService.swift
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
import Contacts
|
||||||
|
import Foundation
|
||||||
|
import OpenClawKit
|
||||||
|
|
||||||
|
final class ContactsService: ContactsServicing {
|
||||||
|
private static var payloadKeys: [CNKeyDescriptor] {
|
||||||
|
[
|
||||||
|
CNContactIdentifierKey as CNKeyDescriptor,
|
||||||
|
CNContactGivenNameKey as CNKeyDescriptor,
|
||||||
|
CNContactFamilyNameKey as CNKeyDescriptor,
|
||||||
|
CNContactOrganizationNameKey as CNKeyDescriptor,
|
||||||
|
CNContactPhoneNumbersKey as CNKeyDescriptor,
|
||||||
|
CNContactEmailAddressesKey as CNKeyDescriptor,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
func search(params: OpenClawContactsSearchParams) async throws -> OpenClawContactsSearchPayload {
|
||||||
|
let store = CNContactStore()
|
||||||
|
let status = CNContactStore.authorizationStatus(for: .contacts)
|
||||||
|
let authorized = await Self.ensureAuthorization(store: store, status: status)
|
||||||
|
guard authorized else {
|
||||||
|
throw NSError(domain: "Contacts", code: 1, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "CONTACTS_PERMISSION_REQUIRED: grant Contacts permission",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
let limit = max(1, min(params.limit ?? 25, 200))
|
||||||
|
|
||||||
|
var contacts: [CNContact] = []
|
||||||
|
if let query = params.query?.trimmingCharacters(in: .whitespacesAndNewlines), !query.isEmpty {
|
||||||
|
let predicate = CNContact.predicateForContacts(matchingName: query)
|
||||||
|
contacts = try store.unifiedContacts(matching: predicate, keysToFetch: Self.payloadKeys)
|
||||||
|
} else {
|
||||||
|
let request = CNContactFetchRequest(keysToFetch: Self.payloadKeys)
|
||||||
|
try store.enumerateContacts(with: request) { contact, stop in
|
||||||
|
contacts.append(contact)
|
||||||
|
if contacts.count >= limit {
|
||||||
|
stop.pointee = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let sliced = Array(contacts.prefix(limit))
|
||||||
|
let payload = sliced.map { Self.payload(from: $0) }
|
||||||
|
|
||||||
|
return OpenClawContactsSearchPayload(contacts: payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func add(params: OpenClawContactsAddParams) async throws -> OpenClawContactsAddPayload {
|
||||||
|
let store = CNContactStore()
|
||||||
|
let status = CNContactStore.authorizationStatus(for: .contacts)
|
||||||
|
let authorized = await Self.ensureAuthorization(store: store, status: status)
|
||||||
|
guard authorized else {
|
||||||
|
throw NSError(domain: "Contacts", code: 1, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "CONTACTS_PERMISSION_REQUIRED: grant Contacts permission",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
let givenName = params.givenName?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let familyName = params.familyName?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let organizationName = params.organizationName?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let displayName = params.displayName?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let phoneNumbers = Self.normalizeStrings(params.phoneNumbers)
|
||||||
|
let emails = Self.normalizeStrings(params.emails, lowercased: true)
|
||||||
|
|
||||||
|
let hasName = !(givenName ?? "").isEmpty || !(familyName ?? "").isEmpty || !(displayName ?? "").isEmpty
|
||||||
|
let hasOrg = !(organizationName ?? "").isEmpty
|
||||||
|
let hasDetails = !phoneNumbers.isEmpty || !emails.isEmpty
|
||||||
|
guard hasName || hasOrg || hasDetails else {
|
||||||
|
throw NSError(domain: "Contacts", code: 2, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "CONTACTS_INVALID: include a name, organization, phone, or email",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
if !phoneNumbers.isEmpty || !emails.isEmpty {
|
||||||
|
if let existing = try Self.findExistingContact(
|
||||||
|
store: store,
|
||||||
|
phoneNumbers: phoneNumbers,
|
||||||
|
emails: emails)
|
||||||
|
{
|
||||||
|
return OpenClawContactsAddPayload(contact: Self.payload(from: existing))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let contact = CNMutableContact()
|
||||||
|
contact.givenName = givenName ?? ""
|
||||||
|
contact.familyName = familyName ?? ""
|
||||||
|
contact.organizationName = organizationName ?? ""
|
||||||
|
if contact.givenName.isEmpty && contact.familyName.isEmpty, let displayName {
|
||||||
|
contact.givenName = displayName
|
||||||
|
}
|
||||||
|
contact.phoneNumbers = phoneNumbers.map {
|
||||||
|
CNLabeledValue(label: CNLabelPhoneNumberMobile, value: CNPhoneNumber(stringValue: $0))
|
||||||
|
}
|
||||||
|
contact.emailAddresses = emails.map {
|
||||||
|
CNLabeledValue(label: CNLabelHome, value: $0 as NSString)
|
||||||
|
}
|
||||||
|
|
||||||
|
let save = CNSaveRequest()
|
||||||
|
save.add(contact, toContainerWithIdentifier: nil)
|
||||||
|
try store.execute(save)
|
||||||
|
|
||||||
|
let persisted: CNContact
|
||||||
|
if !contact.identifier.isEmpty {
|
||||||
|
persisted = try store.unifiedContact(
|
||||||
|
withIdentifier: contact.identifier,
|
||||||
|
keysToFetch: Self.payloadKeys)
|
||||||
|
} else {
|
||||||
|
persisted = contact
|
||||||
|
}
|
||||||
|
|
||||||
|
return OpenClawContactsAddPayload(contact: Self.payload(from: persisted))
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func ensureAuthorization(store: CNContactStore, status: CNAuthorizationStatus) async -> Bool {
|
||||||
|
switch status {
|
||||||
|
case .authorized, .limited:
|
||||||
|
return true
|
||||||
|
case .notDetermined:
|
||||||
|
// Don’t prompt during node.invoke; the caller should instruct the user to grant permission.
|
||||||
|
// Prompts block the invoke and lead to timeouts in headless flows.
|
||||||
|
return false
|
||||||
|
case .restricted, .denied:
|
||||||
|
return false
|
||||||
|
@unknown default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func normalizeStrings(_ values: [String]?, lowercased: Bool = false) -> [String] {
|
||||||
|
(values ?? [])
|
||||||
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
.map { lowercased ? $0.lowercased() : $0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func findExistingContact(
|
||||||
|
store: CNContactStore,
|
||||||
|
phoneNumbers: [String],
|
||||||
|
emails: [String]) throws -> CNContact?
|
||||||
|
{
|
||||||
|
if phoneNumbers.isEmpty && emails.isEmpty {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var matches: [CNContact] = []
|
||||||
|
|
||||||
|
for phone in phoneNumbers {
|
||||||
|
let predicate = CNContact.predicateForContacts(matching: CNPhoneNumber(stringValue: phone))
|
||||||
|
let contacts = try store.unifiedContacts(matching: predicate, keysToFetch: Self.payloadKeys)
|
||||||
|
matches.append(contentsOf: contacts)
|
||||||
|
}
|
||||||
|
|
||||||
|
for email in emails {
|
||||||
|
let predicate = CNContact.predicateForContacts(matchingEmailAddress: email)
|
||||||
|
let contacts = try store.unifiedContacts(matching: predicate, keysToFetch: Self.payloadKeys)
|
||||||
|
matches.append(contentsOf: contacts)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Self.matchContacts(contacts: matches, phoneNumbers: phoneNumbers, emails: emails)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func matchContacts(
|
||||||
|
contacts: [CNContact],
|
||||||
|
phoneNumbers: [String],
|
||||||
|
emails: [String]) -> CNContact?
|
||||||
|
{
|
||||||
|
let normalizedPhones = Set(phoneNumbers.map { normalizePhone($0) }.filter { !$0.isEmpty })
|
||||||
|
let normalizedEmails = Set(emails.map { $0.lowercased() }.filter { !$0.isEmpty })
|
||||||
|
var seen = Set<String>()
|
||||||
|
|
||||||
|
for contact in contacts {
|
||||||
|
guard seen.insert(contact.identifier).inserted else { continue }
|
||||||
|
let contactPhones = Set(contact.phoneNumbers.map { normalizePhone($0.value.stringValue) })
|
||||||
|
let contactEmails = Set(contact.emailAddresses.map { String($0.value).lowercased() })
|
||||||
|
|
||||||
|
if !normalizedPhones.isEmpty, !contactPhones.isDisjoint(with: normalizedPhones) {
|
||||||
|
return contact
|
||||||
|
}
|
||||||
|
if !normalizedEmails.isEmpty, !contactEmails.isDisjoint(with: normalizedEmails) {
|
||||||
|
return contact
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func normalizePhone(_ phone: String) -> String {
|
||||||
|
let trimmed = phone.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let digits = trimmed.unicodeScalars.filter { CharacterSet.decimalDigits.contains($0) }
|
||||||
|
let normalized = String(String.UnicodeScalarView(digits))
|
||||||
|
return normalized.isEmpty ? trimmed : normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func payload(from contact: CNContact) -> OpenClawContactPayload {
|
||||||
|
OpenClawContactPayload(
|
||||||
|
identifier: contact.identifier,
|
||||||
|
displayName: CNContactFormatter.string(from: contact, style: .fullName)
|
||||||
|
?? "\(contact.givenName) \(contact.familyName)".trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
givenName: contact.givenName,
|
||||||
|
familyName: contact.familyName,
|
||||||
|
organizationName: contact.organizationName,
|
||||||
|
phoneNumbers: contact.phoneNumbers.map { $0.value.stringValue },
|
||||||
|
emails: contact.emailAddresses.map { String($0.value) })
|
||||||
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
static func _test_matches(contact: CNContact, phoneNumbers: [String], emails: [String]) -> Bool {
|
||||||
|
matchContacts(contacts: [contact], phoneNumbers: phoneNumbers, emails: emails) != nil
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
87
apps/ios/Sources/Device/DeviceStatusService.swift
Normal file
87
apps/ios/Sources/Device/DeviceStatusService.swift
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import Foundation
|
||||||
|
import OpenClawKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
final class DeviceStatusService: DeviceStatusServicing {
|
||||||
|
private let networkStatus: NetworkStatusService
|
||||||
|
|
||||||
|
init(networkStatus: NetworkStatusService = NetworkStatusService()) {
|
||||||
|
self.networkStatus = networkStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
func status() async throws -> OpenClawDeviceStatusPayload {
|
||||||
|
let battery = self.batteryStatus()
|
||||||
|
let thermal = self.thermalStatus()
|
||||||
|
let storage = self.storageStatus()
|
||||||
|
let network = await self.networkStatus.currentStatus()
|
||||||
|
let uptime = ProcessInfo.processInfo.systemUptime
|
||||||
|
|
||||||
|
return OpenClawDeviceStatusPayload(
|
||||||
|
battery: battery,
|
||||||
|
thermal: thermal,
|
||||||
|
storage: storage,
|
||||||
|
network: network,
|
||||||
|
uptimeSeconds: uptime)
|
||||||
|
}
|
||||||
|
|
||||||
|
func info() -> OpenClawDeviceInfoPayload {
|
||||||
|
let device = UIDevice.current
|
||||||
|
let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"
|
||||||
|
let appBuild = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "0"
|
||||||
|
let locale = Locale.preferredLanguages.first ?? Locale.current.identifier
|
||||||
|
return OpenClawDeviceInfoPayload(
|
||||||
|
deviceName: device.name,
|
||||||
|
modelIdentifier: Self.modelIdentifier(),
|
||||||
|
systemName: device.systemName,
|
||||||
|
systemVersion: device.systemVersion,
|
||||||
|
appVersion: appVersion,
|
||||||
|
appBuild: appBuild,
|
||||||
|
locale: locale)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func batteryStatus() -> OpenClawBatteryStatusPayload {
|
||||||
|
let device = UIDevice.current
|
||||||
|
device.isBatteryMonitoringEnabled = true
|
||||||
|
let level = device.batteryLevel >= 0 ? Double(device.batteryLevel) : nil
|
||||||
|
let state: OpenClawBatteryState = switch device.batteryState {
|
||||||
|
case .charging: .charging
|
||||||
|
case .full: .full
|
||||||
|
case .unplugged: .unplugged
|
||||||
|
case .unknown: .unknown
|
||||||
|
@unknown default: .unknown
|
||||||
|
}
|
||||||
|
return OpenClawBatteryStatusPayload(
|
||||||
|
level: level,
|
||||||
|
state: state,
|
||||||
|
lowPowerModeEnabled: ProcessInfo.processInfo.isLowPowerModeEnabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func thermalStatus() -> OpenClawThermalStatusPayload {
|
||||||
|
let state: OpenClawThermalState = switch ProcessInfo.processInfo.thermalState {
|
||||||
|
case .nominal: .nominal
|
||||||
|
case .fair: .fair
|
||||||
|
case .serious: .serious
|
||||||
|
case .critical: .critical
|
||||||
|
@unknown default: .nominal
|
||||||
|
}
|
||||||
|
return OpenClawThermalStatusPayload(state: state)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func storageStatus() -> OpenClawStorageStatusPayload {
|
||||||
|
let attrs = (try? FileManager.default.attributesOfFileSystem(forPath: NSHomeDirectory())) ?? [:]
|
||||||
|
let total = (attrs[.systemSize] as? NSNumber)?.int64Value ?? 0
|
||||||
|
let free = (attrs[.systemFreeSize] as? NSNumber)?.int64Value ?? 0
|
||||||
|
let used = max(0, total - free)
|
||||||
|
return OpenClawStorageStatusPayload(totalBytes: total, freeBytes: free, usedBytes: used)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func modelIdentifier() -> String {
|
||||||
|
var systemInfo = utsname()
|
||||||
|
uname(&systemInfo)
|
||||||
|
let machine = withUnsafeBytes(of: &systemInfo.machine) { ptr in
|
||||||
|
String(bytes: ptr.prefix { $0 != 0 }, encoding: .utf8)
|
||||||
|
}
|
||||||
|
let trimmed = machine?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
return trimmed.isEmpty ? "unknown" : trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
69
apps/ios/Sources/Device/NetworkStatusService.swift
Normal file
69
apps/ios/Sources/Device/NetworkStatusService.swift
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import Foundation
|
||||||
|
import Network
|
||||||
|
import OpenClawKit
|
||||||
|
|
||||||
|
final class NetworkStatusService: @unchecked Sendable {
|
||||||
|
func currentStatus(timeoutMs: Int = 1500) async -> OpenClawNetworkStatusPayload {
|
||||||
|
await withCheckedContinuation { cont in
|
||||||
|
let monitor = NWPathMonitor()
|
||||||
|
let queue = DispatchQueue(label: "bot.molt.ios.network-status")
|
||||||
|
let state = NetworkStatusState()
|
||||||
|
|
||||||
|
monitor.pathUpdateHandler = { path in
|
||||||
|
guard state.markCompleted() else { return }
|
||||||
|
monitor.cancel()
|
||||||
|
cont.resume(returning: Self.payload(from: path))
|
||||||
|
}
|
||||||
|
|
||||||
|
monitor.start(queue: queue)
|
||||||
|
|
||||||
|
queue.asyncAfter(deadline: .now() + .milliseconds(timeoutMs)) {
|
||||||
|
guard state.markCompleted() else { return }
|
||||||
|
monitor.cancel()
|
||||||
|
cont.resume(returning: Self.fallbackPayload())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func payload(from path: NWPath) -> OpenClawNetworkStatusPayload {
|
||||||
|
let status: OpenClawNetworkPathStatus = switch path.status {
|
||||||
|
case .satisfied: .satisfied
|
||||||
|
case .requiresConnection: .requiresConnection
|
||||||
|
case .unsatisfied: .unsatisfied
|
||||||
|
@unknown default: .unsatisfied
|
||||||
|
}
|
||||||
|
|
||||||
|
var interfaces: [OpenClawNetworkInterfaceType] = []
|
||||||
|
if path.usesInterfaceType(.wifi) { interfaces.append(.wifi) }
|
||||||
|
if path.usesInterfaceType(.cellular) { interfaces.append(.cellular) }
|
||||||
|
if path.usesInterfaceType(.wiredEthernet) { interfaces.append(.wired) }
|
||||||
|
if interfaces.isEmpty { interfaces.append(.other) }
|
||||||
|
|
||||||
|
return OpenClawNetworkStatusPayload(
|
||||||
|
status: status,
|
||||||
|
isExpensive: path.isExpensive,
|
||||||
|
isConstrained: path.isConstrained,
|
||||||
|
interfaces: interfaces)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func fallbackPayload() -> OpenClawNetworkStatusPayload {
|
||||||
|
OpenClawNetworkStatusPayload(
|
||||||
|
status: .unsatisfied,
|
||||||
|
isExpensive: false,
|
||||||
|
isConstrained: false,
|
||||||
|
interfaces: [.other])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class NetworkStatusState: @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var completed = false
|
||||||
|
|
||||||
|
func markCompleted() -> Bool {
|
||||||
|
self.lock.lock()
|
||||||
|
defer { self.lock.unlock() }
|
||||||
|
if self.completed { return false }
|
||||||
|
self.completed = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
48
apps/ios/Sources/Device/NodeDisplayName.swift
Normal file
48
apps/ios/Sources/Device/NodeDisplayName.swift
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import Foundation
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
enum NodeDisplayName {
|
||||||
|
private static let genericNames: Set<String> = ["iOS Node", "iPhone Node", "iPad Node"]
|
||||||
|
|
||||||
|
static func isGeneric(_ name: String) -> Bool {
|
||||||
|
Self.genericNames.contains(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func defaultValue(for interfaceIdiom: UIUserInterfaceIdiom) -> String {
|
||||||
|
switch interfaceIdiom {
|
||||||
|
case .phone:
|
||||||
|
return "iPhone Node"
|
||||||
|
case .pad:
|
||||||
|
return "iPad Node"
|
||||||
|
default:
|
||||||
|
return "iOS Node"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func resolve(
|
||||||
|
existing: String?,
|
||||||
|
deviceName: String,
|
||||||
|
interfaceIdiom: UIUserInterfaceIdiom
|
||||||
|
) -> String {
|
||||||
|
let trimmedExisting = existing?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if !trimmedExisting.isEmpty, !Self.isGeneric(trimmedExisting) {
|
||||||
|
return trimmedExisting
|
||||||
|
}
|
||||||
|
|
||||||
|
let trimmedDevice = deviceName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if let normalized = Self.normalizedDeviceName(trimmedDevice) {
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
return Self.defaultValue(for: interfaceIdiom)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func normalizedDeviceName(_ deviceName: String) -> String? {
|
||||||
|
guard !deviceName.isEmpty else { return nil }
|
||||||
|
let lower = deviceName.lowercased()
|
||||||
|
if lower.contains("iphone") || lower.contains("ipad") || lower.contains("ios") {
|
||||||
|
return deviceName
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
27
apps/ios/Sources/Gateway/GatewayConnectConfig.swift
Normal file
27
apps/ios/Sources/Gateway/GatewayConnectConfig.swift
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import Foundation
|
||||||
|
import OpenClawKit
|
||||||
|
|
||||||
|
/// Single source of truth for "how we connect" to the current gateway.
|
||||||
|
///
|
||||||
|
/// The iOS app maintains two WebSocket sessions to the same gateway:
|
||||||
|
/// - a `role=node` session for device capabilities (`node.invoke.*`)
|
||||||
|
/// - a `role=operator` session for chat/talk/config (`chat.*`, `talk.*`, etc.)
|
||||||
|
///
|
||||||
|
/// Both sessions should derive all connection inputs from this config so we
|
||||||
|
/// don't accidentally persist gateway-scoped state under different keys.
|
||||||
|
struct GatewayConnectConfig: Sendable {
|
||||||
|
let url: URL
|
||||||
|
let stableID: String
|
||||||
|
let tls: GatewayTLSParams?
|
||||||
|
let token: String?
|
||||||
|
let password: String?
|
||||||
|
let nodeOptions: GatewayConnectOptions
|
||||||
|
|
||||||
|
/// Stable, non-empty identifier used for gateway-scoped persistence keys.
|
||||||
|
/// If the caller doesn't provide a stableID, fall back to URL identity.
|
||||||
|
var effectiveStableID: String {
|
||||||
|
let trimmed = self.stableID.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if trimmed.isEmpty { return self.url.absoluteString }
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,15 @@
|
|||||||
import OpenClawKit
|
import AVFoundation
|
||||||
import Darwin
|
import Contacts
|
||||||
|
import CoreLocation
|
||||||
|
import CoreMotion
|
||||||
|
import EventKit
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import OpenClawKit
|
||||||
import Network
|
import Network
|
||||||
import Observation
|
import Observation
|
||||||
|
import Photos
|
||||||
|
import ReplayKit
|
||||||
|
import Speech
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
@@ -42,8 +49,10 @@ final class GatewayConnectionController {
|
|||||||
self.discovery.stop()
|
self.discovery.stop()
|
||||||
case .active, .inactive:
|
case .active, .inactive:
|
||||||
self.discovery.start()
|
self.discovery.start()
|
||||||
|
self.attemptAutoReconnectIfNeeded()
|
||||||
@unknown default:
|
@unknown default:
|
||||||
self.discovery.start()
|
self.discovery.start()
|
||||||
|
self.attemptAutoReconnectIfNeeded()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,6 +69,11 @@ final class GatewayConnectionController {
|
|||||||
port: port,
|
port: port,
|
||||||
useTLS: tlsParams?.required == true)
|
useTLS: tlsParams?.required == true)
|
||||||
else { return }
|
else { return }
|
||||||
|
GatewaySettingsStore.saveLastGatewayConnection(
|
||||||
|
host: host,
|
||||||
|
port: port,
|
||||||
|
useTLS: tlsParams?.required == true,
|
||||||
|
stableID: gateway.stableID)
|
||||||
self.didAutoConnect = true
|
self.didAutoConnect = true
|
||||||
self.startAutoConnect(
|
self.startAutoConnect(
|
||||||
url: url,
|
url: url,
|
||||||
@@ -74,13 +88,24 @@ final class GatewayConnectionController {
|
|||||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
|
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
|
||||||
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
|
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
|
||||||
let stableID = self.manualStableID(host: host, port: port)
|
let resolvedUseTLS = useTLS
|
||||||
let tlsParams = self.resolveManualTLSParams(stableID: stableID, tlsEnabled: useTLS)
|
guard let resolvedPort = self.resolveManualPort(host: host, port: port, useTLS: resolvedUseTLS)
|
||||||
|
else { return }
|
||||||
|
let stableID = self.manualStableID(host: host, port: resolvedPort)
|
||||||
|
let tlsParams = self.resolveManualTLSParams(
|
||||||
|
stableID: stableID,
|
||||||
|
tlsEnabled: resolvedUseTLS,
|
||||||
|
allowTOFUReset: self.shouldForceTLS(host: host))
|
||||||
guard let url = self.buildGatewayURL(
|
guard let url = self.buildGatewayURL(
|
||||||
host: host,
|
host: host,
|
||||||
port: port,
|
port: resolvedPort,
|
||||||
useTLS: tlsParams?.required == true)
|
useTLS: tlsParams?.required == true)
|
||||||
else { return }
|
else { return }
|
||||||
|
GatewaySettingsStore.saveLastGatewayConnection(
|
||||||
|
host: host,
|
||||||
|
port: resolvedPort,
|
||||||
|
useTLS: tlsParams?.required == true,
|
||||||
|
stableID: stableID)
|
||||||
self.didAutoConnect = true
|
self.didAutoConnect = true
|
||||||
self.startAutoConnect(
|
self.startAutoConnect(
|
||||||
url: url,
|
url: url,
|
||||||
@@ -90,6 +115,38 @@ final class GatewayConnectionController {
|
|||||||
password: password)
|
password: password)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func connectLastKnown() async {
|
||||||
|
guard let last = GatewaySettingsStore.loadLastGatewayConnection() else { return }
|
||||||
|
let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
|
||||||
|
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
|
||||||
|
let resolvedUseTLS = last.useTLS
|
||||||
|
let tlsParams = self.resolveManualTLSParams(
|
||||||
|
stableID: last.stableID,
|
||||||
|
tlsEnabled: resolvedUseTLS,
|
||||||
|
allowTOFUReset: self.shouldForceTLS(host: last.host))
|
||||||
|
guard let url = self.buildGatewayURL(
|
||||||
|
host: last.host,
|
||||||
|
port: last.port,
|
||||||
|
useTLS: tlsParams?.required == true)
|
||||||
|
else { return }
|
||||||
|
if resolvedUseTLS != last.useTLS {
|
||||||
|
GatewaySettingsStore.saveLastGatewayConnection(
|
||||||
|
host: last.host,
|
||||||
|
port: last.port,
|
||||||
|
useTLS: resolvedUseTLS,
|
||||||
|
stableID: last.stableID)
|
||||||
|
}
|
||||||
|
self.didAutoConnect = true
|
||||||
|
self.startAutoConnect(
|
||||||
|
url: url,
|
||||||
|
gatewayStableID: last.stableID,
|
||||||
|
tls: tlsParams,
|
||||||
|
token: token,
|
||||||
|
password: password)
|
||||||
|
}
|
||||||
|
|
||||||
private func updateFromDiscovery() {
|
private func updateFromDiscovery() {
|
||||||
let newGateways = self.discovery.gateways
|
let newGateways = self.discovery.gateways
|
||||||
self.gateways = newGateways
|
self.gateways = newGateways
|
||||||
@@ -119,6 +176,7 @@ final class GatewayConnectionController {
|
|||||||
guard appModel.gatewayServerName == nil else { return }
|
guard appModel.gatewayServerName == nil else { return }
|
||||||
|
|
||||||
let defaults = UserDefaults.standard
|
let defaults = UserDefaults.standard
|
||||||
|
guard defaults.bool(forKey: "gateway.autoconnect") else { return }
|
||||||
let manualEnabled = defaults.bool(forKey: "gateway.manual.enabled")
|
let manualEnabled = defaults.bool(forKey: "gateway.manual.enabled")
|
||||||
|
|
||||||
let instanceId = defaults.string(forKey: "node.instanceId")?
|
let instanceId = defaults.string(forKey: "node.instanceId")?
|
||||||
@@ -134,11 +192,19 @@ final class GatewayConnectionController {
|
|||||||
guard !manualHost.isEmpty else { return }
|
guard !manualHost.isEmpty else { return }
|
||||||
|
|
||||||
let manualPort = defaults.integer(forKey: "gateway.manual.port")
|
let manualPort = defaults.integer(forKey: "gateway.manual.port")
|
||||||
let resolvedPort = manualPort > 0 ? manualPort : 18789
|
|
||||||
let manualTLS = defaults.bool(forKey: "gateway.manual.tls")
|
let manualTLS = defaults.bool(forKey: "gateway.manual.tls")
|
||||||
|
let resolvedUseTLS = manualTLS || self.shouldForceTLS(host: manualHost)
|
||||||
|
guard let resolvedPort = self.resolveManualPort(
|
||||||
|
host: manualHost,
|
||||||
|
port: manualPort,
|
||||||
|
useTLS: resolvedUseTLS)
|
||||||
|
else { return }
|
||||||
|
|
||||||
let stableID = self.manualStableID(host: manualHost, port: resolvedPort)
|
let stableID = self.manualStableID(host: manualHost, port: resolvedPort)
|
||||||
let tlsParams = self.resolveManualTLSParams(stableID: stableID, tlsEnabled: manualTLS)
|
let tlsParams = self.resolveManualTLSParams(
|
||||||
|
stableID: stableID,
|
||||||
|
tlsEnabled: resolvedUseTLS,
|
||||||
|
allowTOFUReset: self.shouldForceTLS(host: manualHost))
|
||||||
|
|
||||||
guard let url = self.buildGatewayURL(
|
guard let url = self.buildGatewayURL(
|
||||||
host: manualHost,
|
host: manualHost,
|
||||||
@@ -156,30 +222,80 @@ final class GatewayConnectionController {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let lastKnown = GatewaySettingsStore.loadLastGatewayConnection() {
|
||||||
|
let resolvedUseTLS = lastKnown.useTLS || self.shouldForceTLS(host: lastKnown.host)
|
||||||
|
let tlsParams = self.resolveManualTLSParams(
|
||||||
|
stableID: lastKnown.stableID,
|
||||||
|
tlsEnabled: resolvedUseTLS,
|
||||||
|
allowTOFUReset: self.shouldForceTLS(host: lastKnown.host))
|
||||||
|
guard let url = self.buildGatewayURL(
|
||||||
|
host: lastKnown.host,
|
||||||
|
port: lastKnown.port,
|
||||||
|
useTLS: tlsParams?.required == true)
|
||||||
|
else { return }
|
||||||
|
|
||||||
|
self.didAutoConnect = true
|
||||||
|
self.startAutoConnect(
|
||||||
|
url: url,
|
||||||
|
gatewayStableID: lastKnown.stableID,
|
||||||
|
tls: tlsParams,
|
||||||
|
token: token,
|
||||||
|
password: password)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
let preferredStableID = defaults.string(forKey: "gateway.preferredStableID")?
|
let preferredStableID = defaults.string(forKey: "gateway.preferredStableID")?
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
let lastDiscoveredStableID = defaults.string(forKey: "gateway.lastDiscoveredStableID")?
|
let lastDiscoveredStableID = defaults.string(forKey: "gateway.lastDiscoveredStableID")?
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
|
||||||
let candidates = [preferredStableID, lastDiscoveredStableID].filter { !$0.isEmpty }
|
let candidates = [preferredStableID, lastDiscoveredStableID].filter { !$0.isEmpty }
|
||||||
guard let targetStableID = candidates.first(where: { id in
|
if let targetStableID = candidates.first(where: { id in
|
||||||
self.gateways.contains(where: { $0.stableID == id })
|
self.gateways.contains(where: { $0.stableID == id })
|
||||||
}) else { return }
|
}) {
|
||||||
|
guard let target = self.gateways.first(where: { $0.stableID == targetStableID }) else { return }
|
||||||
|
guard let host = self.resolveGatewayHost(target) else { return }
|
||||||
|
let port = target.gatewayPort ?? 18789
|
||||||
|
let tlsParams = self.resolveDiscoveredTLSParams(gateway: target)
|
||||||
|
guard let url = self.buildGatewayURL(host: host, port: port, useTLS: tlsParams?.required == true)
|
||||||
|
else { return }
|
||||||
|
|
||||||
guard let target = self.gateways.first(where: { $0.stableID == targetStableID }) else { return }
|
self.didAutoConnect = true
|
||||||
guard let host = self.resolveGatewayHost(target) else { return }
|
self.startAutoConnect(
|
||||||
let port = target.gatewayPort ?? 18789
|
url: url,
|
||||||
let tlsParams = self.resolveDiscoveredTLSParams(gateway: target)
|
gatewayStableID: target.stableID,
|
||||||
guard let url = self.buildGatewayURL(host: host, port: port, useTLS: tlsParams?.required == true)
|
tls: tlsParams,
|
||||||
else { return }
|
token: token,
|
||||||
|
password: password)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
self.didAutoConnect = true
|
if self.gateways.count == 1, let gateway = self.gateways.first {
|
||||||
self.startAutoConnect(
|
guard let host = self.resolveGatewayHost(gateway) else { return }
|
||||||
url: url,
|
let port = gateway.gatewayPort ?? 18789
|
||||||
gatewayStableID: target.stableID,
|
let tlsParams = self.resolveDiscoveredTLSParams(gateway: gateway)
|
||||||
tls: tlsParams,
|
guard let url = self.buildGatewayURL(host: host, port: port, useTLS: tlsParams?.required == true)
|
||||||
token: token,
|
else { return }
|
||||||
password: password)
|
|
||||||
|
self.didAutoConnect = true
|
||||||
|
self.startAutoConnect(
|
||||||
|
url: url,
|
||||||
|
gatewayStableID: gateway.stableID,
|
||||||
|
tls: tlsParams,
|
||||||
|
token: token,
|
||||||
|
password: password)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func attemptAutoReconnectIfNeeded() {
|
||||||
|
guard let appModel = self.appModel else { return }
|
||||||
|
guard appModel.gatewayAutoReconnectEnabled else { return }
|
||||||
|
// Avoid starting duplicate connect loops while a prior config is active.
|
||||||
|
guard appModel.activeGatewayConnectConfig == nil else { return }
|
||||||
|
guard UserDefaults.standard.bool(forKey: "gateway.autoconnect") else { return }
|
||||||
|
self.didAutoConnect = false
|
||||||
|
self.maybeAutoConnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateLastDiscoveredGateway(from gateways: [GatewayDiscoveryModel.DiscoveredGateway]) {
|
private func updateLastDiscoveredGateway(from gateways: [GatewayDiscoveryModel.DiscoveredGateway]) {
|
||||||
@@ -205,20 +321,21 @@ final class GatewayConnectionController {
|
|||||||
password: String?)
|
password: String?)
|
||||||
{
|
{
|
||||||
guard let appModel else { return }
|
guard let appModel else { return }
|
||||||
let connectOptions = self.makeConnectOptions()
|
let connectOptions = self.makeConnectOptions(stableID: gatewayStableID)
|
||||||
|
|
||||||
Task { [weak self] in
|
Task { [weak appModel] in
|
||||||
guard let self else { return }
|
guard let appModel else { return }
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
appModel.gatewayStatusText = "Connecting…"
|
appModel.gatewayStatusText = "Connecting…"
|
||||||
}
|
}
|
||||||
appModel.connectToGateway(
|
let cfg = GatewayConnectConfig(
|
||||||
url: url,
|
url: url,
|
||||||
gatewayStableID: gatewayStableID,
|
stableID: gatewayStableID,
|
||||||
tls: tls,
|
tls: tls,
|
||||||
token: token,
|
token: token,
|
||||||
password: password,
|
password: password,
|
||||||
connectOptions: connectOptions)
|
nodeOptions: connectOptions)
|
||||||
|
appModel.applyGatewayConnectConfig(cfg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,13 +354,17 @@ final class GatewayConnectionController {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resolveManualTLSParams(stableID: String, tlsEnabled: Bool) -> GatewayTLSParams? {
|
private func resolveManualTLSParams(
|
||||||
|
stableID: String,
|
||||||
|
tlsEnabled: Bool,
|
||||||
|
allowTOFUReset: Bool = false) -> GatewayTLSParams?
|
||||||
|
{
|
||||||
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
|
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
|
||||||
if tlsEnabled || stored != nil {
|
if tlsEnabled || stored != nil {
|
||||||
return GatewayTLSParams(
|
return GatewayTLSParams(
|
||||||
required: true,
|
required: true,
|
||||||
expectedFingerprint: stored,
|
expectedFingerprint: stored,
|
||||||
allowTOFU: stored == nil,
|
allowTOFU: stored == nil || allowTOFUReset,
|
||||||
storeKey: stableID)
|
storeKey: stableID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,12 +372,12 @@ final class GatewayConnectionController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func resolveGatewayHost(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
|
private func resolveGatewayHost(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
|
||||||
if let lanHost = gateway.lanHost?.trimmingCharacters(in: .whitespacesAndNewlines), !lanHost.isEmpty {
|
|
||||||
return lanHost
|
|
||||||
}
|
|
||||||
if let tailnet = gateway.tailnetDns?.trimmingCharacters(in: .whitespacesAndNewlines), !tailnet.isEmpty {
|
if let tailnet = gateway.tailnetDns?.trimmingCharacters(in: .whitespacesAndNewlines), !tailnet.isEmpty {
|
||||||
return tailnet
|
return tailnet
|
||||||
}
|
}
|
||||||
|
if let lanHost = gateway.lanHost?.trimmingCharacters(in: .whitespacesAndNewlines), !lanHost.isEmpty {
|
||||||
|
return lanHost
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,38 +390,69 @@ final class GatewayConnectionController {
|
|||||||
return components.url
|
return components.url
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func shouldForceTLS(host: String) -> Bool {
|
||||||
|
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
if trimmed.isEmpty { return false }
|
||||||
|
return trimmed.hasSuffix(".ts.net") || trimmed.hasSuffix(".ts.net.")
|
||||||
|
}
|
||||||
|
|
||||||
private func manualStableID(host: String, port: Int) -> String {
|
private func manualStableID(host: String, port: Int) -> String {
|
||||||
"manual|\(host.lowercased())|\(port)"
|
"manual|\(host.lowercased())|\(port)"
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makeConnectOptions() -> GatewayConnectOptions {
|
private func makeConnectOptions(stableID: String?) -> GatewayConnectOptions {
|
||||||
let defaults = UserDefaults.standard
|
let defaults = UserDefaults.standard
|
||||||
let displayName = self.resolvedDisplayName(defaults: defaults)
|
let displayName = self.resolvedDisplayName(defaults: defaults)
|
||||||
|
let resolvedClientId = self.resolvedClientId(defaults: defaults, stableID: stableID)
|
||||||
|
|
||||||
return GatewayConnectOptions(
|
return GatewayConnectOptions(
|
||||||
role: "node",
|
role: "node",
|
||||||
scopes: [],
|
scopes: [],
|
||||||
caps: self.currentCaps(),
|
caps: self.currentCaps(),
|
||||||
commands: self.currentCommands(),
|
commands: self.currentCommands(),
|
||||||
permissions: [:],
|
permissions: self.currentPermissions(),
|
||||||
clientId: "openclaw-ios",
|
clientId: resolvedClientId,
|
||||||
clientMode: "node",
|
clientMode: "node",
|
||||||
clientDisplayName: displayName)
|
clientDisplayName: displayName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func resolvedClientId(defaults: UserDefaults, stableID: String?) -> String {
|
||||||
|
if let stableID,
|
||||||
|
let override = GatewaySettingsStore.loadGatewayClientIdOverride(stableID: stableID) {
|
||||||
|
return override
|
||||||
|
}
|
||||||
|
let manualClientId = defaults.string(forKey: "gateway.manual.clientId")?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if manualClientId?.isEmpty == false {
|
||||||
|
return manualClientId!
|
||||||
|
}
|
||||||
|
return "openclaw-ios"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolveManualPort(host: String, port: Int, useTLS: Bool) -> Int? {
|
||||||
|
if port > 0 {
|
||||||
|
return port <= 65535 ? port : nil
|
||||||
|
}
|
||||||
|
let trimmedHost = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmedHost.isEmpty else { return nil }
|
||||||
|
if useTLS && self.shouldForceTLS(host: trimmedHost) {
|
||||||
|
return 443
|
||||||
|
}
|
||||||
|
return 18789
|
||||||
|
}
|
||||||
|
|
||||||
private func resolvedDisplayName(defaults: UserDefaults) -> String {
|
private func resolvedDisplayName(defaults: UserDefaults) -> String {
|
||||||
let key = "node.displayName"
|
let key = "node.displayName"
|
||||||
let existing = defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
let existingRaw = defaults.string(forKey: key)
|
||||||
if !existing.isEmpty, existing != "iOS Node" { return existing }
|
let resolved = NodeDisplayName.resolve(
|
||||||
|
existing: existingRaw,
|
||||||
let deviceName = UIDevice.current.name.trimmingCharacters(in: .whitespacesAndNewlines)
|
deviceName: UIDevice.current.name,
|
||||||
let candidate = deviceName.isEmpty ? "iOS Node" : deviceName
|
interfaceIdiom: UIDevice.current.userInterfaceIdiom)
|
||||||
|
let existing = existingRaw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
if existing.isEmpty || existing == "iOS Node" {
|
if existing.isEmpty || NodeDisplayName.isGeneric(existing) {
|
||||||
defaults.set(candidate, forKey: key)
|
defaults.set(resolved, forKey: key)
|
||||||
}
|
}
|
||||||
|
return resolved
|
||||||
return candidate
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func currentCaps() -> [String] {
|
private func currentCaps() -> [String] {
|
||||||
@@ -320,6 +472,15 @@ final class GatewayConnectionController {
|
|||||||
let locationMode = OpenClawLocationMode(rawValue: locationModeRaw) ?? .off
|
let locationMode = OpenClawLocationMode(rawValue: locationModeRaw) ?? .off
|
||||||
if locationMode != .off { caps.append(OpenClawCapability.location.rawValue) }
|
if locationMode != .off { caps.append(OpenClawCapability.location.rawValue) }
|
||||||
|
|
||||||
|
caps.append(OpenClawCapability.device.rawValue)
|
||||||
|
caps.append(OpenClawCapability.photos.rawValue)
|
||||||
|
caps.append(OpenClawCapability.contacts.rawValue)
|
||||||
|
caps.append(OpenClawCapability.calendar.rawValue)
|
||||||
|
caps.append(OpenClawCapability.reminders.rawValue)
|
||||||
|
if Self.motionAvailable() {
|
||||||
|
caps.append(OpenClawCapability.motion.rawValue)
|
||||||
|
}
|
||||||
|
|
||||||
return caps
|
return caps
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,10 +496,11 @@ final class GatewayConnectionController {
|
|||||||
OpenClawCanvasA2UICommand.reset.rawValue,
|
OpenClawCanvasA2UICommand.reset.rawValue,
|
||||||
OpenClawScreenCommand.record.rawValue,
|
OpenClawScreenCommand.record.rawValue,
|
||||||
OpenClawSystemCommand.notify.rawValue,
|
OpenClawSystemCommand.notify.rawValue,
|
||||||
OpenClawSystemCommand.which.rawValue,
|
OpenClawChatCommand.push.rawValue,
|
||||||
OpenClawSystemCommand.run.rawValue,
|
OpenClawTalkCommand.pttStart.rawValue,
|
||||||
OpenClawSystemCommand.execApprovalsGet.rawValue,
|
OpenClawTalkCommand.pttStop.rawValue,
|
||||||
OpenClawSystemCommand.execApprovalsSet.rawValue,
|
OpenClawTalkCommand.pttCancel.rawValue,
|
||||||
|
OpenClawTalkCommand.pttOnce.rawValue,
|
||||||
]
|
]
|
||||||
|
|
||||||
let caps = Set(self.currentCaps())
|
let caps = Set(self.currentCaps())
|
||||||
@@ -350,10 +512,76 @@ final class GatewayConnectionController {
|
|||||||
if caps.contains(OpenClawCapability.location.rawValue) {
|
if caps.contains(OpenClawCapability.location.rawValue) {
|
||||||
commands.append(OpenClawLocationCommand.get.rawValue)
|
commands.append(OpenClawLocationCommand.get.rawValue)
|
||||||
}
|
}
|
||||||
|
if caps.contains(OpenClawCapability.device.rawValue) {
|
||||||
|
commands.append(OpenClawDeviceCommand.status.rawValue)
|
||||||
|
commands.append(OpenClawDeviceCommand.info.rawValue)
|
||||||
|
}
|
||||||
|
if caps.contains(OpenClawCapability.photos.rawValue) {
|
||||||
|
commands.append(OpenClawPhotosCommand.latest.rawValue)
|
||||||
|
}
|
||||||
|
if caps.contains(OpenClawCapability.contacts.rawValue) {
|
||||||
|
commands.append(OpenClawContactsCommand.search.rawValue)
|
||||||
|
commands.append(OpenClawContactsCommand.add.rawValue)
|
||||||
|
}
|
||||||
|
if caps.contains(OpenClawCapability.calendar.rawValue) {
|
||||||
|
commands.append(OpenClawCalendarCommand.events.rawValue)
|
||||||
|
commands.append(OpenClawCalendarCommand.add.rawValue)
|
||||||
|
}
|
||||||
|
if caps.contains(OpenClawCapability.reminders.rawValue) {
|
||||||
|
commands.append(OpenClawRemindersCommand.list.rawValue)
|
||||||
|
commands.append(OpenClawRemindersCommand.add.rawValue)
|
||||||
|
}
|
||||||
|
if caps.contains(OpenClawCapability.motion.rawValue) {
|
||||||
|
commands.append(OpenClawMotionCommand.activity.rawValue)
|
||||||
|
commands.append(OpenClawMotionCommand.pedometer.rawValue)
|
||||||
|
}
|
||||||
|
|
||||||
return commands
|
return commands
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func currentPermissions() -> [String: Bool] {
|
||||||
|
var permissions: [String: Bool] = [:]
|
||||||
|
permissions["camera"] = AVCaptureDevice.authorizationStatus(for: .video) == .authorized
|
||||||
|
permissions["microphone"] = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
|
||||||
|
permissions["speechRecognition"] = SFSpeechRecognizer.authorizationStatus() == .authorized
|
||||||
|
permissions["location"] = Self.isLocationAuthorized(
|
||||||
|
status: CLLocationManager().authorizationStatus)
|
||||||
|
&& CLLocationManager.locationServicesEnabled()
|
||||||
|
permissions["screenRecording"] = RPScreenRecorder.shared().isAvailable
|
||||||
|
|
||||||
|
let photoStatus = PHPhotoLibrary.authorizationStatus(for: .readWrite)
|
||||||
|
permissions["photos"] = photoStatus == .authorized || photoStatus == .limited
|
||||||
|
let contactsStatus = CNContactStore.authorizationStatus(for: .contacts)
|
||||||
|
permissions["contacts"] = contactsStatus == .authorized || contactsStatus == .limited
|
||||||
|
|
||||||
|
let calendarStatus = EKEventStore.authorizationStatus(for: .event)
|
||||||
|
permissions["calendar"] =
|
||||||
|
calendarStatus == .authorized || calendarStatus == .fullAccess || calendarStatus == .writeOnly
|
||||||
|
let remindersStatus = EKEventStore.authorizationStatus(for: .reminder)
|
||||||
|
permissions["reminders"] =
|
||||||
|
remindersStatus == .authorized || remindersStatus == .fullAccess || remindersStatus == .writeOnly
|
||||||
|
|
||||||
|
let motionStatus = CMMotionActivityManager.authorizationStatus()
|
||||||
|
let pedometerStatus = CMPedometer.authorizationStatus()
|
||||||
|
permissions["motion"] =
|
||||||
|
motionStatus == .authorized || pedometerStatus == .authorized
|
||||||
|
|
||||||
|
return permissions
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func isLocationAuthorized(status: CLAuthorizationStatus) -> Bool {
|
||||||
|
switch status {
|
||||||
|
case .authorizedAlways, .authorizedWhenInUse, .authorized:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func motionAvailable() -> Bool {
|
||||||
|
CMMotionActivityManager.isActivityAvailable() || CMPedometer.isStepCountingAvailable()
|
||||||
|
}
|
||||||
|
|
||||||
private func platformString() -> String {
|
private func platformString() -> String {
|
||||||
let v = ProcessInfo.processInfo.operatingSystemVersion
|
let v = ProcessInfo.processInfo.operatingSystemVersion
|
||||||
let name = switch UIDevice.current.userInterfaceIdiom {
|
let name = switch UIDevice.current.userInterfaceIdiom {
|
||||||
@@ -407,6 +635,10 @@ extension GatewayConnectionController {
|
|||||||
self.currentCommands()
|
self.currentCommands()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func _test_currentPermissions() -> [String: Bool] {
|
||||||
|
self.currentPermissions()
|
||||||
|
}
|
||||||
|
|
||||||
func _test_platformString() -> String {
|
func _test_platformString() -> String {
|
||||||
self.platformString()
|
self.platformString()
|
||||||
}
|
}
|
||||||
|
|||||||
85
apps/ios/Sources/Gateway/GatewayHealthMonitor.swift
Normal file
85
apps/ios/Sources/Gateway/GatewayHealthMonitor.swift
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import Foundation
|
||||||
|
import OpenClawKit
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class GatewayHealthMonitor {
|
||||||
|
struct Config: Sendable {
|
||||||
|
var intervalSeconds: Double
|
||||||
|
var timeoutSeconds: Double
|
||||||
|
var maxFailures: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
private let config: Config
|
||||||
|
private let sleep: @Sendable (UInt64) async -> Void
|
||||||
|
private var task: Task<Void, Never>?
|
||||||
|
|
||||||
|
init(
|
||||||
|
config: Config = Config(intervalSeconds: 15, timeoutSeconds: 5, maxFailures: 3),
|
||||||
|
sleep: @escaping @Sendable (UInt64) async -> Void = { nanoseconds in
|
||||||
|
try? await Task.sleep(nanoseconds: nanoseconds)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
self.config = config
|
||||||
|
self.sleep = sleep
|
||||||
|
}
|
||||||
|
|
||||||
|
func start(
|
||||||
|
check: @escaping @Sendable () async throws -> Bool,
|
||||||
|
onFailure: @escaping @Sendable (_ failureCount: Int) async -> Void)
|
||||||
|
{
|
||||||
|
self.stop()
|
||||||
|
let config = self.config
|
||||||
|
let sleep = self.sleep
|
||||||
|
self.task = Task { @MainActor in
|
||||||
|
var failures = 0
|
||||||
|
while !Task.isCancelled {
|
||||||
|
let ok = await Self.runCheck(check: check, timeoutSeconds: config.timeoutSeconds)
|
||||||
|
if ok {
|
||||||
|
failures = 0
|
||||||
|
} else {
|
||||||
|
failures += 1
|
||||||
|
if failures >= max(1, config.maxFailures) {
|
||||||
|
await onFailure(failures)
|
||||||
|
failures = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if Task.isCancelled { break }
|
||||||
|
let interval = max(0.0, config.intervalSeconds)
|
||||||
|
let nanos = UInt64(interval * 1_000_000_000)
|
||||||
|
if nanos > 0 {
|
||||||
|
await sleep(nanos)
|
||||||
|
} else {
|
||||||
|
await Task.yield()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
self.task?.cancel()
|
||||||
|
self.task = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func runCheck(
|
||||||
|
check: @escaping @Sendable () async throws -> Bool,
|
||||||
|
timeoutSeconds: Double) async -> Bool
|
||||||
|
{
|
||||||
|
let timeout = max(0.0, timeoutSeconds)
|
||||||
|
if timeout == 0 {
|
||||||
|
return (try? await check()) ?? false
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let timeoutError = NSError(
|
||||||
|
domain: "GatewayHealthMonitor",
|
||||||
|
code: 1,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "health check timed out"])
|
||||||
|
return try await AsyncTimeout.withTimeout(
|
||||||
|
seconds: timeout,
|
||||||
|
onTimeout: { timeoutError },
|
||||||
|
operation: check)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import os
|
||||||
|
|
||||||
enum GatewaySettingsStore {
|
enum GatewaySettingsStore {
|
||||||
private static let gatewayService = "ai.openclaw.gateway"
|
private static let gatewayService = "ai.openclaw.gateway"
|
||||||
@@ -12,6 +13,12 @@ enum GatewaySettingsStore {
|
|||||||
private static let manualPortDefaultsKey = "gateway.manual.port"
|
private static let manualPortDefaultsKey = "gateway.manual.port"
|
||||||
private static let manualTlsDefaultsKey = "gateway.manual.tls"
|
private static let manualTlsDefaultsKey = "gateway.manual.tls"
|
||||||
private static let discoveryDebugLogsDefaultsKey = "gateway.discovery.debugLogs"
|
private static let discoveryDebugLogsDefaultsKey = "gateway.discovery.debugLogs"
|
||||||
|
private static let lastGatewayHostDefaultsKey = "gateway.last.host"
|
||||||
|
private static let lastGatewayPortDefaultsKey = "gateway.last.port"
|
||||||
|
private static let lastGatewayTlsDefaultsKey = "gateway.last.tls"
|
||||||
|
private static let lastGatewayStableIDDefaultsKey = "gateway.last.stableID"
|
||||||
|
private static let clientIdOverrideDefaultsPrefix = "gateway.clientIdOverride."
|
||||||
|
private static let selectedAgentDefaultsPrefix = "gateway.selectedAgentId."
|
||||||
|
|
||||||
private static let instanceIdAccount = "instanceId"
|
private static let instanceIdAccount = "instanceId"
|
||||||
private static let preferredGatewayStableIDAccount = "preferredStableID"
|
private static let preferredGatewayStableIDAccount = "preferredStableID"
|
||||||
@@ -107,6 +114,71 @@ enum GatewaySettingsStore {
|
|||||||
account: self.gatewayPasswordAccount(instanceId: instanceId))
|
account: self.gatewayPasswordAccount(instanceId: instanceId))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func saveLastGatewayConnection(host: String, port: Int, useTLS: Bool, stableID: String) {
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
defaults.set(host, forKey: self.lastGatewayHostDefaultsKey)
|
||||||
|
defaults.set(port, forKey: self.lastGatewayPortDefaultsKey)
|
||||||
|
defaults.set(useTLS, forKey: self.lastGatewayTlsDefaultsKey)
|
||||||
|
defaults.set(stableID, forKey: self.lastGatewayStableIDDefaultsKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func loadLastGatewayConnection() -> (host: String, port: Int, useTLS: Bool, stableID: String)? {
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
let host = defaults.string(forKey: self.lastGatewayHostDefaultsKey)?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
let port = defaults.integer(forKey: self.lastGatewayPortDefaultsKey)
|
||||||
|
let useTLS = defaults.bool(forKey: self.lastGatewayTlsDefaultsKey)
|
||||||
|
let stableID = defaults.string(forKey: self.lastGatewayStableIDDefaultsKey)?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
|
||||||
|
guard !host.isEmpty, port > 0, port <= 65535, !stableID.isEmpty else { return nil }
|
||||||
|
return (host: host, port: port, useTLS: useTLS, stableID: stableID)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func loadGatewayClientIdOverride(stableID: String) -> String? {
|
||||||
|
let trimmedID = stableID.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmedID.isEmpty else { return nil }
|
||||||
|
let key = self.clientIdOverrideDefaultsPrefix + trimmedID
|
||||||
|
let value = UserDefaults.standard.string(forKey: key)?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if value?.isEmpty == false { return value }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
static func saveGatewayClientIdOverride(stableID: String, clientId: String?) {
|
||||||
|
let trimmedID = stableID.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmedID.isEmpty else { return }
|
||||||
|
let key = self.clientIdOverrideDefaultsPrefix + trimmedID
|
||||||
|
let trimmedClientId = clientId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if trimmedClientId.isEmpty {
|
||||||
|
UserDefaults.standard.removeObject(forKey: key)
|
||||||
|
} else {
|
||||||
|
UserDefaults.standard.set(trimmedClientId, forKey: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func loadGatewaySelectedAgentId(stableID: String) -> String? {
|
||||||
|
let trimmedID = stableID.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmedID.isEmpty else { return nil }
|
||||||
|
let key = self.selectedAgentDefaultsPrefix + trimmedID
|
||||||
|
let value = UserDefaults.standard.string(forKey: key)?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if value?.isEmpty == false { return value }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
static func saveGatewaySelectedAgentId(stableID: String, agentId: String?) {
|
||||||
|
let trimmedID = stableID.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmedID.isEmpty else { return }
|
||||||
|
let key = self.selectedAgentDefaultsPrefix + trimmedID
|
||||||
|
let trimmedAgentId = agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if trimmedAgentId.isEmpty {
|
||||||
|
UserDefaults.standard.removeObject(forKey: key)
|
||||||
|
} else {
|
||||||
|
UserDefaults.standard.set(trimmedAgentId, forKey: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static func gatewayTokenAccount(instanceId: String) -> String {
|
private static func gatewayTokenAccount(instanceId: String) -> String {
|
||||||
"gateway-token.\(instanceId)"
|
"gateway-token.\(instanceId)"
|
||||||
}
|
}
|
||||||
@@ -175,3 +247,101 @@ enum GatewaySettingsStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum GatewayDiagnostics {
|
||||||
|
private static let logger = Logger(subsystem: "ai.openclaw.ios", category: "GatewayDiag")
|
||||||
|
private static let queue = DispatchQueue(label: "ai.openclaw.gateway.diagnostics")
|
||||||
|
private static let maxLogBytes: Int64 = 512 * 1024
|
||||||
|
private static let keepLogBytes: Int64 = 256 * 1024
|
||||||
|
private static let logSizeCheckEveryWrites = 50
|
||||||
|
nonisolated(unsafe) private static var logWritesSinceCheck = 0
|
||||||
|
private static var fileURL: URL? {
|
||||||
|
FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first?
|
||||||
|
.appendingPathComponent("openclaw-gateway.log")
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func truncateLogIfNeeded(url: URL) {
|
||||||
|
guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path),
|
||||||
|
let sizeNumber = attrs[.size] as? NSNumber
|
||||||
|
else { return }
|
||||||
|
let size = sizeNumber.int64Value
|
||||||
|
guard size > self.maxLogBytes else { return }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let handle = try FileHandle(forReadingFrom: url)
|
||||||
|
defer { try? handle.close() }
|
||||||
|
|
||||||
|
let start = max(Int64(0), size - self.keepLogBytes)
|
||||||
|
try handle.seek(toOffset: UInt64(start))
|
||||||
|
var tail = try handle.readToEnd() ?? Data()
|
||||||
|
|
||||||
|
// If we truncated mid-line, drop the first partial line so logs remain readable.
|
||||||
|
if start > 0, let nl = tail.firstIndex(of: 10) {
|
||||||
|
let next = tail.index(after: nl)
|
||||||
|
if next < tail.endIndex {
|
||||||
|
tail = tail.suffix(from: next)
|
||||||
|
} else {
|
||||||
|
tail = Data()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try tail.write(to: url, options: .atomic)
|
||||||
|
} catch {
|
||||||
|
// Best-effort only.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func appendToLog(url: URL, data: Data) {
|
||||||
|
if FileManager.default.fileExists(atPath: url.path) {
|
||||||
|
if let handle = try? FileHandle(forWritingTo: url) {
|
||||||
|
defer { try? handle.close() }
|
||||||
|
_ = try? handle.seekToEnd()
|
||||||
|
try? handle.write(contentsOf: data)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try? data.write(to: url, options: .atomic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func bootstrap() {
|
||||||
|
guard let url = fileURL else { return }
|
||||||
|
queue.async {
|
||||||
|
self.truncateLogIfNeeded(url: url)
|
||||||
|
let formatter = ISO8601DateFormatter()
|
||||||
|
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||||
|
let timestamp = formatter.string(from: Date())
|
||||||
|
let line = "[\(timestamp)] gateway diagnostics started\n"
|
||||||
|
if let data = line.data(using: .utf8) {
|
||||||
|
self.appendToLog(url: url, data: data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func log(_ message: String) {
|
||||||
|
let formatter = ISO8601DateFormatter()
|
||||||
|
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||||
|
let timestamp = formatter.string(from: Date())
|
||||||
|
let line = "[\(timestamp)] \(message)"
|
||||||
|
logger.info("\(line, privacy: .public)")
|
||||||
|
|
||||||
|
guard let url = fileURL else { return }
|
||||||
|
queue.async {
|
||||||
|
self.logWritesSinceCheck += 1
|
||||||
|
if self.logWritesSinceCheck >= self.logSizeCheckEveryWrites {
|
||||||
|
self.logWritesSinceCheck = 0
|
||||||
|
self.truncateLogIfNeeded(url: url)
|
||||||
|
}
|
||||||
|
let entry = line + "\n"
|
||||||
|
if let data = entry.data(using: .utf8) {
|
||||||
|
self.appendToLog(url: url, data: data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func reset() {
|
||||||
|
guard let url = fileURL else { return }
|
||||||
|
queue.async {
|
||||||
|
try? FileManager.default.removeItem(at: url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
164
apps/ios/Sources/Media/PhotoLibraryService.swift
Normal file
164
apps/ios/Sources/Media/PhotoLibraryService.swift
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import Foundation
|
||||||
|
import Photos
|
||||||
|
import OpenClawKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
final class PhotoLibraryService: PhotosServicing {
|
||||||
|
// The gateway WebSocket has a max payload size; returning large base64 blobs
|
||||||
|
// can cause the gateway to close the connection. Keep photo payloads small
|
||||||
|
// enough to safely fit in a single RPC frame.
|
||||||
|
//
|
||||||
|
// This is a transport constraint (not a security policy). If callers need
|
||||||
|
// full-resolution media, we should switch to an HTTP media handle flow.
|
||||||
|
private static let maxTotalBase64Chars = 340 * 1024
|
||||||
|
private static let maxPerPhotoBase64Chars = 300 * 1024
|
||||||
|
|
||||||
|
func latest(params: OpenClawPhotosLatestParams) async throws -> OpenClawPhotosLatestPayload {
|
||||||
|
let status = await Self.ensureAuthorization()
|
||||||
|
guard status == .authorized || status == .limited else {
|
||||||
|
throw NSError(domain: "Photos", code: 1, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "PHOTOS_PERMISSION_REQUIRED: grant Photos permission",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
let limit = max(1, min(params.limit ?? 1, 20))
|
||||||
|
let fetchOptions = PHFetchOptions()
|
||||||
|
fetchOptions.fetchLimit = limit
|
||||||
|
fetchOptions.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)]
|
||||||
|
let assets = PHAsset.fetchAssets(with: .image, options: fetchOptions)
|
||||||
|
|
||||||
|
var results: [OpenClawPhotoPayload] = []
|
||||||
|
var remainingBudget = Self.maxTotalBase64Chars
|
||||||
|
let maxWidth = params.maxWidth.flatMap { $0 > 0 ? $0 : nil } ?? 1600
|
||||||
|
let quality = params.quality.map { max(0.1, min(1.0, $0)) } ?? 0.85
|
||||||
|
let formatter = ISO8601DateFormatter()
|
||||||
|
|
||||||
|
assets.enumerateObjects { asset, _, stop in
|
||||||
|
if results.count >= limit { stop.pointee = true; return }
|
||||||
|
if let payload = try? Self.renderAsset(
|
||||||
|
asset,
|
||||||
|
maxWidth: maxWidth,
|
||||||
|
quality: quality,
|
||||||
|
formatter: formatter)
|
||||||
|
{
|
||||||
|
// Keep the entire response under the gateway WS max payload.
|
||||||
|
if payload.base64.count > remainingBudget {
|
||||||
|
stop.pointee = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
remainingBudget -= payload.base64.count
|
||||||
|
results.append(payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return OpenClawPhotosLatestPayload(photos: results)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func ensureAuthorization() async -> PHAuthorizationStatus {
|
||||||
|
// Don’t prompt during node.invoke; prompts block the invoke and lead to timeouts.
|
||||||
|
PHPhotoLibrary.authorizationStatus(for: .readWrite)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func renderAsset(
|
||||||
|
_ asset: PHAsset,
|
||||||
|
maxWidth: Int,
|
||||||
|
quality: Double,
|
||||||
|
formatter: ISO8601DateFormatter) throws -> OpenClawPhotoPayload
|
||||||
|
{
|
||||||
|
let manager = PHImageManager.default()
|
||||||
|
let options = PHImageRequestOptions()
|
||||||
|
options.isSynchronous = true
|
||||||
|
options.isNetworkAccessAllowed = true
|
||||||
|
options.deliveryMode = .highQualityFormat
|
||||||
|
|
||||||
|
let targetSize: CGSize = {
|
||||||
|
guard maxWidth > 0 else { return PHImageManagerMaximumSize }
|
||||||
|
let aspect = CGFloat(asset.pixelHeight) / CGFloat(max(1, asset.pixelWidth))
|
||||||
|
let width = CGFloat(maxWidth)
|
||||||
|
return CGSize(width: width, height: width * aspect)
|
||||||
|
}()
|
||||||
|
|
||||||
|
var image: UIImage?
|
||||||
|
manager.requestImage(
|
||||||
|
for: asset,
|
||||||
|
targetSize: targetSize,
|
||||||
|
contentMode: .aspectFit,
|
||||||
|
options: options)
|
||||||
|
{ result, _ in
|
||||||
|
image = result
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let image else {
|
||||||
|
throw NSError(domain: "Photos", code: 2, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "photo load failed",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
let (data, finalImage) = try encodeJpegUnderBudget(
|
||||||
|
image: image,
|
||||||
|
quality: quality,
|
||||||
|
maxBase64Chars: maxPerPhotoBase64Chars)
|
||||||
|
|
||||||
|
let created = asset.creationDate.map { formatter.string(from: $0) }
|
||||||
|
return OpenClawPhotoPayload(
|
||||||
|
format: "jpeg",
|
||||||
|
base64: data.base64EncodedString(),
|
||||||
|
width: Int(finalImage.size.width),
|
||||||
|
height: Int(finalImage.size.height),
|
||||||
|
createdAt: created)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func encodeJpegUnderBudget(
|
||||||
|
image: UIImage,
|
||||||
|
quality: Double,
|
||||||
|
maxBase64Chars: Int) throws -> (Data, UIImage)
|
||||||
|
{
|
||||||
|
var currentImage = image
|
||||||
|
var currentQuality = max(0.1, min(1.0, quality))
|
||||||
|
|
||||||
|
// Try lowering JPEG quality first, then downscale if needed.
|
||||||
|
for _ in 0..<10 {
|
||||||
|
guard let data = currentImage.jpegData(compressionQuality: currentQuality) else {
|
||||||
|
throw NSError(domain: "Photos", code: 3, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "photo encode failed",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
let base64Len = ((data.count + 2) / 3) * 4
|
||||||
|
if base64Len <= maxBase64Chars {
|
||||||
|
return (data, currentImage)
|
||||||
|
}
|
||||||
|
|
||||||
|
if currentQuality > 0.35 {
|
||||||
|
currentQuality = max(0.25, currentQuality - 0.15)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Downscale by ~25% each step once quality is low.
|
||||||
|
let newWidth = max(240, currentImage.size.width * 0.75)
|
||||||
|
if newWidth >= currentImage.size.width {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
currentImage = resize(image: currentImage, targetWidth: newWidth)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw NSError(domain: "Photos", code: 4, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "photo too large for gateway transport; try smaller maxWidth/quality",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func resize(image: UIImage, targetWidth: CGFloat) -> UIImage {
|
||||||
|
let size = image.size
|
||||||
|
if size.width <= 0 || size.height <= 0 || targetWidth <= 0 {
|
||||||
|
return image
|
||||||
|
}
|
||||||
|
let scale = targetWidth / size.width
|
||||||
|
let targetSize = CGSize(width: targetWidth, height: max(1, size.height * scale))
|
||||||
|
let format = UIGraphicsImageRendererFormat.default()
|
||||||
|
format.scale = 1
|
||||||
|
let renderer = UIGraphicsImageRenderer(size: targetSize, format: format)
|
||||||
|
return renderer.image { _ in
|
||||||
|
image.draw(in: CGRect(origin: .zero, size: targetSize))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
97
apps/ios/Sources/Model/NodeAppModel+Canvas.swift
Normal file
97
apps/ios/Sources/Model/NodeAppModel+Canvas.swift
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import Foundation
|
||||||
|
import Network
|
||||||
|
import os
|
||||||
|
|
||||||
|
extension NodeAppModel {
|
||||||
|
func _test_resolveA2UIHostURL() async -> String? {
|
||||||
|
await self.resolveA2UIHostURL()
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveA2UIHostURL() async -> String? {
|
||||||
|
guard let raw = await self.gatewaySession.currentCanvasHostUrl() else { return nil }
|
||||||
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty, let base = URL(string: trimmed) else { return nil }
|
||||||
|
if let host = base.host, Self.isLoopbackHost(host) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return base.appendingPathComponent("__openclaw__/a2ui/").absoluteString + "?platform=ios"
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func isLoopbackHost(_ host: String) -> Bool {
|
||||||
|
let normalized = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
if normalized.isEmpty { return true }
|
||||||
|
if normalized == "localhost" || normalized == "::1" || normalized == "0.0.0.0" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if normalized == "127.0.0.1" || normalized.hasPrefix("127.") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func showA2UIOnConnectIfNeeded() async {
|
||||||
|
guard let a2uiUrl = await self.resolveA2UIHostURL() else {
|
||||||
|
await MainActor.run {
|
||||||
|
self.lastAutoA2uiURL = nil
|
||||||
|
self.screen.showDefaultCanvas()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let current = self.screen.urlString.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if current.isEmpty || current == self.lastAutoA2uiURL {
|
||||||
|
// Avoid navigating the WKWebView to an unreachable host: it leaves a persistent
|
||||||
|
// "could not connect to the server" overlay even when the gateway is connected.
|
||||||
|
if let url = URL(string: a2uiUrl),
|
||||||
|
await Self.probeTCP(url: url, timeoutSeconds: 2.5)
|
||||||
|
{
|
||||||
|
self.screen.navigate(to: a2uiUrl)
|
||||||
|
self.lastAutoA2uiURL = a2uiUrl
|
||||||
|
} else {
|
||||||
|
self.lastAutoA2uiURL = nil
|
||||||
|
self.screen.showDefaultCanvas()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func showLocalCanvasOnDisconnect() {
|
||||||
|
self.lastAutoA2uiURL = nil
|
||||||
|
self.screen.showDefaultCanvas()
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func probeTCP(url: URL, timeoutSeconds: Double) async -> Bool {
|
||||||
|
guard let host = url.host, !host.isEmpty else { return false }
|
||||||
|
let portInt = url.port ?? ((url.scheme ?? "").lowercased() == "wss" ? 443 : 80)
|
||||||
|
guard portInt >= 1, portInt <= 65535 else { return false }
|
||||||
|
guard let nwPort = NWEndpoint.Port(rawValue: UInt16(portInt)) else { return false }
|
||||||
|
|
||||||
|
let endpointHost = NWEndpoint.Host(host)
|
||||||
|
let connection = NWConnection(host: endpointHost, port: nwPort, using: .tcp)
|
||||||
|
return await withCheckedContinuation { cont in
|
||||||
|
let queue = DispatchQueue(label: "a2ui.preflight")
|
||||||
|
let finished = OSAllocatedUnfairLock(initialState: false)
|
||||||
|
let finish: @Sendable (Bool) -> Void = { ok in
|
||||||
|
let shouldResume = finished.withLock { flag -> Bool in
|
||||||
|
if flag { return false }
|
||||||
|
flag = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
guard shouldResume else { return }
|
||||||
|
connection.cancel()
|
||||||
|
cont.resume(returning: ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
connection.stateUpdateHandler = { state in
|
||||||
|
switch state {
|
||||||
|
case .ready:
|
||||||
|
finish(true)
|
||||||
|
case .failed, .cancelled:
|
||||||
|
finish(false)
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
connection.start(queue: queue)
|
||||||
|
queue.asyncAfter(deadline: .now() + timeoutSeconds) { finish(false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
100
apps/ios/Sources/Motion/MotionService.swift
Normal file
100
apps/ios/Sources/Motion/MotionService.swift
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import CoreMotion
|
||||||
|
import Foundation
|
||||||
|
import OpenClawKit
|
||||||
|
|
||||||
|
final class MotionService: MotionServicing {
|
||||||
|
func activities(params: OpenClawMotionActivityParams) async throws -> OpenClawMotionActivityPayload {
|
||||||
|
guard CMMotionActivityManager.isActivityAvailable() else {
|
||||||
|
throw NSError(domain: "Motion", code: 1, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "MOTION_UNAVAILABLE: activity not supported on this device",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
let auth = CMMotionActivityManager.authorizationStatus()
|
||||||
|
guard auth == .authorized else {
|
||||||
|
throw NSError(domain: "Motion", code: 3, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "MOTION_PERMISSION_REQUIRED: grant Motion & Fitness permission",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
let (start, end) = Self.resolveRange(startISO: params.startISO, endISO: params.endISO)
|
||||||
|
let limit = max(1, min(params.limit ?? 200, 1000))
|
||||||
|
|
||||||
|
let manager = CMMotionActivityManager()
|
||||||
|
let mapped = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<[OpenClawMotionActivityEntry], Error>) in
|
||||||
|
manager.queryActivityStarting(from: start, to: end, to: OperationQueue()) { activity, error in
|
||||||
|
if let error {
|
||||||
|
cont.resume(throwing: error)
|
||||||
|
} else {
|
||||||
|
let formatter = ISO8601DateFormatter()
|
||||||
|
let sliced = Array((activity ?? []).suffix(limit))
|
||||||
|
let entries = sliced.map { entry in
|
||||||
|
OpenClawMotionActivityEntry(
|
||||||
|
startISO: formatter.string(from: entry.startDate),
|
||||||
|
endISO: formatter.string(from: end),
|
||||||
|
confidence: Self.confidenceString(entry.confidence),
|
||||||
|
isWalking: entry.walking,
|
||||||
|
isRunning: entry.running,
|
||||||
|
isCycling: entry.cycling,
|
||||||
|
isAutomotive: entry.automotive,
|
||||||
|
isStationary: entry.stationary,
|
||||||
|
isUnknown: entry.unknown)
|
||||||
|
}
|
||||||
|
cont.resume(returning: entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return OpenClawMotionActivityPayload(activities: mapped)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pedometer(params: OpenClawPedometerParams) async throws -> OpenClawPedometerPayload {
|
||||||
|
guard CMPedometer.isStepCountingAvailable() else {
|
||||||
|
throw NSError(domain: "Motion", code: 2, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "PEDOMETER_UNAVAILABLE: step counting not supported",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
let auth = CMPedometer.authorizationStatus()
|
||||||
|
guard auth == .authorized else {
|
||||||
|
throw NSError(domain: "Motion", code: 4, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "MOTION_PERMISSION_REQUIRED: grant Motion & Fitness permission",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
let (start, end) = Self.resolveRange(startISO: params.startISO, endISO: params.endISO)
|
||||||
|
let pedometer = CMPedometer()
|
||||||
|
let payload = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<OpenClawPedometerPayload, Error>) in
|
||||||
|
pedometer.queryPedometerData(from: start, to: end) { data, error in
|
||||||
|
if let error {
|
||||||
|
cont.resume(throwing: error)
|
||||||
|
} else {
|
||||||
|
let formatter = ISO8601DateFormatter()
|
||||||
|
let payload = OpenClawPedometerPayload(
|
||||||
|
startISO: formatter.string(from: start),
|
||||||
|
endISO: formatter.string(from: end),
|
||||||
|
steps: data?.numberOfSteps.intValue,
|
||||||
|
distanceMeters: data?.distance?.doubleValue,
|
||||||
|
floorsAscended: data?.floorsAscended?.intValue,
|
||||||
|
floorsDescended: data?.floorsDescended?.intValue)
|
||||||
|
cont.resume(returning: payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func resolveRange(startISO: String?, endISO: String?) -> (Date, Date) {
|
||||||
|
let formatter = ISO8601DateFormatter()
|
||||||
|
let start = startISO.flatMap { formatter.date(from: $0) } ?? Calendar.current.startOfDay(for: Date())
|
||||||
|
let end = endISO.flatMap { formatter.date(from: $0) } ?? Date()
|
||||||
|
return (start, end)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func confidenceString(_ confidence: CMMotionActivityConfidence) -> String {
|
||||||
|
switch confidence {
|
||||||
|
case .low: "low"
|
||||||
|
case .medium: "medium"
|
||||||
|
case .high: "high"
|
||||||
|
@unknown default: "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
389
apps/ios/Sources/Onboarding/GatewayOnboardingView.swift
Normal file
389
apps/ios/Sources/Onboarding/GatewayOnboardingView.swift
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct GatewayOnboardingView: View {
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List {
|
||||||
|
Section {
|
||||||
|
Text("Connect to your gateway to get started.")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
NavigationLink("Auto detect") {
|
||||||
|
AutoDetectStep()
|
||||||
|
}
|
||||||
|
NavigationLink("Manual entry") {
|
||||||
|
ManualEntryStep()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Connect Gateway")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct AutoDetectStep: View {
|
||||||
|
@Environment(NodeAppModel.self) private var appModel: NodeAppModel
|
||||||
|
@Environment(GatewayConnectionController.self) private var gatewayController: GatewayConnectionController
|
||||||
|
@AppStorage("gateway.preferredStableID") private var preferredGatewayStableID: String = ""
|
||||||
|
@AppStorage("gateway.lastDiscoveredStableID") private var lastDiscoveredGatewayStableID: String = ""
|
||||||
|
|
||||||
|
@State private var connectingGatewayID: String?
|
||||||
|
@State private var connectStatusText: String?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
Text("We’ll scan for gateways on your network and connect automatically when we find one.")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Connection status") {
|
||||||
|
ConnectionStatusBox(
|
||||||
|
statusLines: self.connectionStatusLines(),
|
||||||
|
secondaryLine: self.connectStatusText)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Button("Retry") {
|
||||||
|
self.resetConnectionState()
|
||||||
|
self.triggerAutoConnect()
|
||||||
|
}
|
||||||
|
.disabled(self.connectingGatewayID != nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Auto detect")
|
||||||
|
.onAppear { self.triggerAutoConnect() }
|
||||||
|
.onChange(of: self.gatewayController.gateways) { _, _ in
|
||||||
|
self.triggerAutoConnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func triggerAutoConnect() {
|
||||||
|
guard self.appModel.gatewayServerName == nil else { return }
|
||||||
|
guard self.connectingGatewayID == nil else { return }
|
||||||
|
guard let candidate = self.autoCandidate() else { return }
|
||||||
|
|
||||||
|
self.connectingGatewayID = candidate.id
|
||||||
|
Task {
|
||||||
|
defer { self.connectingGatewayID = nil }
|
||||||
|
await self.gatewayController.connect(candidate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func autoCandidate() -> GatewayDiscoveryModel.DiscoveredGateway? {
|
||||||
|
let preferred = self.preferredGatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let lastDiscovered = self.lastDiscoveredGatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|
||||||
|
if !preferred.isEmpty,
|
||||||
|
let match = self.gatewayController.gateways.first(where: { $0.stableID == preferred })
|
||||||
|
{
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
if !lastDiscovered.isEmpty,
|
||||||
|
let match = self.gatewayController.gateways.first(where: { $0.stableID == lastDiscovered })
|
||||||
|
{
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
if self.gatewayController.gateways.count == 1 {
|
||||||
|
return self.gatewayController.gateways.first
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func connectionStatusLines() -> [String] {
|
||||||
|
ConnectionStatusBox.defaultLines(appModel: self.appModel, gatewayController: self.gatewayController)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resetConnectionState() {
|
||||||
|
self.appModel.disconnectGateway()
|
||||||
|
self.connectStatusText = nil
|
||||||
|
self.connectingGatewayID = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ManualEntryStep: View {
|
||||||
|
@Environment(NodeAppModel.self) private var appModel: NodeAppModel
|
||||||
|
@Environment(GatewayConnectionController.self) private var gatewayController: GatewayConnectionController
|
||||||
|
|
||||||
|
@State private var setupCode: String = ""
|
||||||
|
@State private var setupStatusText: String?
|
||||||
|
@State private var manualHost: String = ""
|
||||||
|
@State private var manualPortText: String = ""
|
||||||
|
@State private var manualUseTLS: Bool = true
|
||||||
|
@State private var manualToken: String = ""
|
||||||
|
@State private var manualPassword: String = ""
|
||||||
|
|
||||||
|
@State private var connectingGatewayID: String?
|
||||||
|
@State private var connectStatusText: String?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
Section("Setup code") {
|
||||||
|
Text("Use /pair in your bot to get a setup code.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
TextField("Paste setup code", text: self.$setupCode)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
|
||||||
|
Button("Apply setup code") {
|
||||||
|
self.applySetupCode()
|
||||||
|
}
|
||||||
|
.disabled(self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||||
|
|
||||||
|
if let setupStatusText, !setupStatusText.isEmpty {
|
||||||
|
Text(setupStatusText)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
TextField("Host", text: self.$manualHost)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
|
||||||
|
TextField("Port", text: self.$manualPortText)
|
||||||
|
.keyboardType(.numberPad)
|
||||||
|
|
||||||
|
Toggle("Use TLS", isOn: self.$manualUseTLS)
|
||||||
|
|
||||||
|
TextField("Gateway token", text: self.$manualToken)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
|
||||||
|
SecureField("Gateway password", text: self.$manualPassword)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Connection status") {
|
||||||
|
ConnectionStatusBox(
|
||||||
|
statusLines: self.connectionStatusLines(),
|
||||||
|
secondaryLine: self.connectStatusText)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Button {
|
||||||
|
Task { await self.connectManual() }
|
||||||
|
} label: {
|
||||||
|
if self.connectingGatewayID == "manual" {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(.circular)
|
||||||
|
Text("Connecting…")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text("Connect")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(self.connectingGatewayID != nil)
|
||||||
|
|
||||||
|
Button("Retry") {
|
||||||
|
self.resetConnectionState()
|
||||||
|
self.resetManualForm()
|
||||||
|
}
|
||||||
|
.disabled(self.connectingGatewayID != nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Manual entry")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func connectManual() async {
|
||||||
|
let host = self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !host.isEmpty else {
|
||||||
|
self.connectStatusText = "Failed: host required"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let port = self.manualPortValue(), !(1...65535).contains(port) {
|
||||||
|
self.connectStatusText = "Failed: invalid port"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
defaults.set(true, forKey: "gateway.manual.enabled")
|
||||||
|
defaults.set(host, forKey: "gateway.manual.host")
|
||||||
|
defaults.set(self.manualPortValue() ?? 0, forKey: "gateway.manual.port")
|
||||||
|
defaults.set(self.manualUseTLS, forKey: "gateway.manual.tls")
|
||||||
|
|
||||||
|
if let instanceId = defaults.string(forKey: "node.instanceId")?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
!instanceId.isEmpty
|
||||||
|
{
|
||||||
|
let trimmedToken = self.manualToken.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let trimmedPassword = self.manualPassword.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !trimmedToken.isEmpty {
|
||||||
|
GatewaySettingsStore.saveGatewayToken(trimmedToken, instanceId: instanceId)
|
||||||
|
}
|
||||||
|
GatewaySettingsStore.saveGatewayPassword(trimmedPassword, instanceId: instanceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
self.connectingGatewayID = "manual"
|
||||||
|
defer { self.connectingGatewayID = nil }
|
||||||
|
await self.gatewayController.connectManual(
|
||||||
|
host: host,
|
||||||
|
port: self.manualPortValue() ?? 0,
|
||||||
|
useTLS: self.manualUseTLS)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func manualPortValue() -> Int? {
|
||||||
|
let trimmed = self.manualPortText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return nil }
|
||||||
|
return Int(trimmed.filter { $0.isNumber })
|
||||||
|
}
|
||||||
|
|
||||||
|
private func connectionStatusLines() -> [String] {
|
||||||
|
ConnectionStatusBox.defaultLines(appModel: self.appModel, gatewayController: self.gatewayController)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resetConnectionState() {
|
||||||
|
self.appModel.disconnectGateway()
|
||||||
|
self.connectStatusText = nil
|
||||||
|
self.connectingGatewayID = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resetManualForm() {
|
||||||
|
self.setupCode = ""
|
||||||
|
self.setupStatusText = nil
|
||||||
|
self.manualHost = ""
|
||||||
|
self.manualPortText = ""
|
||||||
|
self.manualUseTLS = true
|
||||||
|
self.manualToken = ""
|
||||||
|
self.manualPassword = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct SetupPayload: Codable {
|
||||||
|
var url: String?
|
||||||
|
var host: String?
|
||||||
|
var port: Int?
|
||||||
|
var tls: Bool?
|
||||||
|
var token: String?
|
||||||
|
var password: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applySetupCode() {
|
||||||
|
let raw = self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !raw.isEmpty else {
|
||||||
|
self.setupStatusText = "Paste a setup code to continue."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let payload = self.decodeSetupPayload(raw: raw) else {
|
||||||
|
self.setupStatusText = "Setup code not recognized."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let urlString = payload.url, let url = URL(string: urlString) {
|
||||||
|
self.applyURL(url)
|
||||||
|
} else if let host = payload.host, !host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
self.manualHost = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if let port = payload.port {
|
||||||
|
self.manualPortText = String(port)
|
||||||
|
} else {
|
||||||
|
self.manualPortText = ""
|
||||||
|
}
|
||||||
|
if let tls = payload.tls {
|
||||||
|
self.manualUseTLS = tls
|
||||||
|
}
|
||||||
|
} else if let url = URL(string: raw), url.scheme != nil {
|
||||||
|
self.applyURL(url)
|
||||||
|
} else {
|
||||||
|
self.setupStatusText = "Setup code missing URL or host."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let token = payload.token, !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
self.manualToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
if let password = payload.password, !password.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
self.manualPassword = password.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
|
||||||
|
self.setupStatusText = "Setup code applied."
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyURL(_ url: URL) {
|
||||||
|
guard let host = url.host, !host.isEmpty else { return }
|
||||||
|
self.manualHost = host
|
||||||
|
if let port = url.port {
|
||||||
|
self.manualPortText = String(port)
|
||||||
|
} else {
|
||||||
|
self.manualPortText = ""
|
||||||
|
}
|
||||||
|
let scheme = (url.scheme ?? "").lowercased()
|
||||||
|
if scheme == "wss" || scheme == "https" {
|
||||||
|
self.manualUseTLS = true
|
||||||
|
} else if scheme == "ws" || scheme == "http" {
|
||||||
|
self.manualUseTLS = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func decodeSetupPayload(raw: String) -> SetupPayload? {
|
||||||
|
if let payload = decodeSetupPayloadFromJSON(raw) {
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
if let decoded = decodeBase64Payload(raw),
|
||||||
|
let payload = decodeSetupPayloadFromJSON(decoded)
|
||||||
|
{
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func decodeSetupPayloadFromJSON(_ json: String) -> SetupPayload? {
|
||||||
|
guard let data = json.data(using: .utf8) else { return nil }
|
||||||
|
return try? JSONDecoder().decode(SetupPayload.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func decodeBase64Payload(_ raw: String) -> String? {
|
||||||
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return nil }
|
||||||
|
let normalized = trimmed
|
||||||
|
.replacingOccurrences(of: "-", with: "+")
|
||||||
|
.replacingOccurrences(of: "_", with: "/")
|
||||||
|
let padding = normalized.count % 4
|
||||||
|
let padded = padding == 0 ? normalized : normalized + String(repeating: "=", count: 4 - padding)
|
||||||
|
guard let data = Data(base64Encoded: padded) else { return nil }
|
||||||
|
return String(data: data, encoding: .utf8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ConnectionStatusBox: View {
|
||||||
|
let statusLines: [String]
|
||||||
|
let secondaryLine: String?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
ForEach(self.statusLines, id: \.self) { line in
|
||||||
|
Text(line)
|
||||||
|
.font(.system(size: 12, weight: .regular, design: .monospaced))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
if let secondaryLine, !secondaryLine.isEmpty {
|
||||||
|
Text(secondaryLine)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(10)
|
||||||
|
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||||
|
}
|
||||||
|
|
||||||
|
static func defaultLines(
|
||||||
|
appModel: NodeAppModel,
|
||||||
|
gatewayController: GatewayConnectionController
|
||||||
|
) -> [String] {
|
||||||
|
var lines: [String] = [
|
||||||
|
"gateway: \(appModel.gatewayStatusText)",
|
||||||
|
"discovery: \(gatewayController.discoveryStatusText)",
|
||||||
|
]
|
||||||
|
lines.append("server: \(appModel.gatewayServerName ?? "—")")
|
||||||
|
lines.append("address: \(appModel.gatewayRemoteAddress ?? "—")")
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
}
|
||||||
165
apps/ios/Sources/Reminders/RemindersService.swift
Normal file
165
apps/ios/Sources/Reminders/RemindersService.swift
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import EventKit
|
||||||
|
import Foundation
|
||||||
|
import OpenClawKit
|
||||||
|
|
||||||
|
final class RemindersService: RemindersServicing {
|
||||||
|
func list(params: OpenClawRemindersListParams) async throws -> OpenClawRemindersListPayload {
|
||||||
|
let store = EKEventStore()
|
||||||
|
let status = EKEventStore.authorizationStatus(for: .reminder)
|
||||||
|
let authorized = await Self.ensureAuthorization(store: store, status: status)
|
||||||
|
guard authorized else {
|
||||||
|
throw NSError(domain: "Reminders", code: 1, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "REMINDERS_PERMISSION_REQUIRED: grant Reminders permission",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
let limit = max(1, min(params.limit ?? 50, 500))
|
||||||
|
let statusFilter = params.status ?? .incomplete
|
||||||
|
|
||||||
|
let predicate = store.predicateForReminders(in: nil)
|
||||||
|
let payload = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<[OpenClawReminderPayload], Error>) in
|
||||||
|
store.fetchReminders(matching: predicate) { items in
|
||||||
|
let formatter = ISO8601DateFormatter()
|
||||||
|
let filtered = (items ?? []).filter { reminder in
|
||||||
|
switch statusFilter {
|
||||||
|
case .all:
|
||||||
|
return true
|
||||||
|
case .completed:
|
||||||
|
return reminder.isCompleted
|
||||||
|
case .incomplete:
|
||||||
|
return !reminder.isCompleted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let selected = Array(filtered.prefix(limit))
|
||||||
|
let payload = selected.map { reminder in
|
||||||
|
let due = reminder.dueDateComponents.flatMap { Calendar.current.date(from: $0) }
|
||||||
|
return OpenClawReminderPayload(
|
||||||
|
identifier: reminder.calendarItemIdentifier,
|
||||||
|
title: reminder.title,
|
||||||
|
dueISO: due.map { formatter.string(from: $0) },
|
||||||
|
completed: reminder.isCompleted,
|
||||||
|
listName: reminder.calendar.title)
|
||||||
|
}
|
||||||
|
cont.resume(returning: payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return OpenClawRemindersListPayload(reminders: payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func add(params: OpenClawRemindersAddParams) async throws -> OpenClawRemindersAddPayload {
|
||||||
|
let store = EKEventStore()
|
||||||
|
let status = EKEventStore.authorizationStatus(for: .reminder)
|
||||||
|
let authorized = await Self.ensureWriteAuthorization(store: store, status: status)
|
||||||
|
guard authorized else {
|
||||||
|
throw NSError(domain: "Reminders", code: 2, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "REMINDERS_PERMISSION_REQUIRED: grant Reminders permission",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !title.isEmpty else {
|
||||||
|
throw NSError(domain: "Reminders", code: 3, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "REMINDERS_INVALID: title required",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
let reminder = EKReminder(eventStore: store)
|
||||||
|
reminder.title = title
|
||||||
|
if let notes = params.notes?.trimmingCharacters(in: .whitespacesAndNewlines), !notes.isEmpty {
|
||||||
|
reminder.notes = notes
|
||||||
|
}
|
||||||
|
reminder.calendar = try Self.resolveList(
|
||||||
|
store: store,
|
||||||
|
listId: params.listId,
|
||||||
|
listName: params.listName)
|
||||||
|
|
||||||
|
if let dueISO = params.dueISO?.trimmingCharacters(in: .whitespacesAndNewlines), !dueISO.isEmpty {
|
||||||
|
let formatter = ISO8601DateFormatter()
|
||||||
|
guard let dueDate = formatter.date(from: dueISO) else {
|
||||||
|
throw NSError(domain: "Reminders", code: 4, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "REMINDERS_INVALID: dueISO must be ISO-8601",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
reminder.dueDateComponents = Calendar.current.dateComponents(
|
||||||
|
[.year, .month, .day, .hour, .minute, .second],
|
||||||
|
from: dueDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
try store.save(reminder, commit: true)
|
||||||
|
|
||||||
|
let formatter = ISO8601DateFormatter()
|
||||||
|
let due = reminder.dueDateComponents.flatMap { Calendar.current.date(from: $0) }
|
||||||
|
let payload = OpenClawReminderPayload(
|
||||||
|
identifier: reminder.calendarItemIdentifier,
|
||||||
|
title: reminder.title,
|
||||||
|
dueISO: due.map { formatter.string(from: $0) },
|
||||||
|
completed: reminder.isCompleted,
|
||||||
|
listName: reminder.calendar.title)
|
||||||
|
|
||||||
|
return OpenClawRemindersAddPayload(reminder: payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func ensureAuthorization(store: EKEventStore, status: EKAuthorizationStatus) async -> Bool {
|
||||||
|
switch status {
|
||||||
|
case .authorized:
|
||||||
|
return true
|
||||||
|
case .notDetermined:
|
||||||
|
// Don’t prompt during node.invoke; prompts block the invoke and lead to timeouts.
|
||||||
|
return false
|
||||||
|
case .restricted, .denied:
|
||||||
|
return false
|
||||||
|
case .fullAccess:
|
||||||
|
return true
|
||||||
|
case .writeOnly:
|
||||||
|
return false
|
||||||
|
@unknown default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func ensureWriteAuthorization(store: EKEventStore, status: EKAuthorizationStatus) async -> Bool {
|
||||||
|
switch status {
|
||||||
|
case .authorized, .fullAccess, .writeOnly:
|
||||||
|
return true
|
||||||
|
case .notDetermined:
|
||||||
|
// Don’t prompt during node.invoke; prompts block the invoke and lead to timeouts.
|
||||||
|
return false
|
||||||
|
case .restricted, .denied:
|
||||||
|
return false
|
||||||
|
@unknown default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func resolveList(
|
||||||
|
store: EKEventStore,
|
||||||
|
listId: String?,
|
||||||
|
listName: String?) throws -> EKCalendar
|
||||||
|
{
|
||||||
|
if let id = listId?.trimmingCharacters(in: .whitespacesAndNewlines), !id.isEmpty,
|
||||||
|
let calendar = store.calendar(withIdentifier: id)
|
||||||
|
{
|
||||||
|
return calendar
|
||||||
|
}
|
||||||
|
|
||||||
|
if let title = listName?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty {
|
||||||
|
if let calendar = store.calendars(for: .reminder).first(where: {
|
||||||
|
$0.title.compare(title, options: [.caseInsensitive, .diacriticInsensitive]) == .orderedSame
|
||||||
|
}) {
|
||||||
|
return calendar
|
||||||
|
}
|
||||||
|
throw NSError(domain: "Reminders", code: 5, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "REMINDERS_LIST_NOT_FOUND: no list named \(title)",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
if let fallback = store.defaultCalendarForNewReminders() {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
throw NSError(domain: "Reminders", code: 6, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "REMINDERS_LIST_NOT_FOUND: no default list",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,9 +9,15 @@ struct RootCanvas: View {
|
|||||||
@AppStorage(VoiceWakePreferences.enabledKey) private var voiceWakeEnabled: Bool = false
|
@AppStorage(VoiceWakePreferences.enabledKey) private var voiceWakeEnabled: Bool = false
|
||||||
@AppStorage("screen.preventSleep") private var preventSleep: Bool = true
|
@AppStorage("screen.preventSleep") private var preventSleep: Bool = true
|
||||||
@AppStorage("canvas.debugStatusEnabled") private var canvasDebugStatusEnabled: Bool = false
|
@AppStorage("canvas.debugStatusEnabled") private var canvasDebugStatusEnabled: Bool = false
|
||||||
|
@AppStorage("gateway.onboardingComplete") private var onboardingComplete: Bool = false
|
||||||
|
@AppStorage("gateway.hasConnectedOnce") private var hasConnectedOnce: Bool = false
|
||||||
|
@AppStorage("gateway.preferredStableID") private var preferredGatewayStableID: String = ""
|
||||||
|
@AppStorage("gateway.manual.enabled") private var manualGatewayEnabled: Bool = false
|
||||||
|
@AppStorage("gateway.manual.host") private var manualGatewayHost: String = ""
|
||||||
@State private var presentedSheet: PresentedSheet?
|
@State private var presentedSheet: PresentedSheet?
|
||||||
@State private var voiceWakeToastText: String?
|
@State private var voiceWakeToastText: String?
|
||||||
@State private var toastDismissTask: Task<Void, Never>?
|
@State private var toastDismissTask: Task<Void, Never>?
|
||||||
|
@State private var didAutoOpenSettings: Bool = false
|
||||||
|
|
||||||
private enum PresentedSheet: Identifiable {
|
private enum PresentedSheet: Identifiable {
|
||||||
case settings
|
case settings
|
||||||
@@ -52,12 +58,14 @@ struct RootCanvas: View {
|
|||||||
SettingsTab()
|
SettingsTab()
|
||||||
case .chat:
|
case .chat:
|
||||||
ChatSheet(
|
ChatSheet(
|
||||||
gateway: self.appModel.gatewaySession,
|
gateway: self.appModel.operatorSession,
|
||||||
sessionKey: self.appModel.mainSessionKey,
|
sessionKey: self.appModel.mainSessionKey,
|
||||||
|
agentName: self.appModel.activeAgentName,
|
||||||
userAccent: self.appModel.seamColor)
|
userAccent: self.appModel.seamColor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear { self.updateIdleTimer() }
|
.onAppear { self.updateIdleTimer() }
|
||||||
|
.onAppear { self.maybeAutoOpenSettings() }
|
||||||
.onChange(of: self.preventSleep) { _, _ in self.updateIdleTimer() }
|
.onChange(of: self.preventSleep) { _, _ in self.updateIdleTimer() }
|
||||||
.onChange(of: self.scenePhase) { _, _ in self.updateIdleTimer() }
|
.onChange(of: self.scenePhase) { _, _ in self.updateIdleTimer() }
|
||||||
.onAppear { self.updateCanvasDebugStatus() }
|
.onAppear { self.updateCanvasDebugStatus() }
|
||||||
@@ -65,6 +73,13 @@ struct RootCanvas: View {
|
|||||||
.onChange(of: self.appModel.gatewayStatusText) { _, _ in self.updateCanvasDebugStatus() }
|
.onChange(of: self.appModel.gatewayStatusText) { _, _ in self.updateCanvasDebugStatus() }
|
||||||
.onChange(of: self.appModel.gatewayServerName) { _, _ in self.updateCanvasDebugStatus() }
|
.onChange(of: self.appModel.gatewayServerName) { _, _ in self.updateCanvasDebugStatus() }
|
||||||
.onChange(of: self.appModel.gatewayRemoteAddress) { _, _ in self.updateCanvasDebugStatus() }
|
.onChange(of: self.appModel.gatewayRemoteAddress) { _, _ in self.updateCanvasDebugStatus() }
|
||||||
|
.onChange(of: self.appModel.gatewayServerName) { _, newValue in
|
||||||
|
if newValue != nil {
|
||||||
|
self.onboardingComplete = true
|
||||||
|
self.hasConnectedOnce = true
|
||||||
|
}
|
||||||
|
self.maybeAutoOpenSettings()
|
||||||
|
}
|
||||||
.onChange(of: self.voiceWake.lastTriggeredCommand) { _, newValue in
|
.onChange(of: self.voiceWake.lastTriggeredCommand) { _, newValue in
|
||||||
guard let newValue else { return }
|
guard let newValue else { return }
|
||||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
@@ -119,12 +134,33 @@ struct RootCanvas: View {
|
|||||||
let subtitle = self.appModel.gatewayServerName ?? self.appModel.gatewayRemoteAddress
|
let subtitle = self.appModel.gatewayServerName ?? self.appModel.gatewayRemoteAddress
|
||||||
self.appModel.screen.updateDebugStatus(title: title, subtitle: subtitle)
|
self.appModel.screen.updateDebugStatus(title: title, subtitle: subtitle)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func shouldAutoOpenSettings() -> Bool {
|
||||||
|
if self.appModel.gatewayServerName != nil { return false }
|
||||||
|
if !self.hasConnectedOnce { return true }
|
||||||
|
if !self.onboardingComplete { return true }
|
||||||
|
return !self.hasExistingGatewayConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func hasExistingGatewayConfig() -> Bool {
|
||||||
|
if GatewaySettingsStore.loadLastGatewayConnection() != nil { return true }
|
||||||
|
let manualHost = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return self.manualGatewayEnabled && !manualHost.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
private func maybeAutoOpenSettings() {
|
||||||
|
guard !self.didAutoOpenSettings else { return }
|
||||||
|
guard self.shouldAutoOpenSettings() else { return }
|
||||||
|
self.didAutoOpenSettings = true
|
||||||
|
self.presentedSheet = .settings
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct CanvasContent: View {
|
private struct CanvasContent: View {
|
||||||
@Environment(NodeAppModel.self) private var appModel
|
@Environment(NodeAppModel.self) private var appModel
|
||||||
@AppStorage("talk.enabled") private var talkEnabled: Bool = false
|
@AppStorage("talk.enabled") private var talkEnabled: Bool = false
|
||||||
@AppStorage("talk.button.enabled") private var talkButtonEnabled: Bool = true
|
@AppStorage("talk.button.enabled") private var talkButtonEnabled: Bool = true
|
||||||
|
@State private var showGatewayActions: Bool = false
|
||||||
var systemColorScheme: ColorScheme
|
var systemColorScheme: ColorScheme
|
||||||
var gatewayStatus: StatusPill.GatewayState
|
var gatewayStatus: StatusPill.GatewayState
|
||||||
var voiceWakeEnabled: Bool
|
var voiceWakeEnabled: Bool
|
||||||
@@ -182,7 +218,11 @@ private struct CanvasContent: View {
|
|||||||
activity: self.statusActivity,
|
activity: self.statusActivity,
|
||||||
brighten: self.brightenButtons,
|
brighten: self.brightenButtons,
|
||||||
onTap: {
|
onTap: {
|
||||||
self.openSettings()
|
if self.gatewayStatus == .connected {
|
||||||
|
self.showGatewayActions = true
|
||||||
|
} else {
|
||||||
|
self.openSettings()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.padding(.leading, 10)
|
.padding(.leading, 10)
|
||||||
.safeAreaPadding(.top, 10)
|
.safeAreaPadding(.top, 10)
|
||||||
@@ -197,6 +237,21 @@ private struct CanvasContent: View {
|
|||||||
.transition(.move(edge: .top).combined(with: .opacity))
|
.transition(.move(edge: .top).combined(with: .opacity))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.confirmationDialog(
|
||||||
|
"Gateway",
|
||||||
|
isPresented: self.$showGatewayActions,
|
||||||
|
titleVisibility: .visible)
|
||||||
|
{
|
||||||
|
Button("Disconnect", role: .destructive) {
|
||||||
|
self.appModel.disconnectGateway()
|
||||||
|
}
|
||||||
|
Button("Open Settings") {
|
||||||
|
self.openSettings()
|
||||||
|
}
|
||||||
|
Button("Cancel", role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text("Disconnect from the gateway?")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var statusActivity: StatusPill.Activity? {
|
private var statusActivity: StatusPill.Activity? {
|
||||||
@@ -248,6 +303,10 @@ private struct CanvasContent: View {
|
|||||||
return StatusPill.Activity(title: "Mic permission", systemImage: "mic.slash", tint: .orange)
|
return StatusPill.Activity(title: "Mic permission", systemImage: "mic.slash", tint: .orange)
|
||||||
}
|
}
|
||||||
if voiceStatus == "Paused" {
|
if voiceStatus == "Paused" {
|
||||||
|
// Talk mode intentionally pauses voice wake to release the mic. Don't spam the HUD for that case.
|
||||||
|
if self.appModel.talkMode.isEnabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
let suffix = self.appModel.isBackgrounded ? " (background)" : ""
|
let suffix = self.appModel.isBackgrounded ? " (background)" : ""
|
||||||
return StatusPill.Activity(title: "Voice Wake paused\(suffix)", systemImage: "pause.circle.fill")
|
return StatusPill.Activity(title: "Voice Wake paused\(suffix)", systemImage: "pause.circle.fill")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ struct RootTabs: View {
|
|||||||
@State private var selectedTab: Int = 0
|
@State private var selectedTab: Int = 0
|
||||||
@State private var voiceWakeToastText: String?
|
@State private var voiceWakeToastText: String?
|
||||||
@State private var toastDismissTask: Task<Void, Never>?
|
@State private var toastDismissTask: Task<Void, Never>?
|
||||||
|
@State private var showGatewayActions: Bool = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
TabView(selection: self.$selectedTab) {
|
TabView(selection: self.$selectedTab) {
|
||||||
@@ -27,7 +28,13 @@ struct RootTabs: View {
|
|||||||
gateway: self.gatewayStatus,
|
gateway: self.gatewayStatus,
|
||||||
voiceWakeEnabled: self.voiceWakeEnabled,
|
voiceWakeEnabled: self.voiceWakeEnabled,
|
||||||
activity: self.statusActivity,
|
activity: self.statusActivity,
|
||||||
onTap: { self.selectedTab = 2 })
|
onTap: {
|
||||||
|
if self.gatewayStatus == .connected {
|
||||||
|
self.showGatewayActions = true
|
||||||
|
} else {
|
||||||
|
self.selectedTab = 2
|
||||||
|
}
|
||||||
|
})
|
||||||
.padding(.leading, 10)
|
.padding(.leading, 10)
|
||||||
.safeAreaPadding(.top, 10)
|
.safeAreaPadding(.top, 10)
|
||||||
}
|
}
|
||||||
@@ -62,6 +69,21 @@ struct RootTabs: View {
|
|||||||
self.toastDismissTask?.cancel()
|
self.toastDismissTask?.cancel()
|
||||||
self.toastDismissTask = nil
|
self.toastDismissTask = nil
|
||||||
}
|
}
|
||||||
|
.confirmationDialog(
|
||||||
|
"Gateway",
|
||||||
|
isPresented: self.$showGatewayActions,
|
||||||
|
titleVisibility: .visible)
|
||||||
|
{
|
||||||
|
Button("Disconnect", role: .destructive) {
|
||||||
|
self.appModel.disconnectGateway()
|
||||||
|
}
|
||||||
|
Button("Open Settings") {
|
||||||
|
self.selectedTab = 2
|
||||||
|
}
|
||||||
|
Button("Cancel", role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text("Disconnect from the gateway?")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var gatewayStatus: StatusPill.GatewayState {
|
private var gatewayStatus: StatusPill.GatewayState {
|
||||||
@@ -133,6 +155,10 @@ struct RootTabs: View {
|
|||||||
return StatusPill.Activity(title: "Mic permission", systemImage: "mic.slash", tint: .orange)
|
return StatusPill.Activity(title: "Mic permission", systemImage: "mic.slash", tint: .orange)
|
||||||
}
|
}
|
||||||
if voiceStatus == "Paused" {
|
if voiceStatus == "Paused" {
|
||||||
|
// Talk mode intentionally pauses voice wake to release the mic. Don't spam the HUD for that case.
|
||||||
|
if self.appModel.talkMode.isEnabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
let suffix = self.appModel.isBackgrounded ? " (background)" : ""
|
let suffix = self.appModel.isBackgrounded ? " (background)" : ""
|
||||||
return StatusPill.Activity(title: "Voice Wake paused\(suffix)", systemImage: "pause.circle.fill")
|
return StatusPill.Activity(title: "Voice Wake paused\(suffix)", systemImage: "pause.circle.fill")
|
||||||
}
|
}
|
||||||
|
|||||||
7
apps/ios/Sources/RootView.swift
Normal file
7
apps/ios/Sources/RootView.swift
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct RootView: View {
|
||||||
|
var body: some View {
|
||||||
|
RootCanvas()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,6 +52,20 @@ final class ScreenController {
|
|||||||
|
|
||||||
func navigate(to urlString: String) {
|
func navigate(to urlString: String) {
|
||||||
let trimmed = urlString.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = urlString.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if trimmed.isEmpty {
|
||||||
|
self.urlString = ""
|
||||||
|
self.reload()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let url = URL(string: trimmed),
|
||||||
|
!url.isFileURL,
|
||||||
|
let host = url.host,
|
||||||
|
Self.isLoopbackHost(host)
|
||||||
|
{
|
||||||
|
// Never try to load loopback URLs from a remote gateway.
|
||||||
|
self.showDefaultCanvas()
|
||||||
|
return
|
||||||
|
}
|
||||||
self.urlString = (trimmed == "/" ? "" : trimmed)
|
self.urlString = (trimmed == "/" ? "" : trimmed)
|
||||||
self.reload()
|
self.reload()
|
||||||
}
|
}
|
||||||
@@ -239,6 +253,18 @@ final class ScreenController {
|
|||||||
name: "scaffold",
|
name: "scaffold",
|
||||||
ext: "html",
|
ext: "html",
|
||||||
subdirectory: "CanvasScaffold")
|
subdirectory: "CanvasScaffold")
|
||||||
|
|
||||||
|
private static func isLoopbackHost(_ host: String) -> Bool {
|
||||||
|
let normalized = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
if normalized.isEmpty { return true }
|
||||||
|
if normalized == "localhost" || normalized == "::1" || normalized == "0.0.0.0" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if normalized == "127.0.0.1" || normalized.hasPrefix("127.") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
func isTrustedCanvasUIURL(_ url: URL) -> Bool {
|
func isTrustedCanvasUIURL(_ url: URL) -> Bool {
|
||||||
guard url.isFileURL else { return false }
|
guard url.isFileURL else { return false }
|
||||||
let std = url.standardizedFileURL
|
let std = url.standardizedFileURL
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ struct ScreenTab: View {
|
|||||||
ScreenWebView(controller: self.appModel.screen)
|
ScreenWebView(controller: self.appModel.screen)
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
.overlay(alignment: .top) {
|
.overlay(alignment: .top) {
|
||||||
if let errorText = self.appModel.screen.errorText {
|
if let errorText = self.appModel.screen.errorText,
|
||||||
|
self.appModel.gatewayServerName == nil
|
||||||
|
{
|
||||||
Text(errorText)
|
Text(errorText)
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.padding(10)
|
.padding(10)
|
||||||
|
|||||||
64
apps/ios/Sources/Services/NodeServiceProtocols.swift
Normal file
64
apps/ios/Sources/Services/NodeServiceProtocols.swift
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import CoreLocation
|
||||||
|
import Foundation
|
||||||
|
import OpenClawKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
protocol CameraServicing: Sendable {
|
||||||
|
func listDevices() async -> [CameraController.CameraDeviceInfo]
|
||||||
|
func snap(params: OpenClawCameraSnapParams) async throws -> (format: String, base64: String, width: Int, height: Int)
|
||||||
|
func clip(params: OpenClawCameraClipParams) async throws -> (format: String, base64: String, durationMs: Int, hasAudio: Bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol ScreenRecordingServicing: Sendable {
|
||||||
|
func record(
|
||||||
|
screenIndex: Int?,
|
||||||
|
durationMs: Int?,
|
||||||
|
fps: Double?,
|
||||||
|
includeAudio: Bool?,
|
||||||
|
outPath: String?) async throws -> String
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
protocol LocationServicing: Sendable {
|
||||||
|
func authorizationStatus() -> CLAuthorizationStatus
|
||||||
|
func accuracyAuthorization() -> CLAccuracyAuthorization
|
||||||
|
func ensureAuthorization(mode: OpenClawLocationMode) async -> CLAuthorizationStatus
|
||||||
|
func currentLocation(
|
||||||
|
params: OpenClawLocationGetParams,
|
||||||
|
desiredAccuracy: OpenClawLocationAccuracy,
|
||||||
|
maxAgeMs: Int?,
|
||||||
|
timeoutMs: Int?) async throws -> CLLocation
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol DeviceStatusServicing: Sendable {
|
||||||
|
func status() async throws -> OpenClawDeviceStatusPayload
|
||||||
|
func info() -> OpenClawDeviceInfoPayload
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol PhotosServicing: Sendable {
|
||||||
|
func latest(params: OpenClawPhotosLatestParams) async throws -> OpenClawPhotosLatestPayload
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol ContactsServicing: Sendable {
|
||||||
|
func search(params: OpenClawContactsSearchParams) async throws -> OpenClawContactsSearchPayload
|
||||||
|
func add(params: OpenClawContactsAddParams) async throws -> OpenClawContactsAddPayload
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol CalendarServicing: Sendable {
|
||||||
|
func events(params: OpenClawCalendarEventsParams) async throws -> OpenClawCalendarEventsPayload
|
||||||
|
func add(params: OpenClawCalendarAddParams) async throws -> OpenClawCalendarAddPayload
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol RemindersServicing: Sendable {
|
||||||
|
func list(params: OpenClawRemindersListParams) async throws -> OpenClawRemindersListPayload
|
||||||
|
func add(params: OpenClawRemindersAddParams) async throws -> OpenClawRemindersAddPayload
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol MotionServicing: Sendable {
|
||||||
|
func activities(params: OpenClawMotionActivityParams) async throws -> OpenClawMotionActivityPayload
|
||||||
|
func pedometer(params: OpenClawPedometerParams) async throws -> OpenClawPedometerPayload
|
||||||
|
}
|
||||||
|
|
||||||
|
extension CameraController: CameraServicing {}
|
||||||
|
extension ScreenRecordService: ScreenRecordingServicing {}
|
||||||
|
extension LocationService: LocationServicing {}
|
||||||
58
apps/ios/Sources/Services/NotificationService.swift
Normal file
58
apps/ios/Sources/Services/NotificationService.swift
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import Foundation
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
|
enum NotificationAuthorizationStatus: Sendable {
|
||||||
|
case notDetermined
|
||||||
|
case denied
|
||||||
|
case authorized
|
||||||
|
case provisional
|
||||||
|
case ephemeral
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol NotificationCentering: Sendable {
|
||||||
|
func authorizationStatus() async -> NotificationAuthorizationStatus
|
||||||
|
func requestAuthorization(options: UNAuthorizationOptions) async throws -> Bool
|
||||||
|
func add(_ request: UNNotificationRequest) async throws
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LiveNotificationCenter: NotificationCentering, @unchecked Sendable {
|
||||||
|
private let center: UNUserNotificationCenter
|
||||||
|
|
||||||
|
init(center: UNUserNotificationCenter = .current()) {
|
||||||
|
self.center = center
|
||||||
|
}
|
||||||
|
|
||||||
|
func authorizationStatus() async -> NotificationAuthorizationStatus {
|
||||||
|
let settings = await self.center.notificationSettings()
|
||||||
|
return switch settings.authorizationStatus {
|
||||||
|
case .authorized:
|
||||||
|
.authorized
|
||||||
|
case .provisional:
|
||||||
|
.provisional
|
||||||
|
case .ephemeral:
|
||||||
|
.ephemeral
|
||||||
|
case .denied:
|
||||||
|
.denied
|
||||||
|
case .notDetermined:
|
||||||
|
.notDetermined
|
||||||
|
@unknown default:
|
||||||
|
.denied
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestAuthorization(options: UNAuthorizationOptions) async throws -> Bool {
|
||||||
|
try await self.center.requestAuthorization(options: options)
|
||||||
|
}
|
||||||
|
|
||||||
|
func add(_ request: UNNotificationRequest) async throws {
|
||||||
|
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
|
||||||
|
self.center.add(request) { error in
|
||||||
|
if let error {
|
||||||
|
cont.resume(throwing: error)
|
||||||
|
} else {
|
||||||
|
cont.resume(returning: ())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,14 @@ enum SessionKey {
|
|||||||
return trimmed.isEmpty ? "main" : trimmed
|
return trimmed.isEmpty ? "main" : trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func makeAgentSessionKey(agentId: String, baseKey: String) -> String {
|
||||||
|
let trimmedAgent = agentId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let trimmedBase = baseKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if trimmedAgent.isEmpty { return trimmedBase.isEmpty ? "main" : trimmedBase }
|
||||||
|
let normalizedBase = trimmedBase.isEmpty ? "main" : trimmedBase
|
||||||
|
return "agent:\(trimmedAgent):\(normalizedBase)"
|
||||||
|
}
|
||||||
|
|
||||||
static func isCanonicalMainSessionKey(_ value: String?) -> Bool {
|
static func isCanonicalMainSessionKey(_ value: String?) -> Bool {
|
||||||
let trimmed = (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if trimmed.isEmpty { return false }
|
if trimmed.isEmpty { return false }
|
||||||
|
|||||||
@@ -1,17 +1,10 @@
|
|||||||
import OpenClawKit
|
import OpenClawKit
|
||||||
import Network
|
import Network
|
||||||
import Observation
|
import Observation
|
||||||
|
import os
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Observable
|
|
||||||
private final class ConnectStatusStore {
|
|
||||||
var text: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
extension ConnectStatusStore: @unchecked Sendable {}
|
|
||||||
|
|
||||||
struct SettingsTab: View {
|
struct SettingsTab: View {
|
||||||
@Environment(NodeAppModel.self) private var appModel: NodeAppModel
|
@Environment(NodeAppModel.self) private var appModel: NodeAppModel
|
||||||
@Environment(VoiceWakeManager.self) private var voiceWake: VoiceWakeManager
|
@Environment(VoiceWakeManager.self) private var voiceWake: VoiceWakeManager
|
||||||
@@ -28,99 +21,140 @@ struct SettingsTab: View {
|
|||||||
@AppStorage("screen.preventSleep") private var preventSleep: Bool = true
|
@AppStorage("screen.preventSleep") private var preventSleep: Bool = true
|
||||||
@AppStorage("gateway.preferredStableID") private var preferredGatewayStableID: String = ""
|
@AppStorage("gateway.preferredStableID") private var preferredGatewayStableID: String = ""
|
||||||
@AppStorage("gateway.lastDiscoveredStableID") private var lastDiscoveredGatewayStableID: String = ""
|
@AppStorage("gateway.lastDiscoveredStableID") private var lastDiscoveredGatewayStableID: String = ""
|
||||||
|
@AppStorage("gateway.autoconnect") private var gatewayAutoConnect: Bool = false
|
||||||
@AppStorage("gateway.manual.enabled") private var manualGatewayEnabled: Bool = false
|
@AppStorage("gateway.manual.enabled") private var manualGatewayEnabled: Bool = false
|
||||||
@AppStorage("gateway.manual.host") private var manualGatewayHost: String = ""
|
@AppStorage("gateway.manual.host") private var manualGatewayHost: String = ""
|
||||||
@AppStorage("gateway.manual.port") private var manualGatewayPort: Int = 18789
|
@AppStorage("gateway.manual.port") private var manualGatewayPort: Int = 18789
|
||||||
@AppStorage("gateway.manual.tls") private var manualGatewayTLS: Bool = true
|
@AppStorage("gateway.manual.tls") private var manualGatewayTLS: Bool = true
|
||||||
@AppStorage("gateway.discovery.debugLogs") private var discoveryDebugLogsEnabled: Bool = false
|
@AppStorage("gateway.discovery.debugLogs") private var discoveryDebugLogsEnabled: Bool = false
|
||||||
@AppStorage("canvas.debugStatusEnabled") private var canvasDebugStatusEnabled: Bool = false
|
@AppStorage("canvas.debugStatusEnabled") private var canvasDebugStatusEnabled: Bool = false
|
||||||
@State private var connectStatus = ConnectStatusStore()
|
|
||||||
@State private var connectingGatewayID: String?
|
@State private var connectingGatewayID: String?
|
||||||
@State private var localIPAddress: String?
|
@State private var localIPAddress: String?
|
||||||
@State private var lastLocationModeRaw: String = OpenClawLocationMode.off.rawValue
|
@State private var lastLocationModeRaw: String = OpenClawLocationMode.off.rawValue
|
||||||
@State private var gatewayToken: String = ""
|
@State private var gatewayToken: String = ""
|
||||||
@State private var gatewayPassword: String = ""
|
@State private var gatewayPassword: String = ""
|
||||||
|
@AppStorage("gateway.setupCode") private var setupCode: String = ""
|
||||||
|
@State private var setupStatusText: String?
|
||||||
|
@State private var manualGatewayPortText: String = ""
|
||||||
|
@State private var gatewayExpanded: Bool = true
|
||||||
|
@State private var selectedAgentPickerId: String = ""
|
||||||
|
|
||||||
|
private let gatewayLogger = Logger(subsystem: "ai.openclaw.ios", category: "GatewaySettings")
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
Form {
|
Form {
|
||||||
Section("Node") {
|
Section {
|
||||||
TextField("Name", text: self.$displayName)
|
DisclosureGroup(isExpanded: self.$gatewayExpanded) {
|
||||||
Text(self.instanceId)
|
if !self.isGatewayConnected {
|
||||||
.font(.footnote)
|
Text(
|
||||||
.foregroundStyle(.secondary)
|
"1. Open Telegram and message your bot: /pair\n"
|
||||||
LabeledContent("IP", value: self.localIPAddress ?? "—")
|
+ "2. Copy the setup code it returns\n"
|
||||||
.contextMenu {
|
+ "3. Paste here and tap Connect\n"
|
||||||
if let ip = self.localIPAddress {
|
+ "4. Back in Telegram, run /pair approve")
|
||||||
Button {
|
.font(.footnote)
|
||||||
UIPasteboard.general.string = ip
|
.foregroundStyle(.secondary)
|
||||||
} label: {
|
|
||||||
Label("Copy", systemImage: "doc.on.doc")
|
if let warning = self.tailnetWarningText {
|
||||||
|
Text(warning)
|
||||||
|
.font(.footnote.weight(.semibold))
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField("Paste setup code", text: self.$setupCode)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
|
||||||
|
Button {
|
||||||
|
Task { await self.applySetupCodeAndConnect() }
|
||||||
|
} label: {
|
||||||
|
if self.connectingGatewayID == "manual" {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(.circular)
|
||||||
|
Text("Connecting…")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text("Connect with setup code")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.disabled(self.connectingGatewayID != nil
|
||||||
|
|| self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||||
|
|
||||||
|
if let status = self.setupStatusLine {
|
||||||
|
Text(status)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
LabeledContent("Platform", value: self.platformString())
|
|
||||||
LabeledContent("Version", value: self.appVersion())
|
|
||||||
LabeledContent("Model", value: self.modelIdentifier())
|
|
||||||
}
|
|
||||||
|
|
||||||
Section("Gateway") {
|
if self.isGatewayConnected {
|
||||||
LabeledContent("Discovery", value: self.gatewayController.discoveryStatusText)
|
Picker("Bot", selection: self.$selectedAgentPickerId) {
|
||||||
LabeledContent("Status", value: self.appModel.gatewayStatusText)
|
Text("Default").tag("")
|
||||||
if let serverName = self.appModel.gatewayServerName {
|
let defaultId = (self.appModel.gatewayDefaultAgentId ?? "")
|
||||||
LabeledContent("Server", value: serverName)
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if let addr = self.appModel.gatewayRemoteAddress {
|
ForEach(self.appModel.gatewayAgents.filter { $0.id != defaultId }, id: \.id) { agent in
|
||||||
let parts = Self.parseHostPort(from: addr)
|
let name = (agent.name ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let urlString = Self.httpURLString(host: parts?.host, port: parts?.port, fallback: addr)
|
Text(name.isEmpty ? agent.id : name).tag(agent.id)
|
||||||
LabeledContent("Address") {
|
|
||||||
Text(urlString)
|
|
||||||
}
|
|
||||||
.contextMenu {
|
|
||||||
Button {
|
|
||||||
UIPasteboard.general.string = urlString
|
|
||||||
} label: {
|
|
||||||
Label("Copy URL", systemImage: "doc.on.doc")
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
Text("Controls which bot Chat and Talk speak to.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
if let parts {
|
DisclosureGroup("Advanced") {
|
||||||
|
if self.appModel.gatewayServerName == nil {
|
||||||
|
LabeledContent("Discovery", value: self.gatewayController.discoveryStatusText)
|
||||||
|
}
|
||||||
|
LabeledContent("Status", value: self.appModel.gatewayStatusText)
|
||||||
|
Toggle("Auto-connect on launch", isOn: self.$gatewayAutoConnect)
|
||||||
|
|
||||||
|
if let serverName = self.appModel.gatewayServerName {
|
||||||
|
LabeledContent("Server", value: serverName)
|
||||||
|
if let addr = self.appModel.gatewayRemoteAddress {
|
||||||
|
let parts = Self.parseHostPort(from: addr)
|
||||||
|
let urlString = Self.httpURLString(host: parts?.host, port: parts?.port, fallback: addr)
|
||||||
|
LabeledContent("Address") {
|
||||||
|
Text(urlString)
|
||||||
|
}
|
||||||
|
.contextMenu {
|
||||||
Button {
|
Button {
|
||||||
UIPasteboard.general.string = parts.host
|
UIPasteboard.general.string = urlString
|
||||||
} label: {
|
} label: {
|
||||||
Label("Copy Host", systemImage: "doc.on.doc")
|
Label("Copy URL", systemImage: "doc.on.doc")
|
||||||
}
|
}
|
||||||
|
|
||||||
Button {
|
if let parts {
|
||||||
UIPasteboard.general.string = "\(parts.port)"
|
Button {
|
||||||
} label: {
|
UIPasteboard.general.string = parts.host
|
||||||
Label("Copy Port", systemImage: "doc.on.doc")
|
} label: {
|
||||||
|
Label("Copy Host", systemImage: "doc.on.doc")
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
UIPasteboard.general.string = "\(parts.port)"
|
||||||
|
} label: {
|
||||||
|
Label("Copy Port", systemImage: "doc.on.doc")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Button("Disconnect", role: .destructive) {
|
||||||
|
self.appModel.disconnectGateway()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.gatewayList(showing: .all)
|
||||||
}
|
}
|
||||||
|
|
||||||
Button("Disconnect", role: .destructive) {
|
|
||||||
self.appModel.disconnectGateway()
|
|
||||||
}
|
|
||||||
|
|
||||||
self.gatewayList(showing: .availableOnly)
|
|
||||||
} else {
|
|
||||||
self.gatewayList(showing: .all)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let text = self.connectStatus.text {
|
|
||||||
Text(text)
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
DisclosureGroup("Advanced") {
|
|
||||||
Toggle("Use Manual Gateway", isOn: self.$manualGatewayEnabled)
|
Toggle("Use Manual Gateway", isOn: self.$manualGatewayEnabled)
|
||||||
|
|
||||||
TextField("Host", text: self.$manualGatewayHost)
|
TextField("Host", text: self.$manualGatewayHost)
|
||||||
.textInputAutocapitalization(.never)
|
.textInputAutocapitalization(.never)
|
||||||
.autocorrectionDisabled()
|
.autocorrectionDisabled()
|
||||||
|
|
||||||
TextField("Port", value: self.$manualGatewayPort, format: .number)
|
TextField("Port (optional)", text: self.manualPortBinding)
|
||||||
.keyboardType(.numberPad)
|
.keyboardType(.numberPad)
|
||||||
|
|
||||||
Toggle("Use TLS", isOn: self.$manualGatewayTLS)
|
Toggle("Use TLS", isOn: self.$manualGatewayTLS)
|
||||||
@@ -140,11 +174,11 @@ struct SettingsTab: View {
|
|||||||
}
|
}
|
||||||
.disabled(self.connectingGatewayID != nil || self.manualGatewayHost
|
.disabled(self.connectingGatewayID != nil || self.manualGatewayHost
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
.isEmpty || self.manualGatewayPort <= 0 || self.manualGatewayPort > 65535)
|
.isEmpty || !self.manualPortIsValid)
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
"Use this when mDNS/Bonjour discovery is blocked. "
|
"Use this when mDNS/Bonjour discovery is blocked. "
|
||||||
+ "The gateway WebSocket listens on port 18789 by default.")
|
+ "Leave port empty for 443 on tailnet DNS (TLS) or 18789 otherwise.")
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
@@ -164,58 +198,98 @@ struct SettingsTab: View {
|
|||||||
.autocorrectionDisabled()
|
.autocorrectionDisabled()
|
||||||
|
|
||||||
SecureField("Gateway Password", text: self.$gatewayPassword)
|
SecureField("Gateway Password", text: self.$gatewayPassword)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("Debug")
|
||||||
|
.font(.footnote.weight(.semibold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(self.gatewayDebugText())
|
||||||
|
.font(.system(size: 12, weight: .regular, design: .monospaced))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(10)
|
||||||
|
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Section("Voice") {
|
|
||||||
Toggle("Voice Wake", isOn: self.$voiceWakeEnabled)
|
|
||||||
.onChange(of: self.voiceWakeEnabled) { _, newValue in
|
|
||||||
self.appModel.setVoiceWakeEnabled(newValue)
|
|
||||||
}
|
|
||||||
Toggle("Talk Mode", isOn: self.$talkEnabled)
|
|
||||||
.onChange(of: self.talkEnabled) { _, newValue in
|
|
||||||
self.appModel.setTalkEnabled(newValue)
|
|
||||||
}
|
|
||||||
// Keep this separate so users can hide the side bubble without disabling Talk Mode.
|
|
||||||
Toggle("Show Talk Button", isOn: self.$talkButtonEnabled)
|
|
||||||
|
|
||||||
NavigationLink {
|
|
||||||
VoiceWakeWordsSettingsView()
|
|
||||||
} label: {
|
} label: {
|
||||||
LabeledContent(
|
HStack(spacing: 10) {
|
||||||
"Wake Words",
|
Circle()
|
||||||
value: VoiceWakePreferences.displayString(for: self.voiceWake.triggerWords))
|
.fill(self.isGatewayConnected ? Color.green : Color.secondary.opacity(0.35))
|
||||||
|
.frame(width: 10, height: 10)
|
||||||
|
Text("Gateway")
|
||||||
|
Spacer()
|
||||||
|
Text(self.gatewaySummaryText)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Section("Camera") {
|
Section("Device") {
|
||||||
Toggle("Allow Camera", isOn: self.$cameraEnabled)
|
DisclosureGroup("Features") {
|
||||||
Text("Allows the gateway to request photos or short video clips (foreground only).")
|
Toggle("Voice Wake", isOn: self.$voiceWakeEnabled)
|
||||||
.font(.footnote)
|
.onChange(of: self.voiceWakeEnabled) { _, newValue in
|
||||||
.foregroundStyle(.secondary)
|
self.appModel.setVoiceWakeEnabled(newValue)
|
||||||
}
|
}
|
||||||
|
Toggle("Talk Mode", isOn: self.$talkEnabled)
|
||||||
|
.onChange(of: self.talkEnabled) { _, newValue in
|
||||||
|
self.appModel.setTalkEnabled(newValue)
|
||||||
|
}
|
||||||
|
// Keep this separate so users can hide the side bubble without disabling Talk Mode.
|
||||||
|
Toggle("Show Talk Button", isOn: self.$talkButtonEnabled)
|
||||||
|
|
||||||
Section("Location") {
|
NavigationLink {
|
||||||
Picker("Location Access", selection: self.$locationEnabledModeRaw) {
|
VoiceWakeWordsSettingsView()
|
||||||
Text("Off").tag(OpenClawLocationMode.off.rawValue)
|
} label: {
|
||||||
Text("While Using").tag(OpenClawLocationMode.whileUsing.rawValue)
|
LabeledContent(
|
||||||
Text("Always").tag(OpenClawLocationMode.always.rawValue)
|
"Wake Words",
|
||||||
|
value: VoiceWakePreferences.displayString(for: self.voiceWake.triggerWords))
|
||||||
|
}
|
||||||
|
|
||||||
|
Toggle("Allow Camera", isOn: self.$cameraEnabled)
|
||||||
|
Text("Allows the gateway to request photos or short video clips (foreground only).")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Picker("Location Access", selection: self.$locationEnabledModeRaw) {
|
||||||
|
Text("Off").tag(OpenClawLocationMode.off.rawValue)
|
||||||
|
Text("While Using").tag(OpenClawLocationMode.whileUsing.rawValue)
|
||||||
|
Text("Always").tag(OpenClawLocationMode.always.rawValue)
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
|
||||||
|
Toggle("Precise Location", isOn: self.$locationPreciseEnabled)
|
||||||
|
.disabled(self.locationMode == .off)
|
||||||
|
|
||||||
|
Text("Always requires system permission and may prompt to open Settings.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Toggle("Prevent Sleep", isOn: self.$preventSleep)
|
||||||
|
Text("Keeps the screen awake while OpenClaw is open.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
.pickerStyle(.segmented)
|
|
||||||
|
|
||||||
Toggle("Precise Location", isOn: self.$locationPreciseEnabled)
|
DisclosureGroup("Device Info") {
|
||||||
.disabled(self.locationMode == .off)
|
TextField("Name", text: self.$displayName)
|
||||||
|
Text(self.instanceId)
|
||||||
Text("Always requires system permission and may prompt to open Settings.")
|
.font(.footnote)
|
||||||
.font(.footnote)
|
.foregroundStyle(.secondary)
|
||||||
.foregroundStyle(.secondary)
|
LabeledContent("IP", value: self.localIPAddress ?? "—")
|
||||||
}
|
.contextMenu {
|
||||||
|
if let ip = self.localIPAddress {
|
||||||
Section("Screen") {
|
Button {
|
||||||
Toggle("Prevent Sleep", isOn: self.$preventSleep)
|
UIPasteboard.general.string = ip
|
||||||
Text("Keeps the screen awake while OpenClaw is open.")
|
} label: {
|
||||||
.font(.footnote)
|
Label("Copy", systemImage: "doc.on.doc")
|
||||||
.foregroundStyle(.secondary)
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LabeledContent("Platform", value: self.platformString())
|
||||||
|
LabeledContent("Version", value: self.appVersion())
|
||||||
|
LabeledContent("Model", value: self.modelIdentifier())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Settings")
|
.navigationTitle("Settings")
|
||||||
@@ -232,11 +306,24 @@ struct SettingsTab: View {
|
|||||||
.onAppear {
|
.onAppear {
|
||||||
self.localIPAddress = Self.primaryIPv4Address()
|
self.localIPAddress = Self.primaryIPv4Address()
|
||||||
self.lastLocationModeRaw = self.locationEnabledModeRaw
|
self.lastLocationModeRaw = self.locationEnabledModeRaw
|
||||||
|
self.syncManualPortText()
|
||||||
let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if !trimmedInstanceId.isEmpty {
|
if !trimmedInstanceId.isEmpty {
|
||||||
self.gatewayToken = GatewaySettingsStore.loadGatewayToken(instanceId: trimmedInstanceId) ?? ""
|
self.gatewayToken = GatewaySettingsStore.loadGatewayToken(instanceId: trimmedInstanceId) ?? ""
|
||||||
self.gatewayPassword = GatewaySettingsStore.loadGatewayPassword(instanceId: trimmedInstanceId) ?? ""
|
self.gatewayPassword = GatewaySettingsStore.loadGatewayPassword(instanceId: trimmedInstanceId) ?? ""
|
||||||
}
|
}
|
||||||
|
// Keep setup front-and-center when disconnected; keep things compact once connected.
|
||||||
|
self.gatewayExpanded = !self.isGatewayConnected
|
||||||
|
self.selectedAgentPickerId = self.appModel.selectedAgentId ?? ""
|
||||||
|
}
|
||||||
|
.onChange(of: self.selectedAgentPickerId) { _, newValue in
|
||||||
|
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
self.appModel.setSelectedAgentId(trimmed.isEmpty ? nil : trimmed)
|
||||||
|
}
|
||||||
|
.onChange(of: self.appModel.selectedAgentId ?? "") { _, newValue in
|
||||||
|
if newValue != self.selectedAgentPickerId {
|
||||||
|
self.selectedAgentPickerId = newValue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: self.preferredGatewayStableID) { _, newValue in
|
.onChange(of: self.preferredGatewayStableID) { _, newValue in
|
||||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
@@ -255,8 +342,24 @@ struct SettingsTab: View {
|
|||||||
guard !instanceId.isEmpty else { return }
|
guard !instanceId.isEmpty else { return }
|
||||||
GatewaySettingsStore.saveGatewayPassword(trimmed, instanceId: instanceId)
|
GatewaySettingsStore.saveGatewayPassword(trimmed, instanceId: instanceId)
|
||||||
}
|
}
|
||||||
.onChange(of: self.appModel.gatewayServerName) { _, _ in
|
.onChange(of: self.manualGatewayPort) { _, _ in
|
||||||
self.connectStatus.text = nil
|
self.syncManualPortText()
|
||||||
|
}
|
||||||
|
.onChange(of: self.appModel.gatewayServerName) { _, newValue in
|
||||||
|
if newValue != nil {
|
||||||
|
self.setupCode = ""
|
||||||
|
self.setupStatusText = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if self.manualGatewayEnabled {
|
||||||
|
self.setupStatusText = self.appModel.gatewayStatusText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: self.appModel.gatewayStatusText) { _, newValue in
|
||||||
|
guard self.manualGatewayEnabled || self.connectingGatewayID == "manual" else { return }
|
||||||
|
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return }
|
||||||
|
self.setupStatusText = trimmed
|
||||||
}
|
}
|
||||||
.onChange(of: self.locationEnabledModeRaw) { _, newValue in
|
.onChange(of: self.locationEnabledModeRaw) { _, newValue in
|
||||||
let previous = self.lastLocationModeRaw
|
let previous = self.lastLocationModeRaw
|
||||||
@@ -278,8 +381,24 @@ struct SettingsTab: View {
|
|||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func gatewayList(showing: GatewayListMode) -> some View {
|
private func gatewayList(showing: GatewayListMode) -> some View {
|
||||||
if self.gatewayController.gateways.isEmpty {
|
if self.gatewayController.gateways.isEmpty {
|
||||||
Text("No gateways found yet.")
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
.foregroundStyle(.secondary)
|
Text("No gateways found yet.")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("If your gateway is on another network, connect it and ensure DNS is working.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
if let lastKnown = GatewaySettingsStore.loadLastGatewayConnection() {
|
||||||
|
Button {
|
||||||
|
Task { await self.connectLastKnown() }
|
||||||
|
} label: {
|
||||||
|
self.lastKnownButtonLabel(host: lastKnown.host, port: lastKnown.port)
|
||||||
|
}
|
||||||
|
.disabled(self.connectingGatewayID != nil)
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(self.appModel.seamColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
let connectedID = self.appModel.connectedGatewayID
|
let connectedID = self.appModel.connectedGatewayID
|
||||||
let rows = self.gatewayController.gateways.filter { gateway in
|
let rows = self.gatewayController.gateways.filter { gateway in
|
||||||
@@ -331,6 +450,20 @@ struct SettingsTab: View {
|
|||||||
case availableOnly
|
case availableOnly
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var isGatewayConnected: Bool {
|
||||||
|
let status = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
if status.contains("connected") { return true }
|
||||||
|
return self.appModel.gatewayServerName != nil && !status.contains("offline")
|
||||||
|
}
|
||||||
|
|
||||||
|
private var gatewaySummaryText: String {
|
||||||
|
if let server = self.appModel.gatewayServerName, self.isGatewayConnected {
|
||||||
|
return server
|
||||||
|
}
|
||||||
|
let trimmed = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return trimmed.isEmpty ? "Not connected" : trimmed
|
||||||
|
}
|
||||||
|
|
||||||
private func platformString() -> String {
|
private func platformString() -> String {
|
||||||
let v = ProcessInfo.processInfo.operatingSystemVersion
|
let v = ProcessInfo.processInfo.operatingSystemVersion
|
||||||
return "iOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
|
return "iOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
|
||||||
@@ -377,14 +510,290 @@ struct SettingsTab: View {
|
|||||||
await self.gatewayController.connect(gateway)
|
await self.gatewayController.connect(gateway)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func connectLastKnown() async {
|
||||||
|
self.connectingGatewayID = "last-known"
|
||||||
|
defer { self.connectingGatewayID = nil }
|
||||||
|
await self.gatewayController.connectLastKnown()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func gatewayDebugText() -> String {
|
||||||
|
var lines: [String] = [
|
||||||
|
"gateway: \(self.appModel.gatewayStatusText)",
|
||||||
|
"discovery: \(self.gatewayController.discoveryStatusText)",
|
||||||
|
]
|
||||||
|
lines.append("server: \(self.appModel.gatewayServerName ?? "—")")
|
||||||
|
lines.append("address: \(self.appModel.gatewayRemoteAddress ?? "—")")
|
||||||
|
if let last = self.gatewayController.discoveryDebugLog.last?.message {
|
||||||
|
lines.append("discovery log: \(last)")
|
||||||
|
}
|
||||||
|
return lines.joined(separator: "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func lastKnownButtonLabel(host: String, port: Int) -> some View {
|
||||||
|
if self.connectingGatewayID == "last-known" {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(.circular)
|
||||||
|
Text("Connecting…")
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
} else {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "bolt.horizontal.circle.fill")
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Connect last known")
|
||||||
|
Text("\(host):\(port)")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var manualPortBinding: Binding<String> {
|
||||||
|
Binding(
|
||||||
|
get: { self.manualGatewayPortText },
|
||||||
|
set: { newValue in
|
||||||
|
let filtered = newValue.filter(\.isNumber)
|
||||||
|
if self.manualGatewayPortText != filtered {
|
||||||
|
self.manualGatewayPortText = filtered
|
||||||
|
}
|
||||||
|
if filtered.isEmpty {
|
||||||
|
if self.manualGatewayPort != 0 {
|
||||||
|
self.manualGatewayPort = 0
|
||||||
|
}
|
||||||
|
} else if let port = Int(filtered), self.manualGatewayPort != port {
|
||||||
|
self.manualGatewayPort = port
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private var manualPortIsValid: Bool {
|
||||||
|
if self.manualGatewayPortText.isEmpty { return true }
|
||||||
|
return self.manualGatewayPort >= 1 && self.manualGatewayPort <= 65535
|
||||||
|
}
|
||||||
|
|
||||||
|
private func syncManualPortText() {
|
||||||
|
if self.manualGatewayPort > 0 {
|
||||||
|
let next = String(self.manualGatewayPort)
|
||||||
|
if self.manualGatewayPortText != next {
|
||||||
|
self.manualGatewayPortText = next
|
||||||
|
}
|
||||||
|
} else if !self.manualGatewayPortText.isEmpty {
|
||||||
|
self.manualGatewayPortText = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct SetupPayload: Codable {
|
||||||
|
var url: String?
|
||||||
|
var host: String?
|
||||||
|
var port: Int?
|
||||||
|
var tls: Bool?
|
||||||
|
var token: String?
|
||||||
|
var password: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applySetupCodeAndConnect() async {
|
||||||
|
self.setupStatusText = nil
|
||||||
|
guard self.applySetupCode() else { return }
|
||||||
|
let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let resolvedPort = self.resolvedManualPort(host: host)
|
||||||
|
let hasToken = !self.gatewayToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
let hasPassword = !self.gatewayPassword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
GatewayDiagnostics.log(
|
||||||
|
"setup code applied host=\(host) port=\(resolvedPort ?? -1) tls=\(self.manualGatewayTLS) token=\(hasToken) password=\(hasPassword)")
|
||||||
|
guard let port = resolvedPort else {
|
||||||
|
self.setupStatusText = "Failed: invalid port"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let ok = await self.preflightGateway(host: host, port: port, useTLS: self.manualGatewayTLS)
|
||||||
|
guard ok else { return }
|
||||||
|
self.setupStatusText = "Setup code applied. Connecting…"
|
||||||
|
await self.connectManual()
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
private func applySetupCode() -> Bool {
|
||||||
|
let raw = self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !raw.isEmpty else {
|
||||||
|
self.setupStatusText = "Paste a setup code to continue."
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let payload = self.decodeSetupPayload(raw: raw) else {
|
||||||
|
self.setupStatusText = "Setup code not recognized."
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if let urlString = payload.url, let url = URL(string: urlString) {
|
||||||
|
self.applySetupURL(url)
|
||||||
|
} else if let host = payload.host, !host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
self.manualGatewayHost = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if let port = payload.port {
|
||||||
|
self.manualGatewayPort = port
|
||||||
|
self.manualGatewayPortText = String(port)
|
||||||
|
} else {
|
||||||
|
self.manualGatewayPort = 0
|
||||||
|
self.manualGatewayPortText = ""
|
||||||
|
}
|
||||||
|
if let tls = payload.tls {
|
||||||
|
self.manualGatewayTLS = tls
|
||||||
|
}
|
||||||
|
} else if let url = URL(string: raw), url.scheme != nil {
|
||||||
|
self.applySetupURL(url)
|
||||||
|
} else {
|
||||||
|
self.setupStatusText = "Setup code missing URL or host."
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if let token = payload.token, !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
let trimmedToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
self.gatewayToken = trimmedToken
|
||||||
|
if !trimmedInstanceId.isEmpty {
|
||||||
|
GatewaySettingsStore.saveGatewayToken(trimmedToken, instanceId: trimmedInstanceId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let password = payload.password, !password.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
let trimmedPassword = password.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
self.gatewayPassword = trimmedPassword
|
||||||
|
if !trimmedInstanceId.isEmpty {
|
||||||
|
GatewaySettingsStore.saveGatewayPassword(trimmedPassword, instanceId: trimmedInstanceId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applySetupURL(_ url: URL) {
|
||||||
|
guard let host = url.host, !host.isEmpty else { return }
|
||||||
|
self.manualGatewayHost = host
|
||||||
|
if let port = url.port {
|
||||||
|
self.manualGatewayPort = port
|
||||||
|
self.manualGatewayPortText = String(port)
|
||||||
|
} else {
|
||||||
|
self.manualGatewayPort = 0
|
||||||
|
self.manualGatewayPortText = ""
|
||||||
|
}
|
||||||
|
let scheme = (url.scheme ?? "").lowercased()
|
||||||
|
if scheme == "wss" || scheme == "https" {
|
||||||
|
self.manualGatewayTLS = true
|
||||||
|
} else if scheme == "ws" || scheme == "http" {
|
||||||
|
self.manualGatewayTLS = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolvedManualPort(host: String) -> Int? {
|
||||||
|
if self.manualGatewayPort > 0 {
|
||||||
|
return self.manualGatewayPort <= 65535 ? self.manualGatewayPort : nil
|
||||||
|
}
|
||||||
|
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return nil }
|
||||||
|
if self.manualGatewayTLS && trimmed.lowercased().hasSuffix(".ts.net") {
|
||||||
|
return 443
|
||||||
|
}
|
||||||
|
return 18789
|
||||||
|
}
|
||||||
|
|
||||||
|
private func preflightGateway(host: String, port: Int, useTLS: Bool) async -> Bool {
|
||||||
|
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return false }
|
||||||
|
|
||||||
|
if Self.isTailnetHostOrIP(trimmed) && !Self.hasTailnetIPv4() {
|
||||||
|
let msg = "Tailscale is off on this iPhone. Turn it on, then try again."
|
||||||
|
self.setupStatusText = msg
|
||||||
|
GatewayDiagnostics.log("preflight fail: tailnet missing host=\(trimmed)")
|
||||||
|
self.gatewayLogger.warning("\(msg, privacy: .public)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
self.setupStatusText = "Checking gateway reachability…"
|
||||||
|
let ok = await Self.probeTCP(host: trimmed, port: port, timeoutSeconds: 3)
|
||||||
|
if !ok {
|
||||||
|
let msg = "Can't reach gateway at \(trimmed):\(port). Check Tailscale or LAN."
|
||||||
|
self.setupStatusText = msg
|
||||||
|
GatewayDiagnostics.log("preflight fail: unreachable host=\(trimmed) port=\(port)")
|
||||||
|
self.gatewayLogger.warning("\(msg, privacy: .public)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
GatewayDiagnostics.log("preflight ok host=\(trimmed) port=\(port) tls=\(useTLS)")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func probeTCP(host: String, port: Int, timeoutSeconds: Double) async -> Bool {
|
||||||
|
guard let nwPort = NWEndpoint.Port(rawValue: UInt16(port)) else { return false }
|
||||||
|
let endpointHost = NWEndpoint.Host(host)
|
||||||
|
let connection = NWConnection(host: endpointHost, port: nwPort, using: .tcp)
|
||||||
|
return await withCheckedContinuation { cont in
|
||||||
|
let queue = DispatchQueue(label: "gateway.preflight")
|
||||||
|
let finished = OSAllocatedUnfairLock(initialState: false)
|
||||||
|
let finish: @Sendable (Bool) -> Void = { ok in
|
||||||
|
let shouldResume = finished.withLock { flag -> Bool in
|
||||||
|
if flag { return false }
|
||||||
|
flag = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
guard shouldResume else { return }
|
||||||
|
connection.cancel()
|
||||||
|
cont.resume(returning: ok)
|
||||||
|
}
|
||||||
|
connection.stateUpdateHandler = { state in
|
||||||
|
switch state {
|
||||||
|
case .ready:
|
||||||
|
finish(true)
|
||||||
|
case .failed, .cancelled:
|
||||||
|
finish(false)
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
connection.start(queue: queue)
|
||||||
|
queue.asyncAfter(deadline: .now() + timeoutSeconds) {
|
||||||
|
finish(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func decodeSetupPayload(raw: String) -> SetupPayload? {
|
||||||
|
if let payload = decodeSetupPayloadFromJSON(raw) {
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
if let decoded = decodeBase64Payload(raw),
|
||||||
|
let payload = decodeSetupPayloadFromJSON(decoded)
|
||||||
|
{
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func decodeSetupPayloadFromJSON(_ json: String) -> SetupPayload? {
|
||||||
|
guard let data = json.data(using: .utf8) else { return nil }
|
||||||
|
return try? JSONDecoder().decode(SetupPayload.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func decodeBase64Payload(_ raw: String) -> String? {
|
||||||
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return nil }
|
||||||
|
let normalized = trimmed
|
||||||
|
.replacingOccurrences(of: "-", with: "+")
|
||||||
|
.replacingOccurrences(of: "_", with: "/")
|
||||||
|
let padding = normalized.count % 4
|
||||||
|
let padded = padding == 0 ? normalized : normalized + String(repeating: "=", count: 4 - padding)
|
||||||
|
guard let data = Data(base64Encoded: padded) else { return nil }
|
||||||
|
return String(data: data, encoding: .utf8)
|
||||||
|
}
|
||||||
|
|
||||||
private func connectManual() async {
|
private func connectManual() async {
|
||||||
let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !host.isEmpty else {
|
guard !host.isEmpty else {
|
||||||
self.connectStatus.text = "Failed: host required"
|
self.setupStatusText = "Failed: host required"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard self.manualGatewayPort > 0, self.manualGatewayPort <= 65535 else {
|
guard self.manualPortIsValid else {
|
||||||
self.connectStatus.text = "Failed: invalid port"
|
self.setupStatusText = "Failed: invalid port"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -392,12 +801,54 @@ struct SettingsTab: View {
|
|||||||
self.manualGatewayEnabled = true
|
self.manualGatewayEnabled = true
|
||||||
defer { self.connectingGatewayID = nil }
|
defer { self.connectingGatewayID = nil }
|
||||||
|
|
||||||
|
GatewayDiagnostics.log(
|
||||||
|
"connect manual host=\(host) port=\(self.manualGatewayPort) tls=\(self.manualGatewayTLS)")
|
||||||
await self.gatewayController.connectManual(
|
await self.gatewayController.connectManual(
|
||||||
host: host,
|
host: host,
|
||||||
port: self.manualGatewayPort,
|
port: self.manualGatewayPort,
|
||||||
useTLS: self.manualGatewayTLS)
|
useTLS: self.manualGatewayTLS)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var setupStatusLine: String? {
|
||||||
|
let trimmedSetup = self.setupStatusText?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
let gatewayStatus = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if let friendly = self.friendlyGatewayMessage(from: gatewayStatus) { return friendly }
|
||||||
|
if let friendly = self.friendlyGatewayMessage(from: trimmedSetup) { return friendly }
|
||||||
|
if !trimmedSetup.isEmpty { return trimmedSetup }
|
||||||
|
if gatewayStatus.isEmpty || gatewayStatus == "Offline" { return nil }
|
||||||
|
return gatewayStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
private var tailnetWarningText: String? {
|
||||||
|
let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !host.isEmpty else { return nil }
|
||||||
|
guard Self.isTailnetHostOrIP(host) else { return nil }
|
||||||
|
guard !Self.hasTailnetIPv4() else { return nil }
|
||||||
|
return "This gateway is on your tailnet. Turn on Tailscale on this iPhone, then tap Connect."
|
||||||
|
}
|
||||||
|
|
||||||
|
private func friendlyGatewayMessage(from raw: String) -> String? {
|
||||||
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return nil }
|
||||||
|
let lower = trimmed.lowercased()
|
||||||
|
if lower.contains("pairing required") {
|
||||||
|
return "Pairing required. Go back to Telegram and run /pair approve, then tap Connect again."
|
||||||
|
}
|
||||||
|
if lower.contains("device nonce required") || lower.contains("device nonce mismatch") {
|
||||||
|
return "Secure handshake failed. Make sure Tailscale is connected, then tap Connect again."
|
||||||
|
}
|
||||||
|
if lower.contains("device signature expired") || lower.contains("device signature invalid") {
|
||||||
|
return "Secure handshake failed. Check that your iPhone time is correct, then tap Connect again."
|
||||||
|
}
|
||||||
|
if lower.contains("connect timed out") || lower.contains("timed out") {
|
||||||
|
return "Connection timed out. Make sure Tailscale is connected, then try again."
|
||||||
|
}
|
||||||
|
if lower.contains("unauthorized role") {
|
||||||
|
return "Connected, but some controls are restricted for nodes. This is expected."
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
private static func primaryIPv4Address() -> String? {
|
private static func primaryIPv4Address() -> String? {
|
||||||
var addrList: UnsafeMutablePointer<ifaddrs>?
|
var addrList: UnsafeMutablePointer<ifaddrs>?
|
||||||
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
|
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
|
||||||
@@ -436,6 +887,57 @@ struct SettingsTab: View {
|
|||||||
return en0 ?? fallback
|
return en0 ?? fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func hasTailnetIPv4() -> Bool {
|
||||||
|
var addrList: UnsafeMutablePointer<ifaddrs>?
|
||||||
|
guard getifaddrs(&addrList) == 0, let first = addrList else { return false }
|
||||||
|
defer { freeifaddrs(addrList) }
|
||||||
|
|
||||||
|
for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) {
|
||||||
|
let flags = Int32(ptr.pointee.ifa_flags)
|
||||||
|
let isUp = (flags & IFF_UP) != 0
|
||||||
|
let isLoopback = (flags & IFF_LOOPBACK) != 0
|
||||||
|
let family = ptr.pointee.ifa_addr.pointee.sa_family
|
||||||
|
if !isUp || isLoopback || family != UInt8(AF_INET) { continue }
|
||||||
|
|
||||||
|
var addr = ptr.pointee.ifa_addr.pointee
|
||||||
|
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
|
||||||
|
let result = getnameinfo(
|
||||||
|
&addr,
|
||||||
|
socklen_t(ptr.pointee.ifa_addr.pointee.sa_len),
|
||||||
|
&buffer,
|
||||||
|
socklen_t(buffer.count),
|
||||||
|
nil,
|
||||||
|
0,
|
||||||
|
NI_NUMERICHOST)
|
||||||
|
guard result == 0 else { continue }
|
||||||
|
let len = buffer.prefix { $0 != 0 }
|
||||||
|
let bytes = len.map { UInt8(bitPattern: $0) }
|
||||||
|
guard let ip = String(bytes: bytes, encoding: .utf8) else { continue }
|
||||||
|
if self.isTailnetIPv4(ip) { return true }
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func isTailnetHostOrIP(_ host: String) -> Bool {
|
||||||
|
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
if trimmed.hasSuffix(".ts.net") || trimmed.hasSuffix(".ts.net.") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return self.isTailnetIPv4(trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func isTailnetIPv4(_ ip: String) -> Bool {
|
||||||
|
let parts = ip.split(separator: ".")
|
||||||
|
guard parts.count == 4 else { return false }
|
||||||
|
let octets = parts.compactMap { Int($0) }
|
||||||
|
guard octets.count == 4 else { return false }
|
||||||
|
let a = octets[0]
|
||||||
|
let b = octets[1]
|
||||||
|
guard (0...255).contains(a), (0...255).contains(b) else { return false }
|
||||||
|
return a == 100 && b >= 64 && b <= 127
|
||||||
|
}
|
||||||
|
|
||||||
private static func parseHostPort(from address: String) -> SettingsHostPort? {
|
private static func parseHostPort(from address: String) -> SettingsHostPort? {
|
||||||
SettingsNetworkingHelpers.parseHostPort(from: address)
|
SettingsNetworkingHelpers.parseHostPort(from: address)
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ struct TalkOrbOverlay: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
let seam = self.appModel.seamColor
|
let seam = self.appModel.seamColor
|
||||||
let status = self.appModel.talkMode.statusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
let status = self.appModel.talkMode.statusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let mic = min(max(self.appModel.talkMode.micLevel, 0), 1)
|
||||||
|
|
||||||
VStack(spacing: 14) {
|
VStack(spacing: 14) {
|
||||||
ZStack {
|
ZStack {
|
||||||
@@ -28,7 +29,7 @@ struct TalkOrbOverlay: View {
|
|||||||
.fill(
|
.fill(
|
||||||
RadialGradient(
|
RadialGradient(
|
||||||
colors: [
|
colors: [
|
||||||
seam.opacity(0.95),
|
seam.opacity(0.75 + (0.20 * mic)),
|
||||||
seam.opacity(0.40),
|
seam.opacity(0.40),
|
||||||
Color.black.opacity(0.55),
|
Color.black.opacity(0.55),
|
||||||
],
|
],
|
||||||
@@ -36,6 +37,7 @@ struct TalkOrbOverlay: View {
|
|||||||
startRadius: 1,
|
startRadius: 1,
|
||||||
endRadius: 112))
|
endRadius: 112))
|
||||||
.frame(width: 190, height: 190)
|
.frame(width: 190, height: 190)
|
||||||
|
.scaleEffect(1.0 + (0.12 * mic))
|
||||||
.overlay(
|
.overlay(
|
||||||
Circle()
|
Circle()
|
||||||
.stroke(seam.opacity(0.35), lineWidth: 1))
|
.stroke(seam.opacity(0.35), lineWidth: 1))
|
||||||
@@ -47,6 +49,13 @@ struct TalkOrbOverlay: View {
|
|||||||
self.appModel.talkMode.userTappedOrb()
|
self.appModel.talkMode.userTappedOrb()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let agentName = self.appModel.activeAgentName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !agentName.isEmpty {
|
||||||
|
Text("Bot: \(agentName)")
|
||||||
|
.font(.system(.caption, design: .rounded).weight(.semibold))
|
||||||
|
.foregroundStyle(Color.white.opacity(0.70))
|
||||||
|
}
|
||||||
|
|
||||||
if !status.isEmpty, status != "Off" {
|
if !status.isEmpty, status != "Off" {
|
||||||
Text(status)
|
Text(status)
|
||||||
.font(.system(.footnote, design: .rounded).weight(.semibold))
|
.font(.system(.footnote, design: .rounded).weight(.semibold))
|
||||||
@@ -59,6 +68,14 @@ struct TalkOrbOverlay: View {
|
|||||||
.overlay(
|
.overlay(
|
||||||
Capsule().stroke(seam.opacity(0.22), lineWidth: 1)))
|
Capsule().stroke(seam.opacity(0.22), lineWidth: 1)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if self.appModel.talkMode.isListening {
|
||||||
|
Capsule()
|
||||||
|
.fill(seam.opacity(0.90))
|
||||||
|
.frame(width: max(18, 180 * mic), height: 6)
|
||||||
|
.animation(.easeOut(duration: 0.12), value: mic)
|
||||||
|
.accessibilityLabel("Microphone level")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding(28)
|
.padding(28)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import AVFAudio
|
import AVFAudio
|
||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
import Observation
|
||||||
|
import OpenClawKit
|
||||||
import Speech
|
import Speech
|
||||||
import SwabbleKit
|
import SwabbleKit
|
||||||
|
|
||||||
@@ -96,6 +97,7 @@ final class VoiceWakeManager: NSObject {
|
|||||||
private var lastDispatched: String?
|
private var lastDispatched: String?
|
||||||
private var onCommand: (@Sendable (String) async -> Void)?
|
private var onCommand: (@Sendable (String) async -> Void)?
|
||||||
private var userDefaultsObserver: NSObjectProtocol?
|
private var userDefaultsObserver: NSObjectProtocol?
|
||||||
|
private var suppressedByTalk: Bool = false
|
||||||
|
|
||||||
override init() {
|
override init() {
|
||||||
super.init()
|
super.init()
|
||||||
@@ -141,9 +143,28 @@ final class VoiceWakeManager: NSObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setSuppressedByTalk(_ suppressed: Bool) {
|
||||||
|
self.suppressedByTalk = suppressed
|
||||||
|
if suppressed {
|
||||||
|
_ = self.suspendForExternalAudioCapture()
|
||||||
|
if self.isEnabled {
|
||||||
|
self.statusText = "Paused"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if self.isEnabled {
|
||||||
|
Task { await self.start() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func start() async {
|
func start() async {
|
||||||
guard self.isEnabled else { return }
|
guard self.isEnabled else { return }
|
||||||
if self.isListening { return }
|
if self.isListening { return }
|
||||||
|
guard !self.suppressedByTalk else {
|
||||||
|
self.isListening = false
|
||||||
|
self.statusText = "Paused"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if ProcessInfo.processInfo.environment["SIMULATOR_DEVICE_NAME"] != nil ||
|
if ProcessInfo.processInfo.environment["SIMULATOR_DEVICE_NAME"] != nil ||
|
||||||
ProcessInfo.processInfo.environment["SIMULATOR_UDID"] != nil
|
ProcessInfo.processInfo.environment["SIMULATOR_UDID"] != nil
|
||||||
@@ -159,14 +180,18 @@ final class VoiceWakeManager: NSObject {
|
|||||||
|
|
||||||
let micOk = await Self.requestMicrophonePermission()
|
let micOk = await Self.requestMicrophonePermission()
|
||||||
guard micOk else {
|
guard micOk else {
|
||||||
self.statusText = "Microphone permission denied"
|
self.statusText = Self.permissionMessage(
|
||||||
|
kind: "Microphone",
|
||||||
|
status: AVAudioSession.sharedInstance().recordPermission)
|
||||||
self.isListening = false
|
self.isListening = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let speechOk = await Self.requestSpeechPermission()
|
let speechOk = await Self.requestSpeechPermission()
|
||||||
guard speechOk else {
|
guard speechOk else {
|
||||||
self.statusText = "Speech recognition permission denied"
|
self.statusText = Self.permissionMessage(
|
||||||
|
kind: "Speech recognition",
|
||||||
|
status: SFSpeechRecognizer.authorizationStatus())
|
||||||
self.isListening = false
|
self.isListening = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -364,20 +389,101 @@ final class VoiceWakeManager: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private nonisolated static func requestMicrophonePermission() async -> Bool {
|
private nonisolated static func requestMicrophonePermission() async -> Bool {
|
||||||
await withCheckedContinuation(isolation: nil) { cont in
|
let session = AVAudioSession.sharedInstance()
|
||||||
AVAudioApplication.requestRecordPermission { ok in
|
switch session.recordPermission {
|
||||||
cont.resume(returning: ok)
|
case .granted:
|
||||||
|
return true
|
||||||
|
case .denied:
|
||||||
|
return false
|
||||||
|
case .undetermined:
|
||||||
|
break
|
||||||
|
@unknown default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return await self.requestPermissionWithTimeout { completion in
|
||||||
|
AVAudioSession.sharedInstance().requestRecordPermission { ok in
|
||||||
|
completion(ok)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private nonisolated static func requestSpeechPermission() async -> Bool {
|
private nonisolated static func requestSpeechPermission() async -> Bool {
|
||||||
await withCheckedContinuation(isolation: nil) { cont in
|
let status = SFSpeechRecognizer.authorizationStatus()
|
||||||
SFSpeechRecognizer.requestAuthorization { status in
|
switch status {
|
||||||
cont.resume(returning: status == .authorized)
|
case .authorized:
|
||||||
|
return true
|
||||||
|
case .denied, .restricted:
|
||||||
|
return false
|
||||||
|
case .notDetermined:
|
||||||
|
break
|
||||||
|
@unknown default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return await self.requestPermissionWithTimeout { completion in
|
||||||
|
SFSpeechRecognizer.requestAuthorization { authStatus in
|
||||||
|
completion(authStatus == .authorized)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private nonisolated static func requestPermissionWithTimeout(
|
||||||
|
_ operation: @escaping @Sendable (@escaping (Bool) -> Void) -> Void) async -> Bool
|
||||||
|
{
|
||||||
|
do {
|
||||||
|
return try await AsyncTimeout.withTimeout(
|
||||||
|
seconds: 8,
|
||||||
|
onTimeout: { NSError(domain: "VoiceWake", code: 6, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "permission request timed out",
|
||||||
|
]) },
|
||||||
|
operation: {
|
||||||
|
await withCheckedContinuation(isolation: nil) { cont in
|
||||||
|
Task { @MainActor in
|
||||||
|
operation { ok in
|
||||||
|
cont.resume(returning: ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func permissionMessage(
|
||||||
|
kind: String,
|
||||||
|
status: AVAudioSession.RecordPermission) -> String
|
||||||
|
{
|
||||||
|
switch status {
|
||||||
|
case .denied:
|
||||||
|
return "\(kind) permission denied"
|
||||||
|
case .undetermined:
|
||||||
|
return "\(kind) permission not granted"
|
||||||
|
case .granted:
|
||||||
|
return "\(kind) permission denied"
|
||||||
|
@unknown default:
|
||||||
|
return "\(kind) permission denied"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func permissionMessage(
|
||||||
|
kind: String,
|
||||||
|
status: SFSpeechRecognizerAuthorizationStatus) -> String
|
||||||
|
{
|
||||||
|
switch status {
|
||||||
|
case .denied:
|
||||||
|
return "\(kind) permission denied"
|
||||||
|
case .restricted:
|
||||||
|
return "\(kind) permission restricted"
|
||||||
|
case .notDetermined:
|
||||||
|
return "\(kind) permission not granted"
|
||||||
|
case .authorized:
|
||||||
|
return "\(kind) permission denied"
|
||||||
|
@unknown default:
|
||||||
|
return "\(kind) permission denied"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ Sources/Chat/IOSGatewayChatTransport.swift
|
|||||||
Sources/OpenClawApp.swift
|
Sources/OpenClawApp.swift
|
||||||
Sources/Location/LocationService.swift
|
Sources/Location/LocationService.swift
|
||||||
Sources/Model/NodeAppModel.swift
|
Sources/Model/NodeAppModel.swift
|
||||||
|
Sources/Model/NodeAppModel+Canvas.swift
|
||||||
Sources/RootCanvas.swift
|
Sources/RootCanvas.swift
|
||||||
Sources/RootTabs.swift
|
Sources/RootTabs.swift
|
||||||
Sources/Screen/ScreenController.swift
|
Sources/Screen/ScreenController.swift
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ private struct KeychainEntry: Hashable {
|
|||||||
let account: String
|
let account: String
|
||||||
}
|
}
|
||||||
|
|
||||||
private let gatewayService = "bot.molt.gateway"
|
private let gatewayService = "ai.openclaw.gateway"
|
||||||
private let nodeService = "bot.molt.node"
|
private let nodeService = "ai.openclaw.node"
|
||||||
private let instanceIdEntry = KeychainEntry(service: nodeService, account: "instanceId")
|
private let instanceIdEntry = KeychainEntry(service: nodeService, account: "instanceId")
|
||||||
private let preferredGatewayEntry = KeychainEntry(service: gatewayService, account: "preferredStableID")
|
private let preferredGatewayEntry = KeychainEntry(service: gatewayService, account: "preferredStableID")
|
||||||
private let lastGatewayEntry = KeychainEntry(service: gatewayService, account: "lastDiscoveredStableID")
|
private let lastGatewayEntry = KeychainEntry(service: gatewayService, account: "lastDiscoveredStableID")
|
||||||
|
|||||||
@@ -101,7 +101,8 @@ private func withUserDefaults<T>(_ updates: [String: Any?], _ body: () throws ->
|
|||||||
#expect(presentRes.ok == true)
|
#expect(presentRes.ok == true)
|
||||||
#expect(appModel.screen.urlString.isEmpty)
|
#expect(appModel.screen.urlString.isEmpty)
|
||||||
|
|
||||||
let navigateParams = OpenClawCanvasNavigateParams(url: "http://localhost:18789/")
|
// Loopback URLs are rejected (they are not meaningful for a remote gateway).
|
||||||
|
let navigateParams = OpenClawCanvasNavigateParams(url: "http://example.com/")
|
||||||
let navData = try JSONEncoder().encode(navigateParams)
|
let navData = try JSONEncoder().encode(navigateParams)
|
||||||
let navJSON = String(decoding: navData, as: UTF8.self)
|
let navJSON = String(decoding: navData, as: UTF8.self)
|
||||||
let navigate = BridgeInvokeRequest(
|
let navigate = BridgeInvokeRequest(
|
||||||
@@ -110,7 +111,7 @@ private func withUserDefaults<T>(_ updates: [String: Any?], _ body: () throws ->
|
|||||||
paramsJSON: navJSON)
|
paramsJSON: navJSON)
|
||||||
let navRes = await appModel._test_handleInvoke(navigate)
|
let navRes = await appModel._test_handleInvoke(navigate)
|
||||||
#expect(navRes.ok == true)
|
#expect(navRes.ok == true)
|
||||||
#expect(appModel.screen.urlString == "http://localhost:18789/")
|
#expect(appModel.screen.urlString == "http://example.com/")
|
||||||
|
|
||||||
let evalParams = OpenClawCanvasEvalParams(javaScript: "1+1")
|
let evalParams = OpenClawCanvasEvalParams(javaScript: "1+1")
|
||||||
let evalData = try JSONEncoder().encode(evalParams)
|
let evalData = try JSONEncoder().encode(evalParams)
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum OpenClawCalendarCommand: String, Codable, Sendable {
|
||||||
|
case events = "calendar.events"
|
||||||
|
case add = "calendar.add"
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct OpenClawCalendarEventsParams: Codable, Sendable, Equatable {
|
||||||
|
public var startISO: String?
|
||||||
|
public var endISO: String?
|
||||||
|
public var limit: Int?
|
||||||
|
|
||||||
|
public init(startISO: String? = nil, endISO: String? = nil, limit: Int? = nil) {
|
||||||
|
self.startISO = startISO
|
||||||
|
self.endISO = endISO
|
||||||
|
self.limit = limit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct OpenClawCalendarAddParams: Codable, Sendable, Equatable {
|
||||||
|
public var title: String
|
||||||
|
public var startISO: String
|
||||||
|
public var endISO: String
|
||||||
|
public var isAllDay: Bool?
|
||||||
|
public var location: String?
|
||||||
|
public var notes: String?
|
||||||
|
public var calendarId: String?
|
||||||
|
public var calendarTitle: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
title: String,
|
||||||
|
startISO: String,
|
||||||
|
endISO: String,
|
||||||
|
isAllDay: Bool? = nil,
|
||||||
|
location: String? = nil,
|
||||||
|
notes: String? = nil,
|
||||||
|
calendarId: String? = nil,
|
||||||
|
calendarTitle: String? = nil)
|
||||||
|
{
|
||||||
|
self.title = title
|
||||||
|
self.startISO = startISO
|
||||||
|
self.endISO = endISO
|
||||||
|
self.isAllDay = isAllDay
|
||||||
|
self.location = location
|
||||||
|
self.notes = notes
|
||||||
|
self.calendarId = calendarId
|
||||||
|
self.calendarTitle = calendarTitle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct OpenClawCalendarEventPayload: Codable, Sendable, Equatable {
|
||||||
|
public var identifier: String
|
||||||
|
public var title: String
|
||||||
|
public var startISO: String
|
||||||
|
public var endISO: String
|
||||||
|
public var isAllDay: Bool
|
||||||
|
public var location: String?
|
||||||
|
public var calendarTitle: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
identifier: String,
|
||||||
|
title: String,
|
||||||
|
startISO: String,
|
||||||
|
endISO: String,
|
||||||
|
isAllDay: Bool,
|
||||||
|
location: String? = nil,
|
||||||
|
calendarTitle: String? = nil)
|
||||||
|
{
|
||||||
|
self.identifier = identifier
|
||||||
|
self.title = title
|
||||||
|
self.startISO = startISO
|
||||||
|
self.endISO = endISO
|
||||||
|
self.isAllDay = isAllDay
|
||||||
|
self.location = location
|
||||||
|
self.calendarTitle = calendarTitle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct OpenClawCalendarEventsPayload: Codable, Sendable, Equatable {
|
||||||
|
public var events: [OpenClawCalendarEventPayload]
|
||||||
|
|
||||||
|
public init(events: [OpenClawCalendarEventPayload]) {
|
||||||
|
self.events = events
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct OpenClawCalendarAddPayload: Codable, Sendable, Equatable {
|
||||||
|
public var event: OpenClawCalendarEventPayload
|
||||||
|
|
||||||
|
public init(event: OpenClawCalendarEventPayload) {
|
||||||
|
self.event = event
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,4 +6,10 @@ public enum OpenClawCapability: String, Codable, Sendable {
|
|||||||
case screen
|
case screen
|
||||||
case voiceWake
|
case voiceWake
|
||||||
case location
|
case location
|
||||||
|
case device
|
||||||
|
case photos
|
||||||
|
case contacts
|
||||||
|
case calendar
|
||||||
|
case reminders
|
||||||
|
case motion
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum OpenClawChatCommand: String, Codable, Sendable {
|
||||||
|
case push = "chat.push"
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct OpenClawChatPushParams: Codable, Sendable, Equatable {
|
||||||
|
public var text: String
|
||||||
|
public var speak: Bool?
|
||||||
|
|
||||||
|
public init(text: String, speak: Bool? = nil) {
|
||||||
|
self.text = text
|
||||||
|
self.speak = speak
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct OpenClawChatPushPayload: Codable, Sendable, Equatable {
|
||||||
|
public var messageId: String?
|
||||||
|
|
||||||
|
public init(messageId: String? = nil) {
|
||||||
|
self.messageId = messageId
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum OpenClawContactsCommand: String, Codable, Sendable {
|
||||||
|
case search = "contacts.search"
|
||||||
|
case add = "contacts.add"
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct OpenClawContactsSearchParams: Codable, Sendable, Equatable {
|
||||||
|
public var query: String?
|
||||||
|
public var limit: Int?
|
||||||
|
|
||||||
|
public init(query: String? = nil, limit: Int? = nil) {
|
||||||
|
self.query = query
|
||||||
|
self.limit = limit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct OpenClawContactsAddParams: Codable, Sendable, Equatable {
|
||||||
|
public var givenName: String?
|
||||||
|
public var familyName: String?
|
||||||
|
public var organizationName: String?
|
||||||
|
public var displayName: String?
|
||||||
|
public var phoneNumbers: [String]?
|
||||||
|
public var emails: [String]?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
givenName: String? = nil,
|
||||||
|
familyName: String? = nil,
|
||||||
|
organizationName: String? = nil,
|
||||||
|
displayName: String? = nil,
|
||||||
|
phoneNumbers: [String]? = nil,
|
||||||
|
emails: [String]? = nil)
|
||||||
|
{
|
||||||
|
self.givenName = givenName
|
||||||
|
self.familyName = familyName
|
||||||
|
self.organizationName = organizationName
|
||||||
|
self.displayName = displayName
|
||||||
|
self.phoneNumbers = phoneNumbers
|
||||||
|
self.emails = emails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct OpenClawContactPayload: Codable, Sendable, Equatable {
|
||||||
|
public var identifier: String
|
||||||
|
public var displayName: String
|
||||||
|
public var givenName: String
|
||||||
|
public var familyName: String
|
||||||
|
public var organizationName: String
|
||||||
|
public var phoneNumbers: [String]
|
||||||
|
public var emails: [String]
|
||||||
|
|
||||||
|
public init(
|
||||||
|
identifier: String,
|
||||||
|
displayName: String,
|
||||||
|
givenName: String,
|
||||||
|
familyName: String,
|
||||||
|
organizationName: String,
|
||||||
|
phoneNumbers: [String],
|
||||||
|
emails: [String])
|
||||||
|
{
|
||||||
|
self.identifier = identifier
|
||||||
|
self.displayName = displayName
|
||||||
|
self.givenName = givenName
|
||||||
|
self.familyName = familyName
|
||||||
|
self.organizationName = organizationName
|
||||||
|
self.phoneNumbers = phoneNumbers
|
||||||
|
self.emails = emails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct OpenClawContactsSearchPayload: Codable, Sendable, Equatable {
|
||||||
|
public var contacts: [OpenClawContactPayload]
|
||||||
|
|
||||||
|
public init(contacts: [OpenClawContactPayload]) {
|
||||||
|
self.contacts = contacts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct OpenClawContactsAddPayload: Codable, Sendable, Equatable {
|
||||||
|
public var contact: OpenClawContactPayload
|
||||||
|
|
||||||
|
public init(contact: OpenClawContactPayload) {
|
||||||
|
self.contact = contact
|
||||||
|
}
|
||||||
|
}
|
||||||
134
apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceCommands.swift
Normal file
134
apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceCommands.swift
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum OpenClawDeviceCommand: String, Codable, Sendable {
|
||||||
|
case status = "device.status"
|
||||||
|
case info = "device.info"
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum OpenClawBatteryState: String, Codable, Sendable {
|
||||||
|
case unknown
|
||||||
|
case unplugged
|
||||||
|
case charging
|
||||||
|
case full
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum OpenClawThermalState: String, Codable, Sendable {
|
||||||
|
case nominal
|
||||||
|
case fair
|
||||||
|
case serious
|
||||||
|
case critical
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum OpenClawNetworkPathStatus: String, Codable, Sendable {
|
||||||
|
case satisfied
|
||||||
|
case unsatisfied
|
||||||
|
case requiresConnection
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum OpenClawNetworkInterfaceType: String, Codable, Sendable {
|
||||||
|
case wifi
|
||||||
|
case cellular
|
||||||
|
case wired
|
||||||
|
case other
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct OpenClawBatteryStatusPayload: Codable, Sendable, Equatable {
|
||||||
|
public var level: Double?
|
||||||
|
public var state: OpenClawBatteryState
|
||||||
|
public var lowPowerModeEnabled: Bool
|
||||||
|
|
||||||
|
public init(level: Double?, state: OpenClawBatteryState, lowPowerModeEnabled: Bool) {
|
||||||
|
self.level = level
|
||||||
|
self.state = state
|
||||||
|
self.lowPowerModeEnabled = lowPowerModeEnabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct OpenClawThermalStatusPayload: Codable, Sendable, Equatable {
|
||||||
|
public var state: OpenClawThermalState
|
||||||
|
|
||||||
|
public init(state: OpenClawThermalState) {
|
||||||
|
self.state = state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct OpenClawStorageStatusPayload: Codable, Sendable, Equatable {
|
||||||
|
public var totalBytes: Int64
|
||||||
|
public var freeBytes: Int64
|
||||||
|
public var usedBytes: Int64
|
||||||
|
|
||||||
|
public init(totalBytes: Int64, freeBytes: Int64, usedBytes: Int64) {
|
||||||
|
self.totalBytes = totalBytes
|
||||||
|
self.freeBytes = freeBytes
|
||||||
|
self.usedBytes = usedBytes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct OpenClawNetworkStatusPayload: Codable, Sendable, Equatable {
|
||||||
|
public var status: OpenClawNetworkPathStatus
|
||||||
|
public var isExpensive: Bool
|
||||||
|
public var isConstrained: Bool
|
||||||
|
public var interfaces: [OpenClawNetworkInterfaceType]
|
||||||
|
|
||||||
|
public init(
|
||||||
|
status: OpenClawNetworkPathStatus,
|
||||||
|
isExpensive: Bool,
|
||||||
|
isConstrained: Bool,
|
||||||
|
interfaces: [OpenClawNetworkInterfaceType])
|
||||||
|
{
|
||||||
|
self.status = status
|
||||||
|
self.isExpensive = isExpensive
|
||||||
|
self.isConstrained = isConstrained
|
||||||
|
self.interfaces = interfaces
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct OpenClawDeviceStatusPayload: Codable, Sendable, Equatable {
|
||||||
|
public var battery: OpenClawBatteryStatusPayload
|
||||||
|
public var thermal: OpenClawThermalStatusPayload
|
||||||
|
public var storage: OpenClawStorageStatusPayload
|
||||||
|
public var network: OpenClawNetworkStatusPayload
|
||||||
|
public var uptimeSeconds: Double
|
||||||
|
|
||||||
|
public init(
|
||||||
|
battery: OpenClawBatteryStatusPayload,
|
||||||
|
thermal: OpenClawThermalStatusPayload,
|
||||||
|
storage: OpenClawStorageStatusPayload,
|
||||||
|
network: OpenClawNetworkStatusPayload,
|
||||||
|
uptimeSeconds: Double)
|
||||||
|
{
|
||||||
|
self.battery = battery
|
||||||
|
self.thermal = thermal
|
||||||
|
self.storage = storage
|
||||||
|
self.network = network
|
||||||
|
self.uptimeSeconds = uptimeSeconds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct OpenClawDeviceInfoPayload: Codable, Sendable, Equatable {
|
||||||
|
public var deviceName: String
|
||||||
|
public var modelIdentifier: String
|
||||||
|
public var systemName: String
|
||||||
|
public var systemVersion: String
|
||||||
|
public var appVersion: String
|
||||||
|
public var appBuild: String
|
||||||
|
public var locale: String
|
||||||
|
|
||||||
|
public init(
|
||||||
|
deviceName: String,
|
||||||
|
modelIdentifier: String,
|
||||||
|
systemName: String,
|
||||||
|
systemVersion: String,
|
||||||
|
appVersion: String,
|
||||||
|
appBuild: String,
|
||||||
|
locale: String)
|
||||||
|
{
|
||||||
|
self.deviceName = deviceName
|
||||||
|
self.modelIdentifier = modelIdentifier
|
||||||
|
self.systemName = systemName
|
||||||
|
self.systemVersion = systemVersion
|
||||||
|
self.appVersion = appVersion
|
||||||
|
self.appBuild = appBuild
|
||||||
|
self.locale = locale
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -72,6 +72,10 @@ public struct GatewayConnectOptions: Sendable {
|
|||||||
public var clientId: String
|
public var clientId: String
|
||||||
public var clientMode: String
|
public var clientMode: String
|
||||||
public var clientDisplayName: String?
|
public var clientDisplayName: String?
|
||||||
|
// When false, the connection omits the signed device identity payload.
|
||||||
|
// This is useful for secondary "operator" connections where the shared gateway token
|
||||||
|
// should authorize without triggering device pairing flows.
|
||||||
|
public var includeDeviceIdentity: Bool
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
role: String,
|
role: String,
|
||||||
@@ -81,7 +85,8 @@ public struct GatewayConnectOptions: Sendable {
|
|||||||
permissions: [String: Bool],
|
permissions: [String: Bool],
|
||||||
clientId: String,
|
clientId: String,
|
||||||
clientMode: String,
|
clientMode: String,
|
||||||
clientDisplayName: String?)
|
clientDisplayName: String?,
|
||||||
|
includeDeviceIdentity: Bool = true)
|
||||||
{
|
{
|
||||||
self.role = role
|
self.role = role
|
||||||
self.scopes = scopes
|
self.scopes = scopes
|
||||||
@@ -91,6 +96,7 @@ public struct GatewayConnectOptions: Sendable {
|
|||||||
self.clientId = clientId
|
self.clientId = clientId
|
||||||
self.clientMode = clientMode
|
self.clientMode = clientMode
|
||||||
self.clientDisplayName = clientDisplayName
|
self.clientDisplayName = clientDisplayName
|
||||||
|
self.includeDeviceIdentity = includeDeviceIdentity
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,7 +134,7 @@ public actor GatewayChannelActor {
|
|||||||
private let decoder = JSONDecoder()
|
private let decoder = JSONDecoder()
|
||||||
private let encoder = JSONEncoder()
|
private let encoder = JSONEncoder()
|
||||||
private let connectTimeoutSeconds: Double = 6
|
private let connectTimeoutSeconds: Double = 6
|
||||||
private let connectChallengeTimeoutSeconds: Double = 0.75
|
private let connectChallengeTimeoutSeconds: Double = 3.0
|
||||||
private var watchdogTask: Task<Void, Never>?
|
private var watchdogTask: Task<Void, Never>?
|
||||||
private var tickTask: Task<Void, Never>?
|
private var tickTask: Task<Void, Never>?
|
||||||
private let defaultRequestTimeoutMs: Double = 15000
|
private let defaultRequestTimeoutMs: Double = 15000
|
||||||
@@ -307,9 +313,15 @@ public actor GatewayChannelActor {
|
|||||||
if !options.permissions.isEmpty {
|
if !options.permissions.isEmpty {
|
||||||
params["permissions"] = ProtoAnyCodable(options.permissions)
|
params["permissions"] = ProtoAnyCodable(options.permissions)
|
||||||
}
|
}
|
||||||
let identity = DeviceIdentityStore.loadOrCreate()
|
let includeDeviceIdentity = options.includeDeviceIdentity
|
||||||
let storedToken = DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: role)?.token
|
let identity = includeDeviceIdentity ? DeviceIdentityStore.loadOrCreate() : nil
|
||||||
let authToken = storedToken ?? self.token
|
let storedToken =
|
||||||
|
(includeDeviceIdentity && identity != nil)
|
||||||
|
? DeviceAuthStore.loadToken(deviceId: identity!.deviceId, role: role)?.token
|
||||||
|
: nil
|
||||||
|
// If we're not sending a device identity, a device token can't be validated server-side.
|
||||||
|
// In that mode we always use the shared gateway token/password.
|
||||||
|
let authToken = includeDeviceIdentity ? (storedToken ?? self.token) : self.token
|
||||||
let authSource: GatewayAuthSource
|
let authSource: GatewayAuthSource
|
||||||
if storedToken != nil {
|
if storedToken != nil {
|
||||||
authSource = .deviceToken
|
authSource = .deviceToken
|
||||||
@@ -322,7 +334,7 @@ public actor GatewayChannelActor {
|
|||||||
}
|
}
|
||||||
self.lastAuthSource = authSource
|
self.lastAuthSource = authSource
|
||||||
self.logger.info("gateway connect auth=\(authSource.rawValue, privacy: .public)")
|
self.logger.info("gateway connect auth=\(authSource.rawValue, privacy: .public)")
|
||||||
let canFallbackToShared = storedToken != nil && self.token != nil
|
let canFallbackToShared = includeDeviceIdentity && storedToken != nil && self.token != nil
|
||||||
if let authToken {
|
if let authToken {
|
||||||
params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(authToken)])
|
params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(authToken)])
|
||||||
} else if let password = self.password {
|
} else if let password = self.password {
|
||||||
@@ -333,7 +345,7 @@ public actor GatewayChannelActor {
|
|||||||
let scopesValue = scopes.joined(separator: ",")
|
let scopesValue = scopes.joined(separator: ",")
|
||||||
var payloadParts = [
|
var payloadParts = [
|
||||||
connectNonce == nil ? "v1" : "v2",
|
connectNonce == nil ? "v1" : "v2",
|
||||||
identity.deviceId,
|
identity?.deviceId ?? "",
|
||||||
clientId,
|
clientId,
|
||||||
clientMode,
|
clientMode,
|
||||||
role,
|
role,
|
||||||
@@ -345,18 +357,20 @@ public actor GatewayChannelActor {
|
|||||||
payloadParts.append(connectNonce)
|
payloadParts.append(connectNonce)
|
||||||
}
|
}
|
||||||
let payload = payloadParts.joined(separator: "|")
|
let payload = payloadParts.joined(separator: "|")
|
||||||
if let signature = DeviceIdentityStore.signPayload(payload, identity: identity),
|
if includeDeviceIdentity, let identity {
|
||||||
let publicKey = DeviceIdentityStore.publicKeyBase64Url(identity) {
|
if let signature = DeviceIdentityStore.signPayload(payload, identity: identity),
|
||||||
var device: [String: ProtoAnyCodable] = [
|
let publicKey = DeviceIdentityStore.publicKeyBase64Url(identity) {
|
||||||
"id": ProtoAnyCodable(identity.deviceId),
|
var device: [String: ProtoAnyCodable] = [
|
||||||
"publicKey": ProtoAnyCodable(publicKey),
|
"id": ProtoAnyCodable(identity.deviceId),
|
||||||
"signature": ProtoAnyCodable(signature),
|
"publicKey": ProtoAnyCodable(publicKey),
|
||||||
"signedAt": ProtoAnyCodable(signedAtMs),
|
"signature": ProtoAnyCodable(signature),
|
||||||
]
|
"signedAt": ProtoAnyCodable(signedAtMs),
|
||||||
if let connectNonce {
|
]
|
||||||
device["nonce"] = ProtoAnyCodable(connectNonce)
|
if let connectNonce {
|
||||||
|
device["nonce"] = ProtoAnyCodable(connectNonce)
|
||||||
|
}
|
||||||
|
params["device"] = ProtoAnyCodable(device)
|
||||||
}
|
}
|
||||||
params["device"] = ProtoAnyCodable(device)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let frame = RequestFrame(
|
let frame = RequestFrame(
|
||||||
@@ -371,7 +385,9 @@ public actor GatewayChannelActor {
|
|||||||
try await self.handleConnectResponse(response, identity: identity, role: role)
|
try await self.handleConnectResponse(response, identity: identity, role: role)
|
||||||
} catch {
|
} catch {
|
||||||
if canFallbackToShared {
|
if canFallbackToShared {
|
||||||
DeviceAuthStore.clearToken(deviceId: identity.deviceId, role: role)
|
if let identity {
|
||||||
|
DeviceAuthStore.clearToken(deviceId: identity.deviceId, role: role)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
@@ -379,7 +395,7 @@ public actor GatewayChannelActor {
|
|||||||
|
|
||||||
private func handleConnectResponse(
|
private func handleConnectResponse(
|
||||||
_ res: ResponseFrame,
|
_ res: ResponseFrame,
|
||||||
identity: DeviceIdentity,
|
identity: DeviceIdentity?,
|
||||||
role: String
|
role: String
|
||||||
) async throws {
|
) async throws {
|
||||||
if res.ok == false {
|
if res.ok == false {
|
||||||
@@ -404,11 +420,13 @@ public actor GatewayChannelActor {
|
|||||||
let authRole = auth["role"]?.value as? String ?? role
|
let authRole = auth["role"]?.value as? String ?? role
|
||||||
let scopes = (auth["scopes"]?.value as? [ProtoAnyCodable])?
|
let scopes = (auth["scopes"]?.value as? [ProtoAnyCodable])?
|
||||||
.compactMap { $0.value as? String } ?? []
|
.compactMap { $0.value as? String } ?? []
|
||||||
_ = DeviceAuthStore.storeToken(
|
if let identity {
|
||||||
deviceId: identity.deviceId,
|
_ = DeviceAuthStore.storeToken(
|
||||||
role: authRole,
|
deviceId: identity.deviceId,
|
||||||
token: deviceToken,
|
role: authRole,
|
||||||
scopes: scopes)
|
token: deviceToken,
|
||||||
|
scopes: scopes)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
self.lastTick = Date()
|
self.lastTick = Date()
|
||||||
self.tickTask?.cancel()
|
self.tickTask?.cancel()
|
||||||
@@ -498,7 +516,10 @@ public actor GatewayChannelActor {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
} catch {
|
} catch {
|
||||||
if error is ConnectChallengeError { return nil }
|
if error is ConnectChallengeError {
|
||||||
|
self.logger.warning("gateway connect challenge timed out")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ public actor GatewayNodeSession {
|
|||||||
private var activeURL: URL?
|
private var activeURL: URL?
|
||||||
private var activeToken: String?
|
private var activeToken: String?
|
||||||
private var activePassword: String?
|
private var activePassword: String?
|
||||||
|
private var activeConnectOptionsKey: String?
|
||||||
private var connectOptions: GatewayConnectOptions?
|
private var connectOptions: GatewayConnectOptions?
|
||||||
private var onConnected: (@Sendable () async -> Void)?
|
private var onConnected: (@Sendable () async -> Void)?
|
||||||
private var onDisconnected: (@Sendable (String) async -> Void)?
|
private var onDisconnected: (@Sendable (String) async -> Void)?
|
||||||
@@ -103,6 +104,42 @@ public actor GatewayNodeSession {
|
|||||||
|
|
||||||
public init() {}
|
public init() {}
|
||||||
|
|
||||||
|
private func connectOptionsKey(_ options: GatewayConnectOptions) -> String {
|
||||||
|
func sorted(_ values: [String]) -> String {
|
||||||
|
values.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
.sorted()
|
||||||
|
.joined(separator: ",")
|
||||||
|
}
|
||||||
|
let role = options.role.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let scopes = sorted(options.scopes)
|
||||||
|
let caps = sorted(options.caps)
|
||||||
|
let commands = sorted(options.commands)
|
||||||
|
let clientId = options.clientId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let clientMode = options.clientMode.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let clientDisplayName = (options.clientDisplayName ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let includeDeviceIdentity = options.includeDeviceIdentity ? "1" : "0"
|
||||||
|
let permissions = options.permissions
|
||||||
|
.map { key, value in
|
||||||
|
let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return "\(trimmed)=\(value ? "1" : "0")"
|
||||||
|
}
|
||||||
|
.sorted()
|
||||||
|
.joined(separator: ",")
|
||||||
|
|
||||||
|
return [
|
||||||
|
role,
|
||||||
|
scopes,
|
||||||
|
caps,
|
||||||
|
commands,
|
||||||
|
clientId,
|
||||||
|
clientMode,
|
||||||
|
clientDisplayName,
|
||||||
|
includeDeviceIdentity,
|
||||||
|
permissions,
|
||||||
|
].joined(separator: "|")
|
||||||
|
}
|
||||||
|
|
||||||
public func connect(
|
public func connect(
|
||||||
url: URL,
|
url: URL,
|
||||||
token: String?,
|
token: String?,
|
||||||
@@ -113,9 +150,11 @@ public actor GatewayNodeSession {
|
|||||||
onDisconnected: @escaping @Sendable (String) async -> Void,
|
onDisconnected: @escaping @Sendable (String) async -> Void,
|
||||||
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse
|
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse
|
||||||
) async throws {
|
) async throws {
|
||||||
|
let nextOptionsKey = self.connectOptionsKey(connectOptions)
|
||||||
let shouldReconnect = self.activeURL != url ||
|
let shouldReconnect = self.activeURL != url ||
|
||||||
self.activeToken != token ||
|
self.activeToken != token ||
|
||||||
self.activePassword != password ||
|
self.activePassword != password ||
|
||||||
|
self.activeConnectOptionsKey != nextOptionsKey ||
|
||||||
self.channel == nil
|
self.channel == nil
|
||||||
|
|
||||||
self.connectOptions = connectOptions
|
self.connectOptions = connectOptions
|
||||||
@@ -138,12 +177,13 @@ public actor GatewayNodeSession {
|
|||||||
},
|
},
|
||||||
connectOptions: connectOptions,
|
connectOptions: connectOptions,
|
||||||
disconnectHandler: { [weak self] reason in
|
disconnectHandler: { [weak self] reason in
|
||||||
await self?.onDisconnected?(reason)
|
await self?.handleChannelDisconnected(reason)
|
||||||
})
|
})
|
||||||
self.channel = channel
|
self.channel = channel
|
||||||
self.activeURL = url
|
self.activeURL = url
|
||||||
self.activeToken = token
|
self.activeToken = token
|
||||||
self.activePassword = password
|
self.activePassword = password
|
||||||
|
self.activeConnectOptionsKey = nextOptionsKey
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let channel = self.channel else {
|
guard let channel = self.channel else {
|
||||||
@@ -157,7 +197,6 @@ public actor GatewayNodeSession {
|
|||||||
_ = await self.waitForSnapshot(timeoutMs: 500)
|
_ = await self.waitForSnapshot(timeoutMs: 500)
|
||||||
await self.notifyConnectedIfNeeded()
|
await self.notifyConnectedIfNeeded()
|
||||||
} catch {
|
} catch {
|
||||||
await onDisconnected(error.localizedDescription)
|
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -168,6 +207,7 @@ public actor GatewayNodeSession {
|
|||||||
self.activeURL = nil
|
self.activeURL = nil
|
||||||
self.activeToken = nil
|
self.activeToken = nil
|
||||||
self.activePassword = nil
|
self.activePassword = nil
|
||||||
|
self.activeConnectOptionsKey = nil
|
||||||
self.resetConnectionState()
|
self.resetConnectionState()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,6 +289,13 @@ public actor GatewayNodeSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func handleChannelDisconnected(_ reason: String) async {
|
||||||
|
// The underlying channel can auto-reconnect; resetting state here ensures we surface a fresh
|
||||||
|
// onConnected callback once a new snapshot arrives after reconnect.
|
||||||
|
self.resetConnectionState()
|
||||||
|
await self.onDisconnected?(reason)
|
||||||
|
}
|
||||||
|
|
||||||
private func markSnapshotReceived() {
|
private func markSnapshotReceived() {
|
||||||
self.snapshotReceived = true
|
self.snapshotReceived = true
|
||||||
if !self.snapshotWaiters.isEmpty {
|
if !self.snapshotWaiters.isEmpty {
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum OpenClawMotionCommand: String, Codable, Sendable {
|
||||||
|
case activity = "motion.activity"
|
||||||
|
case pedometer = "motion.pedometer"
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct OpenClawMotionActivityParams: Codable, Sendable, Equatable {
|
||||||
|
public var startISO: String?
|
||||||
|
public var endISO: String?
|
||||||
|
public var limit: Int?
|
||||||
|
|
||||||
|
public init(startISO: String? = nil, endISO: String? = nil, limit: Int? = nil) {
|
||||||
|
self.startISO = startISO
|
||||||
|
self.endISO = endISO
|
||||||
|
self.limit = limit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct OpenClawMotionActivityEntry: Codable, Sendable, Equatable {
|
||||||
|
public var startISO: String
|
||||||
|
public var endISO: String
|
||||||
|
public var confidence: String
|
||||||
|
public var isWalking: Bool
|
||||||
|
public var isRunning: Bool
|
||||||
|
public var isCycling: Bool
|
||||||
|
public var isAutomotive: Bool
|
||||||
|
public var isStationary: Bool
|
||||||
|
public var isUnknown: Bool
|
||||||
|
|
||||||
|
public init(
|
||||||
|
startISO: String,
|
||||||
|
endISO: String,
|
||||||
|
confidence: String,
|
||||||
|
isWalking: Bool,
|
||||||
|
isRunning: Bool,
|
||||||
|
isCycling: Bool,
|
||||||
|
isAutomotive: Bool,
|
||||||
|
isStationary: Bool,
|
||||||
|
isUnknown: Bool)
|
||||||
|
{
|
||||||
|
self.startISO = startISO
|
||||||
|
self.endISO = endISO
|
||||||
|
self.confidence = confidence
|
||||||
|
self.isWalking = isWalking
|
||||||
|
self.isRunning = isRunning
|
||||||
|
self.isCycling = isCycling
|
||||||
|
self.isAutomotive = isAutomotive
|
||||||
|
self.isStationary = isStationary
|
||||||
|
self.isUnknown = isUnknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct OpenClawMotionActivityPayload: Codable, Sendable, Equatable {
|
||||||
|
public var activities: [OpenClawMotionActivityEntry]
|
||||||
|
|
||||||
|
public init(activities: [OpenClawMotionActivityEntry]) {
|
||||||
|
self.activities = activities
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct OpenClawPedometerParams: Codable, Sendable, Equatable {
|
||||||
|
public var startISO: String?
|
||||||
|
public var endISO: String?
|
||||||
|
|
||||||
|
public init(startISO: String? = nil, endISO: String? = nil) {
|
||||||
|
self.startISO = startISO
|
||||||
|
self.endISO = endISO
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct OpenClawPedometerPayload: Codable, Sendable, Equatable {
|
||||||
|
public var startISO: String
|
||||||
|
public var endISO: String
|
||||||
|
public var steps: Int?
|
||||||
|
public var distanceMeters: Double?
|
||||||
|
public var floorsAscended: Int?
|
||||||
|
public var floorsDescended: Int?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
startISO: String,
|
||||||
|
endISO: String,
|
||||||
|
steps: Int?,
|
||||||
|
distanceMeters: Double?,
|
||||||
|
floorsAscended: Int?,
|
||||||
|
floorsDescended: Int?)
|
||||||
|
{
|
||||||
|
self.startISO = startISO
|
||||||
|
self.endISO = endISO
|
||||||
|
self.steps = steps
|
||||||
|
self.distanceMeters = distanceMeters
|
||||||
|
self.floorsAscended = floorsAscended
|
||||||
|
self.floorsDescended = floorsDescended
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum OpenClawPhotosCommand: String, Codable, Sendable {
|
||||||
|
case latest = "photos.latest"
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct OpenClawPhotosLatestParams: Codable, Sendable, Equatable {
|
||||||
|
public var limit: Int?
|
||||||
|
public var maxWidth: Int?
|
||||||
|
public var quality: Double?
|
||||||
|
|
||||||
|
public init(limit: Int? = nil, maxWidth: Int? = nil, quality: Double? = nil) {
|
||||||
|
self.limit = limit
|
||||||
|
self.maxWidth = maxWidth
|
||||||
|
self.quality = quality
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct OpenClawPhotoPayload: Codable, Sendable, Equatable {
|
||||||
|
public var format: String
|
||||||
|
public var base64: String
|
||||||
|
public var width: Int
|
||||||
|
public var height: Int
|
||||||
|
public var createdAt: String?
|
||||||
|
|
||||||
|
public init(format: String, base64: String, width: Int, height: Int, createdAt: String? = nil) {
|
||||||
|
self.format = format
|
||||||
|
self.base64 = base64
|
||||||
|
self.width = width
|
||||||
|
self.height = height
|
||||||
|
self.createdAt = createdAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct OpenClawPhotosLatestPayload: Codable, Sendable, Equatable {
|
||||||
|
public var photos: [OpenClawPhotoPayload]
|
||||||
|
|
||||||
|
public init(photos: [OpenClawPhotoPayload]) {
|
||||||
|
self.photos = photos
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum OpenClawRemindersCommand: String, Codable, Sendable {
|
||||||
|
case list = "reminders.list"
|
||||||
|
case add = "reminders.add"
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum OpenClawReminderStatusFilter: String, Codable, Sendable {
|
||||||
|
case incomplete
|
||||||
|
case completed
|
||||||
|
case all
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct OpenClawRemindersListParams: Codable, Sendable, Equatable {
|
||||||
|
public var status: OpenClawReminderStatusFilter?
|
||||||
|
public var limit: Int?
|
||||||
|
|
||||||
|
public init(status: OpenClawReminderStatusFilter? = nil, limit: Int? = nil) {
|
||||||
|
self.status = status
|
||||||
|
self.limit = limit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct OpenClawRemindersAddParams: Codable, Sendable, Equatable {
|
||||||
|
public var title: String
|
||||||
|
public var dueISO: String?
|
||||||
|
public var notes: String?
|
||||||
|
public var listId: String?
|
||||||
|
public var listName: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
title: String,
|
||||||
|
dueISO: String? = nil,
|
||||||
|
notes: String? = nil,
|
||||||
|
listId: String? = nil,
|
||||||
|
listName: String? = nil)
|
||||||
|
{
|
||||||
|
self.title = title
|
||||||
|
self.dueISO = dueISO
|
||||||
|
self.notes = notes
|
||||||
|
self.listId = listId
|
||||||
|
self.listName = listName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct OpenClawReminderPayload: Codable, Sendable, Equatable {
|
||||||
|
public var identifier: String
|
||||||
|
public var title: String
|
||||||
|
public var dueISO: String?
|
||||||
|
public var completed: Bool
|
||||||
|
public var listName: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
identifier: String,
|
||||||
|
title: String,
|
||||||
|
dueISO: String? = nil,
|
||||||
|
completed: Bool,
|
||||||
|
listName: String? = nil)
|
||||||
|
{
|
||||||
|
self.identifier = identifier
|
||||||
|
self.title = title
|
||||||
|
self.dueISO = dueISO
|
||||||
|
self.completed = completed
|
||||||
|
self.listName = listName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct OpenClawRemindersListPayload: Codable, Sendable, Equatable {
|
||||||
|
public var reminders: [OpenClawReminderPayload]
|
||||||
|
|
||||||
|
public init(reminders: [OpenClawReminderPayload]) {
|
||||||
|
self.reminders = reminders
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct OpenClawRemindersAddPayload: Codable, Sendable, Equatable {
|
||||||
|
public var reminder: OpenClawReminderPayload
|
||||||
|
|
||||||
|
public init(reminder: OpenClawReminderPayload) {
|
||||||
|
self.reminder = reminder
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum OpenClawTalkCommand: String, Codable, Sendable {
|
||||||
|
case pttStart = "talk.ptt.start"
|
||||||
|
case pttStop = "talk.ptt.stop"
|
||||||
|
case pttCancel = "talk.ptt.cancel"
|
||||||
|
case pttOnce = "talk.ptt.once"
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct OpenClawTalkPTTStartPayload: Codable, Sendable, Equatable {
|
||||||
|
public var captureId: String
|
||||||
|
|
||||||
|
public init(captureId: String) {
|
||||||
|
self.captureId = captureId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct OpenClawTalkPTTStopPayload: Codable, Sendable, Equatable {
|
||||||
|
public var captureId: String
|
||||||
|
public var transcript: String?
|
||||||
|
public var status: String
|
||||||
|
|
||||||
|
public init(captureId: String, transcript: String?, status: String) {
|
||||||
|
self.captureId = captureId
|
||||||
|
self.transcript = transcript
|
||||||
|
self.status = status
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user