Revert "iOS: align node permissions and notifications"

This reverts commit b17e6fdd07.
This commit is contained in:
Mariano Belinky 2026-01-31 09:32:29 +01:00
parent ed65131c1c
commit 821ed35be1
5 changed files with 8 additions and 290 deletions

View file

@ -1,39 +1,14 @@
import OpenClawKit import OpenClawKit
import AVFoundation
import CoreLocation
import Darwin import Darwin
import Foundation import Foundation
import Network import Network
import Observation import Observation
import ReplayKit
import SwiftUI import SwiftUI
import UIKit import UIKit
@MainActor @MainActor
@Observable @Observable
final class GatewayConnectionController { final class GatewayConnectionController {
struct PermissionStatusProvider: Sendable {
var cameraStatus: @Sendable () -> AVAuthorizationStatus
var microphoneStatus: @Sendable () -> AVAuthorizationStatus
var locationStatus: @Sendable () -> CLAuthorizationStatus
var locationServicesEnabled: @Sendable () -> Bool
var screenRecordingAvailable: @Sendable () -> Bool
static func live() -> PermissionStatusProvider {
PermissionStatusProvider(
cameraStatus: { AVCaptureDevice.authorizationStatus(for: .video) },
microphoneStatus: { AVCaptureDevice.authorizationStatus(for: .audio) },
locationStatus: {
if #available(iOS 14.0, *) {
return CLLocationManager.authorizationStatus()
}
return CLLocationManager().authorizationStatus
},
locationServicesEnabled: { CLLocationManager.locationServicesEnabled() },
screenRecordingAvailable: { RPScreenRecorder.shared().isAvailable })
}
}
private(set) var gateways: [GatewayDiscoveryModel.DiscoveredGateway] = [] private(set) var gateways: [GatewayDiscoveryModel.DiscoveredGateway] = []
private(set) var discoveryStatusText: String = "Idle" private(set) var discoveryStatusText: String = "Idle"
private(set) var discoveryDebugLog: [GatewayDiscoveryModel.DebugLogEntry] = [] private(set) var discoveryDebugLog: [GatewayDiscoveryModel.DebugLogEntry] = []
@ -41,15 +16,9 @@ final class GatewayConnectionController {
private let discovery = GatewayDiscoveryModel() private let discovery = GatewayDiscoveryModel()
private weak var appModel: NodeAppModel? private weak var appModel: NodeAppModel?
private var didAutoConnect = false private var didAutoConnect = false
private let permissionProvider: PermissionStatusProvider
init( init(appModel: NodeAppModel, startDiscovery: Bool = true) {
appModel: NodeAppModel,
startDiscovery: Bool = true,
permissionProvider: PermissionStatusProvider = PermissionStatusProvider.live())
{
self.appModel = appModel self.appModel = appModel
self.permissionProvider = permissionProvider
GatewaySettingsStore.bootstrapPersistence() GatewaySettingsStore.bootstrapPersistence()
let defaults = UserDefaults.standard let defaults = UserDefaults.standard
@ -313,7 +282,7 @@ final class GatewayConnectionController {
scopes: [], scopes: [],
caps: self.currentCaps(), caps: self.currentCaps(),
commands: self.currentCommands(), commands: self.currentCommands(),
permissions: self.currentPermissions(), permissions: [:],
clientId: "openclaw-ios", clientId: "openclaw-ios",
clientMode: "node", clientMode: "node",
clientDisplayName: displayName) clientDisplayName: displayName)
@ -366,6 +335,10 @@ final class GatewayConnectionController {
OpenClawCanvasA2UICommand.reset.rawValue, OpenClawCanvasA2UICommand.reset.rawValue,
OpenClawScreenCommand.record.rawValue, OpenClawScreenCommand.record.rawValue,
OpenClawSystemCommand.notify.rawValue, OpenClawSystemCommand.notify.rawValue,
OpenClawSystemCommand.which.rawValue,
OpenClawSystemCommand.run.rawValue,
OpenClawSystemCommand.execApprovalsGet.rawValue,
OpenClawSystemCommand.execApprovalsSet.rawValue,
] ]
let caps = Set(self.currentCaps()) let caps = Set(self.currentCaps())
@ -381,32 +354,6 @@ final class GatewayConnectionController {
return commands return commands
} }
private func currentPermissions() -> [String: Bool] {
let camera = self.permissionProvider.cameraStatus()
let microphone = self.permissionProvider.microphoneStatus()
let locationStatus = self.permissionProvider.locationStatus()
let locationEnabled = self.permissionProvider.locationServicesEnabled()
let screenRecordingAvailable = self.permissionProvider.screenRecordingAvailable()
return [
"camera": camera == .authorized,
"microphone": microphone == .authorized,
"location": locationEnabled && Self.isLocationAuthorized(status: locationStatus),
"screenRecording": screenRecordingAvailable,
]
}
private static func isLocationAuthorized(status: CLAuthorizationStatus) -> Bool {
switch status {
case .authorizedAlways, .authorizedWhenInUse:
return true
case .authorized:
return true
default:
return false
}
}
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 {
@ -460,10 +407,6 @@ 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()
} }

