fix: land contributor PR #39516 from @Imhermes1
macOS app/chat/browser/cron/permissions fixes. Co-authored-by: ImHermes1 <lukeforn@gmail.com>
This commit is contained in:
@@ -110,6 +110,44 @@ actor GatewayConnection {
|
||||
private var subscribers: [UUID: AsyncStream<GatewayPush>.Continuation] = [:]
|
||||
private var lastSnapshot: HelloOk?
|
||||
|
||||
private struct LossyDecodable<Value: Decodable>: Decodable {
|
||||
let value: Value?
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
do {
|
||||
self.value = try Value(from: decoder)
|
||||
} catch {
|
||||
self.value = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct LossyCronListResponse: Decodable {
|
||||
let jobs: [LossyDecodable<CronJob>]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case jobs
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.jobs = try container.decodeIfPresent([LossyDecodable<CronJob>].self, forKey: .jobs) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
private struct LossyCronRunsResponse: Decodable {
|
||||
let entries: [LossyDecodable<CronRunLogEntry>]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case entries
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.entries = try container.decodeIfPresent([LossyDecodable<CronRunLogEntry>].self, forKey: .entries) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
init(
|
||||
configProvider: @escaping @Sendable () async throws -> Config = GatewayConnection.defaultConfigProvider,
|
||||
sessionBox: WebSocketSessionBox? = nil)
|
||||
@@ -703,17 +741,17 @@ extension GatewayConnection {
|
||||
}
|
||||
|
||||
func cronList(includeDisabled: Bool = true) async throws -> [CronJob] {
|
||||
let res: CronListResponse = try await self.requestDecoded(
|
||||
let data = try await self.requestRaw(
|
||||
method: .cronList,
|
||||
params: ["includeDisabled": AnyCodable(includeDisabled)])
|
||||
return res.jobs
|
||||
return try Self.decodeCronListResponse(data)
|
||||
}
|
||||
|
||||
func cronRuns(jobId: String, limit: Int = 200) async throws -> [CronRunLogEntry] {
|
||||
let res: CronRunsResponse = try await self.requestDecoded(
|
||||
let data = try await self.requestRaw(
|
||||
method: .cronRuns,
|
||||
params: ["id": AnyCodable(jobId), "limit": AnyCodable(limit)])
|
||||
return res.entries
|
||||
return try Self.decodeCronRunsResponse(data)
|
||||
}
|
||||
|
||||
func cronRun(jobId: String, force: Bool = true) async throws {
|
||||
@@ -739,4 +777,24 @@ extension GatewayConnection {
|
||||
func cronAdd(payload: [String: AnyCodable]) async throws {
|
||||
try await self.requestVoid(method: .cronAdd, params: payload)
|
||||
}
|
||||
|
||||
nonisolated static func decodeCronListResponse(_ data: Data) throws -> [CronJob] {
|
||||
let decoded = try JSONDecoder().decode(LossyCronListResponse.self, from: data)
|
||||
let jobs = decoded.jobs.compactMap(\.value)
|
||||
let skipped = decoded.jobs.count - jobs.count
|
||||
if skipped > 0 {
|
||||
gatewayConnectionLogger.warning("cron.list skipped \(skipped, privacy: .public) malformed jobs")
|
||||
}
|
||||
return jobs
|
||||
}
|
||||
|
||||
nonisolated static func decodeCronRunsResponse(_ data: Data) throws -> [CronRunLogEntry] {
|
||||
let decoded = try JSONDecoder().decode(LossyCronRunsResponse.self, from: data)
|
||||
let entries = decoded.entries.compactMap(\.value)
|
||||
let skipped = decoded.entries.count - entries.count
|
||||
if skipped > 0 {
|
||||
gatewayConnectionLogger.warning("cron.runs skipped \(skipped, privacy: .public) malformed entries")
|
||||
}
|
||||
return entries
|
||||
}
|
||||
}
|
||||
|
||||
@@ -614,6 +614,44 @@ actor GatewayEndpointStore {
|
||||
}
|
||||
|
||||
extension GatewayEndpointStore {
|
||||
static func localConfig() -> GatewayConnection.Config {
|
||||
self.localConfig(
|
||||
root: OpenClawConfigFile.loadDict(),
|
||||
env: ProcessInfo.processInfo.environment,
|
||||
launchdSnapshot: GatewayLaunchAgentManager.launchdConfigSnapshot(),
|
||||
tailscaleIP: TailscaleService.fallbackTailnetIPv4())
|
||||
}
|
||||
|
||||
static func localConfig(
|
||||
root: [String: Any],
|
||||
env: [String: String],
|
||||
launchdSnapshot: LaunchAgentPlistSnapshot?,
|
||||
tailscaleIP: String?) -> GatewayConnection.Config
|
||||
{
|
||||
let port = GatewayEnvironment.gatewayPort()
|
||||
let bind = self.resolveGatewayBindMode(root: root, env: env)
|
||||
let customBindHost = self.resolveGatewayCustomBindHost(root: root)
|
||||
let scheme = self.resolveGatewayScheme(root: root, env: env)
|
||||
let host = self.resolveLocalGatewayHost(
|
||||
bindMode: bind,
|
||||
customBindHost: customBindHost,
|
||||
tailscaleIP: tailscaleIP)
|
||||
let token = self.resolveGatewayToken(
|
||||
isRemote: false,
|
||||
root: root,
|
||||
env: env,
|
||||
launchdSnapshot: launchdSnapshot)
|
||||
let password = self.resolveGatewayPassword(
|
||||
isRemote: false,
|
||||
root: root,
|
||||
env: env,
|
||||
launchdSnapshot: launchdSnapshot)
|
||||
return (
|
||||
url: URL(string: "\(scheme)://\(host):\(port)")!,
|
||||
token: token,
|
||||
password: password)
|
||||
}
|
||||
|
||||
private static func normalizeDashboardPath(_ rawPath: String?) -> String {
|
||||
let trimmed = (rawPath ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return "/" }
|
||||
@@ -721,5 +759,18 @@ extension GatewayEndpointStore {
|
||||
customBindHost: customBindHost,
|
||||
tailscaleIP: tailscaleIP)
|
||||
}
|
||||
|
||||
static func _testLocalConfig(
|
||||
root: [String: Any],
|
||||
env: [String: String],
|
||||
launchdSnapshot: LaunchAgentPlistSnapshot? = nil,
|
||||
tailscaleIP: String? = nil) -> GatewayConnection.Config
|
||||
{
|
||||
self.localConfig(
|
||||
root: root,
|
||||
env: env,
|
||||
launchdSnapshot: launchdSnapshot,
|
||||
tailscaleIP: tailscaleIP)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
234
apps/macos/Sources/OpenClaw/NodeMode/MacNodeBrowserProxy.swift
Normal file
234
apps/macos/Sources/OpenClaw/NodeMode/MacNodeBrowserProxy.swift
Normal file
@@ -0,0 +1,234 @@
|
||||
import Foundation
|
||||
import OpenClawProtocol
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
actor MacNodeBrowserProxy {
|
||||
static let shared = MacNodeBrowserProxy()
|
||||
|
||||
struct Endpoint {
|
||||
let baseURL: URL
|
||||
let token: String?
|
||||
let password: String?
|
||||
}
|
||||
|
||||
private struct RequestParams: Decodable {
|
||||
let method: String?
|
||||
let path: String?
|
||||
let query: [String: OpenClawProtocol.AnyCodable]?
|
||||
let body: OpenClawProtocol.AnyCodable?
|
||||
let timeoutMs: Int?
|
||||
let profile: String?
|
||||
}
|
||||
|
||||
private struct ProxyFilePayload {
|
||||
let path: String
|
||||
let base64: String
|
||||
let mimeType: String?
|
||||
|
||||
func asJSON() -> [String: Any] {
|
||||
var json: [String: Any] = [
|
||||
"path": self.path,
|
||||
"base64": self.base64,
|
||||
]
|
||||
if let mimeType = self.mimeType {
|
||||
json["mimeType"] = mimeType
|
||||
}
|
||||
return json
|
||||
}
|
||||
}
|
||||
|
||||
private static let maxProxyFileBytes = 10 * 1024 * 1024
|
||||
private let endpointProvider: @Sendable () -> Endpoint
|
||||
private let performRequest: @Sendable (URLRequest) async throws -> (Data, URLResponse)
|
||||
|
||||
init(
|
||||
session: URLSession = .shared,
|
||||
endpointProvider: (@Sendable () -> Endpoint)? = nil,
|
||||
performRequest: (@Sendable (URLRequest) async throws -> (Data, URLResponse))? = nil)
|
||||
{
|
||||
self.endpointProvider = endpointProvider ?? MacNodeBrowserProxy.defaultEndpoint
|
||||
self.performRequest = performRequest ?? { request in
|
||||
try await session.data(for: request)
|
||||
}
|
||||
}
|
||||
|
||||
func request(paramsJSON: String?) async throws -> String {
|
||||
let params = try Self.decodeRequestParams(from: paramsJSON)
|
||||
let request = try Self.makeRequest(params: params, endpoint: self.endpointProvider())
|
||||
let (data, response) = try await self.performRequest(request)
|
||||
let http = try Self.requireHTTPResponse(response)
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
throw NSError(domain: "MacNodeBrowserProxy", code: http.statusCode, userInfo: [
|
||||
NSLocalizedDescriptionKey: Self.httpErrorMessage(statusCode: http.statusCode, data: data),
|
||||
])
|
||||
}
|
||||
|
||||
let result = try JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed])
|
||||
let files = try Self.loadProxyFiles(from: result)
|
||||
var payload: [String: Any] = ["result": result]
|
||||
if !files.isEmpty {
|
||||
payload["files"] = files.map { $0.asJSON() }
|
||||
}
|
||||
let payloadData = try JSONSerialization.data(withJSONObject: payload)
|
||||
guard let payloadJSON = String(data: payloadData, encoding: .utf8) else {
|
||||
throw NSError(domain: "MacNodeBrowserProxy", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: "browser proxy returned invalid UTF-8",
|
||||
])
|
||||
}
|
||||
return payloadJSON
|
||||
}
|
||||
|
||||
private static func defaultEndpoint() -> Endpoint {
|
||||
let config = GatewayEndpointStore.localConfig()
|
||||
let controlPort = GatewayEnvironment.gatewayPort() + 2
|
||||
let baseURL = URL(string: "http://127.0.0.1:\(controlPort)")!
|
||||
return Endpoint(baseURL: baseURL, token: config.token, password: config.password)
|
||||
}
|
||||
|
||||
private static func decodeRequestParams(from raw: String?) throws -> RequestParams {
|
||||
guard let raw else {
|
||||
throw NSError(domain: "MacNodeBrowserProxy", code: 3, userInfo: [
|
||||
NSLocalizedDescriptionKey: "INVALID_REQUEST: paramsJSON required",
|
||||
])
|
||||
}
|
||||
return try JSONDecoder().decode(RequestParams.self, from: Data(raw.utf8))
|
||||
}
|
||||
|
||||
private static func makeRequest(params: RequestParams, endpoint: Endpoint) throws -> URLRequest {
|
||||
let method = (params.method ?? "GET").trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
|
||||
let path = (params.path ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !path.isEmpty else {
|
||||
throw NSError(domain: "MacNodeBrowserProxy", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "INVALID_REQUEST: path required",
|
||||
])
|
||||
}
|
||||
|
||||
let normalizedPath = path.hasPrefix("/") ? path : "/\(path)"
|
||||
guard var components = URLComponents(
|
||||
url: endpoint.baseURL.appendingPathComponent(String(normalizedPath.dropFirst())),
|
||||
resolvingAgainstBaseURL: false)
|
||||
else {
|
||||
throw NSError(domain: "MacNodeBrowserProxy", code: 4, userInfo: [
|
||||
NSLocalizedDescriptionKey: "INVALID_REQUEST: invalid browser proxy URL",
|
||||
])
|
||||
}
|
||||
|
||||
var queryItems: [URLQueryItem] = []
|
||||
if let query = params.query {
|
||||
for key in query.keys.sorted() {
|
||||
let value = query[key]?.value
|
||||
guard value != nil, !(value is NSNull) else { continue }
|
||||
queryItems.append(URLQueryItem(name: key, value: Self.stringValue(for: value)))
|
||||
}
|
||||
}
|
||||
let profile = params.profile?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if !profile.isEmpty && !queryItems.contains(where: { $0.name == "profile" }) {
|
||||
queryItems.append(URLQueryItem(name: "profile", value: profile))
|
||||
}
|
||||
if !queryItems.isEmpty {
|
||||
components.queryItems = queryItems
|
||||
}
|
||||
guard let url = components.url else {
|
||||
throw NSError(domain: "MacNodeBrowserProxy", code: 5, userInfo: [
|
||||
NSLocalizedDescriptionKey: "INVALID_REQUEST: invalid browser proxy URL",
|
||||
])
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = method
|
||||
request.timeoutInterval = params.timeoutMs.map { TimeInterval(max($0, 1)) / 1000 } ?? 5
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
if let token = endpoint.token?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty {
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
} else if let password = endpoint.password?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!password.isEmpty
|
||||
{
|
||||
request.setValue(password, forHTTPHeaderField: "x-openclaw-password")
|
||||
}
|
||||
|
||||
if method != "GET", let body = params.body?.value {
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: body, options: [.fragmentsAllowed])
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
}
|
||||
|
||||
return request
|
||||
}
|
||||
|
||||
private static func requireHTTPResponse(_ response: URLResponse) throws -> HTTPURLResponse {
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw NSError(domain: "MacNodeBrowserProxy", code: 6, userInfo: [
|
||||
NSLocalizedDescriptionKey: "browser proxy returned a non-HTTP response",
|
||||
])
|
||||
}
|
||||
return http
|
||||
}
|
||||
|
||||
private static func httpErrorMessage(statusCode: Int, data: Data) -> String {
|
||||
if let object = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) as? [String: Any],
|
||||
let error = object["error"] as? String,
|
||||
!error.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
{
|
||||
return error
|
||||
}
|
||||
if let text = String(data: data, encoding: .utf8)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!text.isEmpty
|
||||
{
|
||||
return text
|
||||
}
|
||||
return "HTTP \(statusCode)"
|
||||
}
|
||||
|
||||
private static func stringValue(for value: Any?) -> String? {
|
||||
guard let value else { return nil }
|
||||
if let string = value as? String { return string }
|
||||
if let bool = value as? Bool { return bool ? "true" : "false" }
|
||||
if let number = value as? NSNumber { return number.stringValue }
|
||||
return String(describing: value)
|
||||
}
|
||||
|
||||
private static func loadProxyFiles(from result: Any) throws -> [ProxyFilePayload] {
|
||||
let paths = self.collectProxyPaths(from: result)
|
||||
return try paths.map(self.loadProxyFile)
|
||||
}
|
||||
|
||||
private static func collectProxyPaths(from payload: Any) -> [String] {
|
||||
guard let object = payload as? [String: Any] else { return [] }
|
||||
|
||||
var paths = Set<String>()
|
||||
if let path = object["path"] as? String, !path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
paths.insert(path.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
}
|
||||
if let imagePath = object["imagePath"] as? String,
|
||||
!imagePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
{
|
||||
paths.insert(imagePath.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
}
|
||||
if let download = object["download"] as? [String: Any],
|
||||
let path = download["path"] as? String,
|
||||
!path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
{
|
||||
paths.insert(path.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
}
|
||||
return paths.sorted()
|
||||
}
|
||||
|
||||
private static func loadProxyFile(path: String) throws -> ProxyFilePayload {
|
||||
let url = URL(fileURLWithPath: path)
|
||||
let values = try url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey])
|
||||
guard values.isRegularFile == true else {
|
||||
throw NSError(domain: "MacNodeBrowserProxy", code: 7, userInfo: [
|
||||
NSLocalizedDescriptionKey: "browser proxy file not found: \(path)",
|
||||
])
|
||||
}
|
||||
if let fileSize = values.fileSize, fileSize > Self.maxProxyFileBytes {
|
||||
throw NSError(domain: "MacNodeBrowserProxy", code: 8, userInfo: [
|
||||
NSLocalizedDescriptionKey: "browser proxy file exceeds 10MB: \(path)",
|
||||
])
|
||||
}
|
||||
|
||||
let data = try Data(contentsOf: url)
|
||||
let mimeType = UTType(filenameExtension: url.pathExtension)?.preferredMIMEType
|
||||
return ProxyFilePayload(path: path, base64: data.base64EncodedString(), mimeType: mimeType)
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ final class MacNodeModeCoordinator {
|
||||
private func run() async {
|
||||
var retryDelay: UInt64 = 1_000_000_000
|
||||
var lastCameraEnabled: Bool?
|
||||
var lastBrowserControlEnabled: Bool?
|
||||
let defaults = UserDefaults.standard
|
||||
|
||||
while !Task.isCancelled {
|
||||
@@ -48,6 +49,14 @@ final class MacNodeModeCoordinator {
|
||||
await self.session.disconnect()
|
||||
try? await Task.sleep(nanoseconds: 200_000_000)
|
||||
}
|
||||
let browserControlEnabled = OpenClawConfigFile.browserControlEnabled()
|
||||
if lastBrowserControlEnabled == nil {
|
||||
lastBrowserControlEnabled = browserControlEnabled
|
||||
} else if lastBrowserControlEnabled != browserControlEnabled {
|
||||
lastBrowserControlEnabled = browserControlEnabled
|
||||
await self.session.disconnect()
|
||||
try? await Task.sleep(nanoseconds: 200_000_000)
|
||||
}
|
||||
|
||||
do {
|
||||
let config = try await GatewayEndpointStore.shared.requireConfig()
|
||||
@@ -108,6 +117,9 @@ final class MacNodeModeCoordinator {
|
||||
|
||||
private func currentCaps() -> [String] {
|
||||
var caps: [String] = [OpenClawCapability.canvas.rawValue, OpenClawCapability.screen.rawValue]
|
||||
if OpenClawConfigFile.browserControlEnabled() {
|
||||
caps.append(OpenClawCapability.browser.rawValue)
|
||||
}
|
||||
if UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false {
|
||||
caps.append(OpenClawCapability.camera.rawValue)
|
||||
}
|
||||
@@ -142,6 +154,9 @@ final class MacNodeModeCoordinator {
|
||||
]
|
||||
|
||||
let capsSet = Set(caps)
|
||||
if capsSet.contains(OpenClawCapability.browser.rawValue) {
|
||||
commands.append(OpenClawBrowserCommand.proxy.rawValue)
|
||||
}
|
||||
if capsSet.contains(OpenClawCapability.camera.rawValue) {
|
||||
commands.append(OpenClawCameraCommand.list.rawValue)
|
||||
commands.append(OpenClawCameraCommand.snap.rawValue)
|
||||
|
||||
@@ -6,6 +6,7 @@ import OpenClawKit
|
||||
actor MacNodeRuntime {
|
||||
private let cameraCapture = CameraCaptureService()
|
||||
private let makeMainActorServices: () async -> any MacNodeRuntimeMainActorServices
|
||||
private let browserProxyRequest: @Sendable (String?) async throws -> String
|
||||
private var cachedMainActorServices: (any MacNodeRuntimeMainActorServices)?
|
||||
private var mainSessionKey: String = "main"
|
||||
private var eventSender: (@Sendable (String, String?) async -> Void)?
|
||||
@@ -13,9 +14,13 @@ actor MacNodeRuntime {
|
||||
init(
|
||||
makeMainActorServices: @escaping () async -> any MacNodeRuntimeMainActorServices = {
|
||||
await MainActor.run { LiveMacNodeRuntimeMainActorServices() }
|
||||
},
|
||||
browserProxyRequest: @escaping @Sendable (String?) async throws -> String = { paramsJSON in
|
||||
try await MacNodeBrowserProxy.shared.request(paramsJSON: paramsJSON)
|
||||
})
|
||||
{
|
||||
self.makeMainActorServices = makeMainActorServices
|
||||
self.browserProxyRequest = browserProxyRequest
|
||||
}
|
||||
|
||||
func updateMainSessionKey(_ sessionKey: String) {
|
||||
@@ -50,6 +55,8 @@ actor MacNodeRuntime {
|
||||
OpenClawCanvasA2UICommand.push.rawValue,
|
||||
OpenClawCanvasA2UICommand.pushJSONL.rawValue:
|
||||
return try await self.handleA2UIInvoke(req)
|
||||
case OpenClawBrowserCommand.proxy.rawValue:
|
||||
return try await self.handleBrowserProxyInvoke(req)
|
||||
case OpenClawCameraCommand.snap.rawValue,
|
||||
OpenClawCameraCommand.clip.rawValue,
|
||||
OpenClawCameraCommand.list.rawValue:
|
||||
@@ -165,6 +172,19 @@ actor MacNodeRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
private func handleBrowserProxyInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||
guard OpenClawConfigFile.browserControlEnabled() else {
|
||||
return BridgeInvokeResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: OpenClawNodeError(
|
||||
code: .unavailable,
|
||||
message: "BROWSER_DISABLED: enable Browser in Settings"))
|
||||
}
|
||||
let payloadJSON = try await self.browserProxyRequest(req.paramsJSON)
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payloadJSON)
|
||||
}
|
||||
|
||||
private func handleCameraInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||
guard Self.cameraEnabled() else {
|
||||
return BridgeInvokeResponse(
|
||||
|
||||
@@ -9,24 +9,28 @@ struct PermissionsSettings: View {
|
||||
let showOnboarding: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
SystemRunSettingsView()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
SystemRunSettingsView()
|
||||
|
||||
Text("Allow these so OpenClaw can notify and capture when needed.")
|
||||
.padding(.top, 4)
|
||||
Text("Allow these so OpenClaw can notify and capture when needed.")
|
||||
.padding(.top, 4)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
PermissionStatusList(status: self.status, refresh: self.refresh)
|
||||
.padding(.horizontal, 2)
|
||||
.padding(.vertical, 6)
|
||||
PermissionStatusList(status: self.status, refresh: self.refresh)
|
||||
.padding(.horizontal, 2)
|
||||
.padding(.vertical, 6)
|
||||
|
||||
LocationAccessSettings()
|
||||
LocationAccessSettings()
|
||||
|
||||
Button("Restart onboarding") { self.showOnboarding() }
|
||||
.buttonStyle(.bordered)
|
||||
Spacer()
|
||||
Button("Restart onboarding") { self.showOnboarding() }
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 12)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,11 +103,16 @@ private struct LocationAccessSettings: View {
|
||||
struct PermissionStatusList: View {
|
||||
let status: [Capability: Bool]
|
||||
let refresh: () async -> Void
|
||||
@State private var pendingCapability: Capability?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
ForEach(Capability.allCases, id: \.self) { cap in
|
||||
PermissionRow(capability: cap, status: self.status[cap] ?? false) {
|
||||
PermissionRow(
|
||||
capability: cap,
|
||||
status: self.status[cap] ?? false,
|
||||
isPending: self.pendingCapability == cap)
|
||||
{
|
||||
Task { await self.handle(cap) }
|
||||
}
|
||||
}
|
||||
@@ -122,20 +131,43 @@ struct PermissionStatusList: View {
|
||||
|
||||
@MainActor
|
||||
private func handle(_ cap: Capability) async {
|
||||
guard self.pendingCapability == nil else { return }
|
||||
self.pendingCapability = cap
|
||||
defer { self.pendingCapability = nil }
|
||||
|
||||
_ = await PermissionManager.ensure([cap], interactive: true)
|
||||
await self.refreshStatusTransitions()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func refreshStatusTransitions() async {
|
||||
await self.refresh()
|
||||
|
||||
// TCC and notification settings can settle after the prompt closes or when the app regains focus.
|
||||
for delay in [300_000_000, 900_000_000, 1_800_000_000] {
|
||||
try? await Task.sleep(nanoseconds: UInt64(delay))
|
||||
await self.refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PermissionRow: View {
|
||||
let capability: Capability
|
||||
let status: Bool
|
||||
let isPending: Bool
|
||||
let compact: Bool
|
||||
let action: () -> Void
|
||||
|
||||
init(capability: Capability, status: Bool, compact: Bool = false, action: @escaping () -> Void) {
|
||||
init(
|
||||
capability: Capability,
|
||||
status: Bool,
|
||||
isPending: Bool = false,
|
||||
compact: Bool = false,
|
||||
action: @escaping () -> Void)
|
||||
{
|
||||
self.capability = capability
|
||||
self.status = status
|
||||
self.isPending = isPending
|
||||
self.compact = compact
|
||||
self.action = action
|
||||
}
|
||||
@@ -150,17 +182,49 @@ struct PermissionRow: View {
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(self.title).font(.body.weight(.semibold))
|
||||
Text(self.subtitle).font(.caption).foregroundStyle(.secondary)
|
||||
Text(self.subtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
Spacer()
|
||||
if self.status {
|
||||
Label("Granted", systemImage: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
} else {
|
||||
Button("Grant") { self.action() }
|
||||
.buttonStyle(.bordered)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.layoutPriority(1)
|
||||
VStack(alignment: .trailing, spacing: 4) {
|
||||
if self.status {
|
||||
Label("Granted", systemImage: "checkmark.circle.fill")
|
||||
.labelStyle(.iconOnly)
|
||||
.foregroundStyle(.green)
|
||||
.font(.title3)
|
||||
.help("Granted")
|
||||
} else if self.isPending {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
.frame(width: 78)
|
||||
} else {
|
||||
Button("Grant") { self.action() }
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(self.compact ? .small : .regular)
|
||||
.frame(minWidth: self.compact ? 68 : 78, alignment: .trailing)
|
||||
}
|
||||
|
||||
if self.status {
|
||||
Text("Granted")
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.green)
|
||||
} else if self.isPending {
|
||||
Text("Checking…")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Text("Request access")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.frame(minWidth: self.compact ? 86 : 104, alignment: .trailing)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.vertical, self.compact ? 4 : 6)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import AppKit
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
@@ -98,6 +99,10 @@ struct SettingsRootView: View {
|
||||
.onChange(of: self.selectedTab) { _, newValue in
|
||||
self.updatePermissionMonitoring(for: newValue)
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
|
||||
guard self.selectedTab == .permissions else { return }
|
||||
Task { await self.refreshPerms() }
|
||||
}
|
||||
.onDisappear { self.stopPermissionMonitoring() }
|
||||
.task {
|
||||
guard !self.isPreview else { return }
|
||||
|
||||
Reference in New Issue
Block a user