refactor(macos): drop duplicate AnyCodable

This commit is contained in:
Peter Steinberger
2026-01-07 19:30:01 +00:00
parent e4f62c5b0c
commit 8913bfbcd5
24 changed files with 64 additions and 90 deletions

View File

@@ -1,3 +1,4 @@
import ClawdbotProtocol
import SwiftUI import SwiftUI
@MainActor @MainActor

View File

@@ -1,54 +0,0 @@
import Foundation
/// Lightweight `Codable` wrapper that round-trips heterogeneous JSON payloads.
/// Marked `@unchecked Sendable` because it can hold reference types.
struct AnyCodable: Codable, @unchecked Sendable {
let value: Any
init(_ value: Any) { self.value = value }
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let intVal = try? container.decode(Int.self) { self.value = intVal; return }
if let doubleVal = try? container.decode(Double.self) { self.value = doubleVal; return }
if let boolVal = try? container.decode(Bool.self) { self.value = boolVal; return }
if let stringVal = try? container.decode(String.self) { self.value = stringVal; return }
if container.decodeNil() { self.value = NSNull(); return }
if let dict = try? container.decode([String: AnyCodable].self) { self.value = dict; return }
if let array = try? container.decode([AnyCodable].self) { self.value = array; return }
throw DecodingError.dataCorruptedError(
in: container,
debugDescription: "Unsupported type")
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self.value {
case let intVal as Int: try container.encode(intVal)
case let doubleVal as Double: try container.encode(doubleVal)
case let boolVal as Bool: try container.encode(boolVal)
case let stringVal as String: try container.encode(stringVal)
case is NSNull: try container.encodeNil()
case let dict as [String: AnyCodable]: try container.encode(dict)
case let array as [AnyCodable]: try container.encode(array)
case let dict as [String: Any]:
try container.encode(dict.mapValues { AnyCodable($0) })
case let array as [Any]:
try container.encode(array.map { AnyCodable($0) })
case let dict as NSDictionary:
var converted: [String: AnyCodable] = [:]
for (k, v) in dict {
guard let key = k as? String else { continue }
converted[key] = AnyCodable(v)
}
try container.encode(converted)
case let array as NSArray:
try container.encode(array.map { AnyCodable($0) })
default:
let context = EncodingError.Context(
codingPath: encoder.codingPath,
debugDescription: "Unsupported type")
throw EncodingError.invalidValue(self.value, context)
}
}
}

View File