View file

@ -3,63 +3,6 @@ import Network
import Observation import Observation
import SwiftUI import SwiftUI
import UIKit import UIKit
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 in
self.center.add(request) { error in
if let error {
cont.resume(throwing: error)
} else {
cont.resume()
}
}
}
}
}
@MainActor @MainActor
@Observable @Observable
@ -85,7 +28,6 @@ final class NodeAppModel {
private let gateway = GatewayNodeSession() private let gateway = GatewayNodeSession()
private var gatewayTask: Task<Void, Never>? private var gatewayTask: Task<Void, Never>?
private var voiceWakeSyncTask: Task<Void, Never>? private var voiceWakeSyncTask: Task<Void, Never>?
private let notificationCenter: NotificationCentering
@ObservationIgnored private var cameraHUDDismissTask: Task<Void, Never>? @ObservationIgnored private var cameraHUDDismissTask: Task<Void, Never>?
let voiceWake = VoiceWakeManager() let voiceWake = VoiceWakeManager()
let talkMode = TalkModeManager() let talkMode = TalkModeManager()
@ -100,8 +42,7 @@ final class NodeAppModel {
var cameraFlashNonce: Int = 0 var cameraFlashNonce: Int = 0
var screenRecordActive: Bool = false var screenRecordActive: Bool = false
init(notificationCenter: NotificationCentering = LiveNotificationCenter()) { init() {
self.notificationCenter = notificationCenter
self.voiceWake.configure { [weak self] cmd in self.voiceWake.configure { [weak self] cmd in
guard let self else { return } guard let self else { return }
let sessionKey = await MainActor.run { self.mainSessionKey } let sessionKey = await MainActor.run { self.mainSessionKey }
@ -601,14 +542,12 @@ final class NodeAppModel {
return try await self.handleCameraInvoke(req) return try await self.handleCameraInvoke(req)
case OpenClawScreenCommand.record.rawValue: case OpenClawScreenCommand.record.rawValue:
return try await self.handleScreenRecordInvoke(req) return try await self.handleScreenRecordInvoke(req)
case OpenClawSystemCommand.notify.rawValue:
return try await self.handleSystemNotify(req)
default: default:
return BridgeInvokeResponse( return BridgeInvokeResponse(
id: req.id, id: req.id,
ok: false, ok: false,
error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command")) error: OpenClawNodeError(code: .invalidRequest, message: "INVALID_REQUEST: unknown command"))
} }
} catch { } catch {
if command.hasPrefix("camera.") { if command.hasPrefix("camera.") {
let text = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription let text = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
@ -689,7 +628,6 @@ final class NodeAppModel {
case OpenClawCanvasCommand.present.rawValue: case OpenClawCanvasCommand.present.rawValue:
let params = (try? Self.decodeParams(OpenClawCanvasPresentParams.self, from: req.paramsJSON)) ?? let params = (try? Self.decodeParams(OpenClawCanvasPresentParams.self, from: req.paramsJSON)) ??
OpenClawCanvasPresentParams() OpenClawCanvasPresentParams()
// iOS ignores placement params (canvas presents full-screen).
let url = params.url?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let url = params.url?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if url.isEmpty { if url.isEmpty {
self.screen.showDefaultCanvas() self.screen.showDefaultCanvas()
@ -698,7 +636,6 @@ final class NodeAppModel {
} }
return BridgeInvokeResponse(id: req.id, ok: true) return BridgeInvokeResponse(id: req.id, ok: true)
case OpenClawCanvasCommand.hide.rawValue: case OpenClawCanvasCommand.hide.rawValue:
self.showLocalCanvasOnDisconnect()
return BridgeInvokeResponse(id: req.id, ok: true) return BridgeInvokeResponse(id: req.id, ok: true)
case OpenClawCanvasCommand.navigate.rawValue: case OpenClawCanvasCommand.navigate.rawValue:
let params = try Self.decodeParams(OpenClawCanvasNavigateParams.self, from: req.paramsJSON) let params = try Self.decodeParams(OpenClawCanvasNavigateParams.self, from: req.paramsJSON)
@ -922,58 +859,6 @@ final class NodeAppModel {
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
} }
private func handleSystemNotify(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
let params = try Self.decodeParams(OpenClawSystemNotifyParams.self, from: req.paramsJSON)
let status = await self.notificationCenter.authorizationStatus()
let authorized: Bool
switch status {
case .authorized, .provisional, .ephemeral:
authorized = true
case .notDetermined:
authorized = (try await self.notificationCenter
.requestAuthorization(options: [.alert, .sound, .badge]))
case .denied:
authorized = false
}
guard authorized else {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: OpenClawNodeError(
code: .unavailable,
message: "NOTIFICATION_PERMISSION_REQUIRED: enable Notifications in Settings"))
}
let content = UNMutableNotificationContent()
content.title = params.title
content.body = params.body
let sound = params.sound?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if sound.isEmpty {
content.sound = .default
} else {
content.sound = UNNotificationSound(named: UNNotificationSoundName(rawValue: sound))
}
if let priority = params.priority {
switch priority {
case .passive:
content.interruptionLevel = .passive
case .active:
content.interruptionLevel = .active
case .timeSensitive:
content.interruptionLevel = .timeSensitive
}
}
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 0.1, repeats: false)
let request = UNNotificationRequest(
identifier: UUID().uuidString,
content: content,
trigger: trigger)
try await self.notificationCenter.add(request)
return BridgeInvokeResponse(id: req.id, ok: true)
}
} }
private extension NodeAppModel { private extension NodeAppModel {

View file

@ -1,6 +1,4 @@
import OpenClawKit import OpenClawKit
import AVFoundation
import CoreLocation
import Foundation import Foundation
import Testing import Testing
import UIKit import UIKit
@ -78,41 +76,4 @@ private func withUserDefaults<T>(_ updates: [String: Any?], _ body: () throws ->
#expect(commands.contains(OpenClawLocationCommand.get.rawValue)) #expect(commands.contains(OpenClawLocationCommand.get.rawValue))
} }
} }
@Test @MainActor func currentCommandsExcludeSystemExecButKeepNotify() {
withUserDefaults([
"node.instanceId": "ios-test",
]) {
let appModel = NodeAppModel()
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
let commands = Set(controller._test_currentCommands())
#expect(commands.contains(OpenClawSystemCommand.notify.rawValue))
#expect(commands.contains(OpenClawSystemCommand.run.rawValue) == false)
#expect(commands.contains(OpenClawSystemCommand.which.rawValue) == false)
#expect(commands.contains(OpenClawSystemCommand.execApprovalsGet.rawValue) == false)
#expect(commands.contains(OpenClawSystemCommand.execApprovalsSet.rawValue) == false)
}
}
@Test @MainActor func currentPermissionsIncludeExpectedKeys() {
let provider = GatewayConnectionController.PermissionStatusProvider(
cameraStatus: { .authorized },
microphoneStatus: { .denied },
locationStatus: { .authorizedWhenInUse },
locationServicesEnabled: { true },
screenRecordingAvailable: { false })
let appModel = NodeAppModel()
let controller = GatewayConnectionController(
appModel: appModel,
startDiscovery: false,
permissionProvider: provider)
let permissions = controller._test_currentPermissions()
#expect(permissions["camera"] == true)
#expect(permissions["microphone"] == false)
#expect(permissions["location"] == true)
#expect(permissions["screenRecording"] == false)
}
} }

View file

@ -124,11 +124,6 @@ private func withUserDefaults<T>(_ updates: [String: Any?], _ body: () throws ->
let payloadData = try #require(evalRes.payloadJSON?.data(using: .utf8)) let payloadData = try #require(evalRes.payloadJSON?.data(using: .utf8))
let payload = try JSONSerialization.jsonObject(with: payloadData) as? [String: Any] let payload = try JSONSerialization.jsonObject(with: payloadData) as? [String: Any]
#expect(payload?["result"] as? String == "2") #expect(payload?["result"] as? String == "2")
let hide = BridgeInvokeRequest(id: "hide", command: OpenClawCanvasCommand.hide.rawValue)
let hideRes = await appModel._test_handleInvoke(hide)
#expect(hideRes.ok == true)
#expect(appModel.screen.urlString.isEmpty)
} }
@Test @MainActor func handleInvokeA2UICommandsFailWhenHostMissing() async throws { @Test @MainActor func handleInvokeA2UICommandsFailWhenHostMissing() async throws {

View file

@ -1,66 +0,0 @@
import OpenClawKit
import Foundation
import Testing
import UserNotifications
@testable import OpenClaw
actor TestNotificationCenter: NotificationCentering {
private var status: NotificationAuthorizationStatus
private let requestResult: Bool
private var requestedAuthorization: Bool = false
private var storedRequests: [UNNotificationRequest] = []
init(status: NotificationAuthorizationStatus, requestResult: Bool) {
self.status = status
self.requestResult = requestResult
}
func authorizationStatus() async -> NotificationAuthorizationStatus {
status
}
func requestAuthorization(options _: UNAuthorizationOptions) async throws -> Bool {
self.requestedAuthorization = true
if self.requestResult {
self.status = .authorized
}
return self.requestResult
}
func add(_ request: UNNotificationRequest) async throws {
self.storedRequests.append(request)
}
func didRequestAuthorization() async -> Bool {
requestedAuthorization
}
func requests() async -> [UNNotificationRequest] {
storedRequests
}
}
@Suite(.serialized) struct NodeAppModelNotifyTests {
@Test @MainActor func handleSystemNotifyRequestsPermissionAndAddsNotification() async throws {
let center = TestNotificationCenter(status: .notDetermined, requestResult: true)
let appModel = NodeAppModel(notificationCenter: center)
let params = OpenClawSystemNotifyParams(title: "Hello", body: "World")
let data = try JSONEncoder().encode(params)
let json = String(decoding: data, as: UTF8.self)
let req = BridgeInvokeRequest(
id: "notify",
command: OpenClawSystemCommand.notify.rawValue,
paramsJSON: json)
let res = await appModel._test_handleInvoke(req)
#expect(res.ok == true)
#expect(await center.didRequestAuthorization() == true)
let requests = await center.requests()
#expect(requests.count == 1)
#expect(requests.first?.content.title == "Hello")
#expect(requests.first?.content.body == "World")
}
}