@@ -229,7 +229,7 @@ actor BridgeServer {
error: BridgeRPCError(code: "FORBIDDEN", message: "Method not allowed")) error: BridgeRPCError(code: "FORBIDDEN", message: "Method not allowed"))
} }
let params: [String: AnyCodable]? let params: [String: ClawdbotProtocol.AnyCodable]?
if let json = req.paramsJSON?.trimmingCharacters(in: .whitespacesAndNewlines), !json.isEmpty { if let json = req.paramsJSON?.trimmingCharacters(in: .whitespacesAndNewlines), !json.isEmpty {
guard let data = json.data(using: .utf8) else { guard let data = json.data(using: .utf8) else {
return BridgeRPCResponse( return BridgeRPCResponse(
@@ -238,7 +238,7 @@ actor BridgeServer {
error: BridgeRPCError(code: "INVALID_REQUEST", message: "paramsJSON not UTF-8")) error: BridgeRPCError(code: "INVALID_REQUEST", message: "paramsJSON not UTF-8"))
} }
do { do {
params = try JSONDecoder().decode([String: AnyCodable].self, from: data) params = try JSONDecoder().decode([String: ClawdbotProtocol.AnyCodable].self, from: data)
} catch { } catch {
return BridgeRPCResponse( return BridgeRPCResponse(
id: req.id, id: req.id,
@@ -360,16 +360,16 @@ actor BridgeServer {
"reason \(reason)", "reason \(reason)",
].compactMap(\.self).joined(separator: " · ") ].compactMap(\.self).joined(separator: " · ")
var params: [String: AnyCodable] = [ var params: [String: ClawdbotProtocol.AnyCodable] = [
"text": AnyCodable(summary), "text": ClawdbotProtocol.AnyCodable(summary),
"instanceId": AnyCodable(nodeId), "instanceId": ClawdbotProtocol.AnyCodable(nodeId),
"host": AnyCodable(host), "host": ClawdbotProtocol.AnyCodable(host),
"mode": AnyCodable("node"), "mode": ClawdbotProtocol.AnyCodable("node"),
"reason": AnyCodable(reason), "reason": ClawdbotProtocol.AnyCodable(reason),
"tags": AnyCodable(tags), "tags": ClawdbotProtocol.AnyCodable(tags),
] ]
if let ip { params["ip"] = AnyCodable(ip) } if let ip { params["ip"] = ClawdbotProtocol.AnyCodable(ip) }
if let version { params["version"] = AnyCodable(version) } if let version { params["version"] = ClawdbotProtocol.AnyCodable(version) }
await GatewayConnection.shared.sendSystemEvent(params) await GatewayConnection.shared.sendSystemEvent(params)
} }

View File

@@ -1,3 +1,4 @@
import ClawdbotProtocol
import Foundation import Foundation
enum ClawdbotConfigFile { enum ClawdbotConfigFile {

View File

@@ -3,9 +3,9 @@ import Foundation
enum ClawdbotEnv { enum ClawdbotEnv {
static func path(_ key: String) -> String? { static func path(_ key: String) -> String? {
// Normalize env overrides once so UI + file IO stay consistent. // Normalize env overrides once so UI + file IO stay consistent.
guard let value = ProcessInfo.processInfo.environment[key]? guard let raw = getenv(key) else { return nil }
.trimmingCharacters(in: .whitespacesAndNewlines), let value = String(cString: raw).trimmingCharacters(in: .whitespacesAndNewlines)
!value.isEmpty guard !value.isEmpty
else { else {
return nil return nil
} }

View File

@@ -1,3 +1,4 @@
import ClawdbotProtocol
import Foundation import Foundation
enum ConfigStore { enum ConfigStore {

View File

@@ -1,3 +1,4 @@
import ClawdbotProtocol
import Foundation import Foundation
import SwiftUI import SwiftUI

View File

@@ -1,3 +1,4 @@
import ClawdbotProtocol
import SwiftUI import SwiftUI
struct CronJobEditor: View { struct CronJobEditor: View {

View File

@@ -1,3 +1,4 @@
import ClawdbotProtocol
import Foundation import Foundation
extension CronSettings { extension CronSettings {

View File

@@ -1,4 +1,5 @@
import ClawdbotKit import ClawdbotKit
import ClawdbotProtocol
import Foundation import Foundation
import Observation import Observation
import SwiftUI import SwiftUI
@@ -53,7 +54,7 @@ final class WorkActivityStore {
phase: String, phase: String,
name: String?, name: String?,
meta: String?, meta: String?,
args: [String: AnyCodable]?) args: [String: ClawdbotProtocol.AnyCodable]?)
{ {
let toolKind = Self.mapToolKind(name) let toolKind = Self.mapToolKind(name)
let label = Self.buildLabel(name: name, meta: meta, args: args) let label = Self.buildLabel(name: name, meta: meta, args: args)
@@ -211,7 +212,7 @@ final class WorkActivityStore {
private static func buildLabel( private static func buildLabel(
name: String?, name: String?,
meta: String?, meta: String?,
args: [String: AnyCodable]?) -> String args: [String: ClawdbotProtocol.AnyCodable]?) -> String
{ {
let wrappedArgs = self.wrapToolArgs(args) let wrappedArgs = self.wrapToolArgs(args)
let display = ToolDisplayRegistry.resolve(name: name ?? "tool", args: wrappedArgs, meta: meta) let display = ToolDisplayRegistry.resolve(name: name ?? "tool", args: wrappedArgs, meta: meta)
@@ -221,17 +222,17 @@ final class WorkActivityStore {
return display.label return display.label
} }
private static func wrapToolArgs(_ args: [String: AnyCodable]?) -> ClawdbotKit.AnyCodable? { private static func wrapToolArgs(_ args: [String: ClawdbotProtocol.AnyCodable]?) -> ClawdbotKit.AnyCodable? {
guard let args else { return nil } guard let args else { return nil }
let converted: [String: Any] = args.mapValues { self.unwrapJSONValue($0.value) } let converted: [String: Any] = args.mapValues { self.unwrapJSONValue($0.value) }
return ClawdbotKit.AnyCodable(converted) return ClawdbotKit.AnyCodable(converted)
} }
private static func unwrapJSONValue(_ value: Any) -> Any { private static func unwrapJSONValue(_ value: Any) -> Any {
if let dict = value as? [String: AnyCodable] { if let dict = value as? [String: ClawdbotProtocol.AnyCodable] {
return dict.mapValues { self.unwrapJSONValue($0.value) } return dict.mapValues { self.unwrapJSONValue($0.value) }
} }
if let array = value as? [AnyCodable] { if let array = value as? [ClawdbotProtocol.AnyCodable] {
return array.map { self.unwrapJSONValue($0.value) } return array.map { self.unwrapJSONValue($0.value) }
} }
if let dict = value as? [String: Any] { if let dict = value as? [String: Any] {

View File

@@ -1,5 +1,6 @@
import Foundation import Foundation
import Testing import Testing
import ClawdbotProtocol
@testable import Clawdbot @testable import Clawdbot
@Suite @Suite
@@ -15,7 +16,7 @@ struct AgentEventStoreTests {
seq: 1, seq: 1,
stream: "test", stream: "test",
ts: 0, ts: 0,
data: [:] as [String: AnyCodable], data: [:] as [String: ClawdbotProtocol.AnyCodable],
summary: nil)) summary: nil))
#expect(store.events.count == 1) #expect(store.events.count == 1)
@@ -32,7 +33,7 @@ struct AgentEventStoreTests {
seq: i, seq: i,
stream: "test", stream: "test",
ts: Double(i), ts: Double(i),
data: [:] as [String: AnyCodable], data: [:] as [String: ClawdbotProtocol.AnyCodable],
summary: nil)) summary: nil))
} }

View File

@@ -12,7 +12,7 @@ import Testing
"null": NSNull(), "null": NSNull(),
] ]
let data = try JSONEncoder().encode(Clawdbot.AnyCodable(payload)) let data = try JSONEncoder().encode(ClawdbotProtocol.AnyCodable(payload))
let obj = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) let obj = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
#expect(obj["tags"] as? [String] == ["node", "ios"]) #expect(obj["tags"] as? [String] == ["node", "ios"])

View File

@@ -35,7 +35,7 @@ struct CronJobEditorSmokeTests {
thinking: "low", thinking: "low",
timeoutSeconds: 120, timeoutSeconds: 120,
deliver: true, deliver: true,
channel: "whatsapp", provider: "whatsapp",
to: "+15551234567", to: "+15551234567",
bestEffortDeliver: true), bestEffortDeliver: true),
isolation: CronIsolation(postToMainPrefix: "Cron"), isolation: CronIsolation(postToMainPrefix: "Cron"),

View File

@@ -31,7 +31,7 @@ struct CronModelsTests {
thinking: "low", thinking: "low",
timeoutSeconds: 15, timeoutSeconds: 15,
deliver: true, deliver: true,
channel: "whatsapp", provider: "whatsapp",
to: "+15551234567", to: "+15551234567",
bestEffortDeliver: false) bestEffortDeliver: false)
let data = try JSONEncoder().encode(payload) let data = try JSONEncoder().encode(payload)

View File

@@ -170,7 +170,7 @@ import Testing
let url = URL(string: "ws://example.invalid")! let url = URL(string: "ws://example.invalid")!
let cfg = ConfigSource(token: nil) let cfg = ConfigSource(token: nil)
let conn = GatewayConnection( let conn = GatewayConnection(
configProvider: { (url, cfg.snapshotToken()) }, configProvider: { (url: url, token: cfg.snapshotToken(), password: nil) },
sessionBox: WebSocketSessionBox(session: session)) sessionBox: WebSocketSessionBox(session: session))
_ = try await conn.request(method: "status", params: nil) _ = try await conn.request(method: "status", params: nil)
@@ -186,7 +186,7 @@ import Testing
let url = URL(string: "ws://example.invalid")! let url = URL(string: "ws://example.invalid")!
let cfg = ConfigSource(token: "a") let cfg = ConfigSource(token: "a")
let conn = GatewayConnection( let conn = GatewayConnection(
configProvider: { (url, cfg.snapshotToken()) }, configProvider: { (url: url, token: cfg.snapshotToken(), password: nil) },
sessionBox: WebSocketSessionBox(session: session)) sessionBox: WebSocketSessionBox(session: session))
_ = try await conn.request(method: "status", params: nil) _ = try await conn.request(method: "status", params: nil)
@@ -203,7 +203,7 @@ import Testing
let url = URL(string: "ws://example.invalid")! let url = URL(string: "ws://example.invalid")!
let cfg = ConfigSource(token: nil) let cfg = ConfigSource(token: nil)
let conn = GatewayConnection( let conn = GatewayConnection(
configProvider: { (url, cfg.snapshotToken()) }, configProvider: { (url: url, token: cfg.snapshotToken(), password: nil) },
sessionBox: WebSocketSessionBox(session: session)) sessionBox: WebSocketSessionBox(session: session))
async let r1: Data = conn.request(method: "status", params: nil) async let r1: Data = conn.request(method: "status", params: nil)
@@ -218,7 +218,7 @@ import Testing
let url = URL(string: "ws://example.invalid")! let url = URL(string: "ws://example.invalid")!
let cfg = ConfigSource(token: nil) let cfg = ConfigSource(token: nil)
let conn = GatewayConnection( let conn = GatewayConnection(
configProvider: { (url, cfg.snapshotToken()) }, configProvider: { (url: url, token: cfg.snapshotToken(), password: nil) },
sessionBox: WebSocketSessionBox(session: session)) sessionBox: WebSocketSessionBox(session: session))
_ = try await conn.request(method: "status", params: nil) _ = try await conn.request(method: "status", params: nil)
@@ -239,7 +239,7 @@ import Testing
let url = URL(string: "ws://example.invalid")! let url = URL(string: "ws://example.invalid")!
let cfg = ConfigSource(token: nil) let cfg = ConfigSource(token: nil)
let conn = GatewayConnection( let conn = GatewayConnection(
configProvider: { (url, cfg.snapshotToken()) }, configProvider: { (url: url, token: cfg.snapshotToken(), password: nil) },
sessionBox: WebSocketSessionBox(session: session)) sessionBox: WebSocketSessionBox(session: session))
let stream = await conn.subscribe(bufferingNewest: 10) let stream = await conn.subscribe(bufferingNewest: 10)

View File

@@ -20,11 +20,27 @@ import Testing
} }
@Test func gatewayPortDefaultsAndRespectsOverride() { @Test func gatewayPortDefaultsAndRespectsOverride() {
let envKey = "CLAWDBOT_CONFIG_PATH"
let previousEnv = getenv(envKey).map { String(cString: $0) }
let configPath = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-test-config-\(UUID().uuidString).json")
.path
setenv(envKey, configPath, 1)
defer {
if let previousEnv {
setenv(envKey, previousEnv, 1)
} else {
unsetenv(envKey)
}
}
UserDefaults.standard.removeObject(forKey: "gatewayPort")
defer { UserDefaults.standard.removeObject(forKey: "gatewayPort") }
let defaultPort = GatewayEnvironment.gatewayPort() let defaultPort = GatewayEnvironment.gatewayPort()
#expect(defaultPort == 18789) #expect(defaultPort == 18789)
UserDefaults.standard.set(19999, forKey: "gatewayPort") UserDefaults.standard.set(19999, forKey: "gatewayPort")
defer { UserDefaults.standard.removeObject(forKey: "gatewayPort") }
#expect(GatewayEnvironment.gatewayPort() == 19999) #expect(GatewayEnvironment.gatewayPort() == 19999)
} }

View File

@@ -1,6 +1,7 @@
import AppKit import AppKit
import Foundation import Foundation
import Testing import Testing
import ClawdbotProtocol
@testable import Clawdbot @testable import Clawdbot
@@ -23,7 +24,7 @@ struct LowCoverageHelperTests {
#expect(dict["list"]?.arrayValue?.count == 2) #expect(dict["list"]?.arrayValue?.count == 2)
let foundation = any.foundationValue as? [String: Any] let foundation = any.foundationValue as? [String: Any]
#expect(foundation?["title"] as? String == "Hello") #expect((foundation?["title"] as? String) == "Hello")
} }
@Test func attributedStringStripsForegroundColor() { @Test func attributedStringStripsForegroundColor() {

View File

@@ -1,6 +1,7 @@
import AppKit import AppKit
import SwiftUI import SwiftUI
import Testing import Testing
import ClawdbotProtocol
@testable import Clawdbot @testable import Clawdbot

View File

@@ -30,7 +30,7 @@ struct MenuSessionsInjectorTests {
key: "main", key: "main",
kind: .direct, kind: .direct,
displayName: nil, displayName: nil,
surface: nil, provider: nil,
subject: nil, subject: nil,
room: nil, room: nil,
space: nil, space: nil,
@@ -47,7 +47,7 @@ struct MenuSessionsInjectorTests {
key: "discord:group:alpha", key: "discord:group:alpha",
kind: .group, kind: .group,
displayName: nil, displayName: nil,
surface: nil, provider: nil,
subject: nil, subject: nil,
room: nil, room: nil,
space: nil, space: nil,

View File

@@ -28,7 +28,7 @@ struct SessionDataTests {
key: "user@example.com", key: "user@example.com",
kind: .direct, kind: .direct,
displayName: nil, displayName: nil,
surface: nil, provider: nil,
subject: nil, subject: nil,
room: nil, room: nil,
space: nil, space: nil,

View File

@@ -45,7 +45,7 @@ struct SettingsViewSmokeTests {
thinking: "low", thinking: "low",
timeoutSeconds: 30, timeoutSeconds: 30,
deliver: true, deliver: true,
channel: "sms", provider: "sms",
to: "+15551234567", to: "+15551234567",
bestEffortDeliver: true), bestEffortDeliver: true),
isolation: CronIsolation(postToMainPrefix: "[cron] "), isolation: CronIsolation(postToMainPrefix: "[cron] "),

View File

@@ -1,4 +1,5 @@
import Testing import Testing
import ClawdbotProtocol
@testable import Clawdbot @testable import Clawdbot
@Suite(.serialized) @Suite(.serialized)

View File

@@ -17,6 +17,6 @@ import Testing
#expect(opts.thinking == "low") #expect(opts.thinking == "low")
#expect(opts.deliver == true) #expect(opts.deliver == true)
#expect(opts.to == nil) #expect(opts.to == nil)
#expect(opts.channel == .last) #expect(opts.provider == .last)
} }
} }

View File

@@ -1,5 +1,6 @@
import Foundation import Foundation
import Testing import Testing
import ClawdbotProtocol
@testable import Clawdbot @testable import Clawdbot
@Suite @Suite