fix: stabilize macos build

This commit is contained in:
Peter Steinberger
2026-01-04 06:42:32 +00:00
parent bab5bd61a5
commit ad1932dca3
39 changed files with 349 additions and 285 deletions

View File

@@ -254,41 +254,41 @@ final class AppState {
let configRoot = ClawdisConfigFile.loadDict() let configRoot = ClawdisConfigFile.loadDict()
let configGateway = configRoot["gateway"] as? [String: Any] let configGateway = configRoot["gateway"] as? [String: Any]
let configModeRaw = (configGateway?["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) let configModeRaw = (configGateway?["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let configMode: ConnectionMode? = { let configMode: ConnectionMode? = switch configModeRaw {
switch configModeRaw { case "local":
case "local": .local
return .local case "remote":
case "remote": .remote
return .remote default:
default: nil
return nil }
}
}()
let configRemoteUrl = (configGateway?["remote"] as? [String: Any])?["url"] as? String let configRemoteUrl = (configGateway?["remote"] as? [String: Any])?["url"] as? String
let configHasRemoteUrl = !(configRemoteUrl? let configHasRemoteUrl = !(configRemoteUrl?
.trimmingCharacters(in: .whitespacesAndNewlines) .trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty ?? true) .isEmpty ?? true)
let storedMode = UserDefaults.standard.string(forKey: connectionModeKey) let storedMode = UserDefaults.standard.string(forKey: connectionModeKey)
if let configMode { let resolvedConnectionMode: ConnectionMode = if let configMode {
self.connectionMode = configMode configMode
} else if configHasRemoteUrl { } else if configHasRemoteUrl {
self.connectionMode = .remote .remote
} else if let storedMode { } else if let storedMode {
self.connectionMode = ConnectionMode(rawValue: storedMode) ?? .local ConnectionMode(rawValue: storedMode) ?? .local
} else { } else {
self.connectionMode = onboardingSeen ? .local : .unconfigured onboardingSeen ? .local : .unconfigured
} }
self.connectionMode = resolvedConnectionMode
let storedRemoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? "" let storedRemoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? ""
if self.connectionMode == .remote, let resolvedRemoteTarget = if resolvedConnectionMode == .remote,
storedRemoteTarget.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, storedRemoteTarget.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
let host = AppState.remoteHost(from: configRemoteUrl) let host = AppState.remoteHost(from: configRemoteUrl)
{ {
self.remoteTarget = "\(NSUserName())@\(host)" "\(NSUserName())@\(host)"
} else { } else {
self.remoteTarget = storedRemoteTarget storedRemoteTarget
} }
self.remoteTarget = resolvedRemoteTarget
self.remoteIdentity = UserDefaults.standard.string(forKey: remoteIdentityKey) ?? "" self.remoteIdentity = UserDefaults.standard.string(forKey: remoteIdentityKey) ?? ""
self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? "" self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? ""
self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? "" self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? ""
@@ -322,10 +322,6 @@ final class AppState {
} }
} }
deinit {
self.configWatcher?.stop()
}
private static func remoteHost(from urlString: String?) -> String? { private static func remoteHost(from urlString: String?) -> String? {
guard let raw = urlString?.trimmingCharacters(in: .whitespacesAndNewlines), guard let raw = urlString?.trimmingCharacters(in: .whitespacesAndNewlines),
!raw.isEmpty, !raw.isEmpty,
@@ -361,18 +357,16 @@ final class AppState {
.trimmingCharacters(in: .whitespacesAndNewlines) .trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty ?? true) .isEmpty ?? true)
let desiredMode: ConnectionMode? = { let desiredMode: ConnectionMode? = switch modeRaw {
switch modeRaw { case "local":
case "local": .local
return .local case "remote":
case "remote": .remote
return .remote case "unconfigured":
case "unconfigured": .unconfigured
return .unconfigured default:
default: nil
return nil }
}
}()
if let desiredMode { if let desiredMode {
if desiredMode != self.connectionMode { if desiredMode != self.connectionMode {
@@ -407,14 +401,13 @@ final class AppState {
var gateway = root["gateway"] as? [String: Any] ?? [:] var gateway = root["gateway"] as? [String: Any] ?? [:]
var changed = false var changed = false
let desiredMode: String? let desiredMode: String? = switch self.connectionMode {
switch self.connectionMode {
case .local: case .local:
desiredMode = "local" "local"
case .remote: case .remote:
desiredMode = "remote" "remote"
case .unconfigured: case .unconfigured:
desiredMode = nil nil
} }
let currentMode = (gateway["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) let currentMode = (gateway["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)

View File

@@ -244,7 +244,7 @@ actor CameraCaptureService {
deviceId: String?) -> AVCaptureDevice? deviceId: String?) -> AVCaptureDevice?
{ {
if let deviceId, !deviceId.isEmpty { if let deviceId, !deviceId.isEmpty {
if let match = Self.availableCameras().first(where: { $0.uniqueID == deviceId }) { if let match = availableCameras().first(where: { $0.uniqueID == deviceId }) {
return match return match
} }
} }
@@ -331,7 +331,7 @@ actor CameraCaptureService {
private func sleepDelayMs(_ delayMs: Int) async { private func sleepDelayMs(_ delayMs: Int) async {
guard delayMs > 0 else { return } guard delayMs > 0 else { return }
let ns = UInt64(min(delayMs, 10_000)) * 1_000_000 let ns = UInt64(min(delayMs, 10000)) * 1_000_000
try? await Task.sleep(nanoseconds: ns) try? await Task.sleep(nanoseconds: ns)
} }

View File

@@ -100,7 +100,8 @@ enum ClawdisConfigFile {
static func gatewayPassword() -> String? { static func gatewayPassword() -> String? {
let root = self.loadDict() let root = self.loadDict()
guard let gateway = root["gateway"] as? [String: Any], guard let gateway = root["gateway"] as? [String: Any],
let remote = gateway["remote"] as? [String: Any] else { let remote = gateway["remote"] as? [String: Any]
else {
return nil return nil
} }
return remote["password"] as? String return remote["password"] as? String
@@ -121,5 +122,4 @@ enum ClawdisConfigFile {
} }
return nil return nil
} }
} }

View File

@@ -157,7 +157,7 @@ enum CommandResolver {
} }
static func findExecutable(named name: String, searchPaths: [String]? = nil) -> String? { static func findExecutable(named name: String, searchPaths: [String]? = nil) -> String? {
for dir in (searchPaths ?? self.preferredPaths()) { for dir in searchPaths ?? self.preferredPaths() {
let candidate = (dir as NSString).appendingPathComponent(name) let candidate = (dir as NSString).appendingPathComponent(name)
if FileManager.default.isExecutableFile(atPath: candidate) { if FileManager.default.isExecutableFile(atPath: candidate) {
return candidate return candidate

View File

@@ -90,8 +90,8 @@ extension ConfigFileWatcher {
private func handleEvents( private func handleEvents(
numEvents: Int, numEvents: Int,
eventPaths: UnsafeMutableRawPointer?, eventPaths: UnsafeMutableRawPointer?,
eventFlags: UnsafePointer<FSEventStreamEventFlags>? eventFlags: UnsafePointer<FSEventStreamEventFlags>?)
) { {
guard numEvents > 0 else { return } guard numEvents > 0 else { return }
guard eventFlags != nil else { return } guard eventFlags != nil else { return }
guard self.matchesTarget(eventPaths: eventPaths) else { return } guard self.matchesTarget(eventPaths: eventPaths) else { return }

View File

@@ -78,7 +78,7 @@ enum ConfigStore {
let data = try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys]) let data = try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys])
guard let raw = String(data: data, encoding: .utf8) else { guard let raw = String(data: data, encoding: .utf8) else {
throw NSError(domain: "ConfigStore", code: 1, userInfo: [ throw NSError(domain: "ConfigStore", code: 1, userInfo: [
NSLocalizedDescriptionKey: "Failed to encode config." NSLocalizedDescriptionKey: "Failed to encode config.",
]) ])
} }
let params: [String: AnyCodable] = ["raw": AnyCodable(raw)] let params: [String: AnyCodable] = ["raw": AnyCodable(raw)]
@@ -88,7 +88,7 @@ enum ConfigStore {
timeoutMs: 10000) timeoutMs: 10000)
} }
#if DEBUG #if DEBUG
static func _testSetOverrides(_ overrides: Overrides) async { static func _testSetOverrides(_ overrides: Overrides) async {
await self.overrideStore.setOverride(overrides) await self.overrideStore.setOverride(overrides)
} }
@@ -96,5 +96,5 @@ enum ConfigStore {
static func _testClearOverrides() async { static func _testClearOverrides() async {
await self.overrideStore.setOverride(.init()) await self.overrideStore.setOverride(.init())
} }
#endif #endif
} }

View File

@@ -492,7 +492,7 @@ struct ConnectionsSettings: View {
GroupBox("Guilds") { GroupBox("Guilds") {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
ForEach($store.discordGuilds) { $guild in ForEach(self.$store.discordGuilds) { $guild in
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
HStack { HStack {
TextField("guild id or slug", text: $guild.key) TextField("guild id or slug", text: $guild.key)

View File

@@ -172,8 +172,8 @@ struct DiscordGuildForm: Identifiable {
requireMention: Bool = false, requireMention: Bool = false,
reactionNotifications: String = "own", reactionNotifications: String = "own",
users: String = "", users: String = "",
channels: [DiscordGuildChannelForm] = [] channels: [DiscordGuildChannelForm] = [])
) { {
self.key = key self.key = key
self.slug = slug self.slug = slug
self.requireMention = requireMention self.requireMention = requireMention
@@ -473,12 +473,16 @@ final class ConnectionsStore {
} else { } else {
self.discordMediaMaxMb = "" self.discordMediaMaxMb = ""
} }
if let history = discord?["historyLimit"]?.doubleValue ?? discord?["historyLimit"]?.intValue.map(Double.init) { if let history = discord?["historyLimit"]?.doubleValue ?? discord?["historyLimit"]?.intValue
.map(Double.init)
{
self.discordHistoryLimit = String(Int(history)) self.discordHistoryLimit = String(Int(history))
} else { } else {
self.discordHistoryLimit = "" self.discordHistoryLimit = ""
} }
if let limit = discord?["textChunkLimit"]?.doubleValue ?? discord?["textChunkLimit"]?.intValue.map(Double.init) { if let limit = discord?["textChunkLimit"]?.doubleValue ?? discord?["textChunkLimit"]?.intValue
.map(Double.init)
{
self.discordTextChunkLimit = String(Int(limit)) self.discordTextChunkLimit = String(Int(limit))
} else { } else {
self.discordTextChunkLimit = "" self.discordTextChunkLimit = ""
@@ -506,9 +510,10 @@ final class ConnectionsStore {
return nil return nil
} }
.joined(separator: ", ") ?? "" .joined(separator: ", ") ?? ""
let channels: [DiscordGuildChannelForm] let channels: [DiscordGuildChannelForm] = if let channelMap = entry["channels"]?
if let channelMap = entry["channels"]?.dictionaryValue { .dictionaryValue
channels = channelMap.map { channelKey, channelValue in {
channelMap.map { channelKey, channelValue in
let channelEntry = channelValue.dictionaryValue ?? [:] let channelEntry = channelValue.dictionaryValue ?? [:]
let allow = channelEntry["allow"]?.boolValue ?? true let allow = channelEntry["allow"]?.boolValue ?? true
let channelRequireMention = let channelRequireMention =
@@ -519,7 +524,7 @@ final class ConnectionsStore {
requireMention: channelRequireMention) requireMention: channelRequireMention)
} }
} else { } else {
channels = [] []
} }
return DiscordGuildForm( return DiscordGuildForm(
key: key, key: key,

View File

@@ -12,11 +12,13 @@ struct ContextUsageBar: View {
if match == .darkAqua { return base } if match == .darkAqua { return base }
return base.blended(withFraction: 0.24, of: .black) ?? base return base.blended(withFraction: 0.24, of: .black) ?? base
} }
private static let trackFill: NSColor = .init(name: nil) { appearance in private static let trackFill: NSColor = .init(name: nil) { appearance in
let match = appearance.bestMatch(from: [.aqua, .darkAqua]) let match = appearance.bestMatch(from: [.aqua, .darkAqua])
if match == .darkAqua { return NSColor.white.withAlphaComponent(0.14) } if match == .darkAqua { return NSColor.white.withAlphaComponent(0.14) }
return NSColor.black.withAlphaComponent(0.12) return NSColor.black.withAlphaComponent(0.12)
} }
private static let trackStroke: NSColor = .init(name: nil) { appearance in private static let trackStroke: NSColor = .init(name: nil) { appearance in
let match = appearance.bestMatch(from: [.aqua, .darkAqua]) let match = appearance.bestMatch(from: [.aqua, .darkAqua])
if match == .darkAqua { return NSColor.white.withAlphaComponent(0.22) } if match == .darkAqua { return NSColor.white.withAlphaComponent(0.22) }

View File

@@ -55,8 +55,8 @@ final class ControlChannel {
private(set) var state: ConnectionState = .disconnected { private(set) var state: ConnectionState = .disconnected {
didSet { didSet {
CanvasManager.shared.refreshDebugStatus() CanvasManager.shared.refreshDebugStatus()
guard oldValue != state else { return } guard oldValue != self.state else { return }
switch state { switch self.state {
case .connected: case .connected:
self.logger.info("control channel state -> connected") self.logger.info("control channel state -> connected")
case .connecting: case .connecting:
@@ -71,6 +71,7 @@ final class ControlChannel {
} }
} }
} }
private(set) var lastPingMs: Double? private(set) var lastPingMs: Double?
private let logger = Logger(subsystem: "com.clawdis", category: "control") private let logger = Logger(subsystem: "com.clawdis", category: "control")

View File

@@ -138,17 +138,20 @@ enum DeviceModelCatalog {
if bundle.url( if bundle.url(
forResource: "ios-device-identifiers", forResource: "ios-device-identifiers",
withExtension: "json", withExtension: "json",
subdirectory: self.resourceSubdirectory) != nil { subdirectory: self.resourceSubdirectory) != nil
{
return bundle return bundle
} }
if bundle.url( if bundle.url(
forResource: "mac-device-identifiers", forResource: "mac-device-identifiers",
withExtension: "json", withExtension: "json",
subdirectory: self.resourceSubdirectory) != nil { subdirectory: self.resourceSubdirectory) != nil
{
return bundle return bundle
} }
return nil return nil
} }
private enum NameValue: Decodable { private enum NameValue: Decodable {
case string(String) case string(String)
case stringArray([String]) case stringArray([String])

View File

@@ -237,8 +237,9 @@ actor GatewayConnection {
guard let snapshot = self.lastSnapshot else { return (nil, nil) } guard let snapshot = self.lastSnapshot else { return (nil, nil) }
let configPath = snapshot.snapshot.configpath?.trimmingCharacters(in: .whitespacesAndNewlines) let configPath = snapshot.snapshot.configpath?.trimmingCharacters(in: .whitespacesAndNewlines)
let stateDir = snapshot.snapshot.statedir?.trimmingCharacters(in: .whitespacesAndNewlines) let stateDir = snapshot.snapshot.statedir?.trimmingCharacters(in: .whitespacesAndNewlines)
return (configPath?.isEmpty == false ? configPath : nil, return (
stateDir?.isEmpty == false ? stateDir : nil) configPath?.isEmpty == false ? configPath : nil,
stateDir?.isEmpty == false ? stateDir : nil)
} }
func subscribe(bufferingNewest: Int = 100) -> AsyncStream<GatewayPush> { func subscribe(bufferingNewest: Int = 100) -> AsyncStream<GatewayPush> {
@@ -270,7 +271,9 @@ actor GatewayConnection {
} }
private func configure(url: URL, token: String?, password: String?) async { private func configure(url: URL, token: String?, password: String?) async {
if self.client != nil, self.configuredURL == url, self.configuredToken == token, self.configuredPassword == password { if self.client != nil, self.configuredURL == url, self.configuredToken == token,
self.configuredPassword == password
{
return return
} }
if let client { if let client {

View File

@@ -40,8 +40,8 @@ actor GatewayEndpointStore {
private static func resolveGatewayPassword( private static func resolveGatewayPassword(
isRemote: Bool, isRemote: Bool,
root: [String: Any], root: [String: Any],
env: [String: String] env: [String: String]) -> String?
) -> String? { {
let raw = env["CLAWDIS_GATEWAY_PASSWORD"] ?? "" let raw = env["CLAWDIS_GATEWAY_PASSWORD"] ?? ""
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty { if !trimmed.isEmpty {
@@ -93,7 +93,11 @@ actor GatewayEndpointStore {
let password = deps.password() let password = deps.password()
switch initialMode { switch initialMode {
case .local: case .local:
self.state = .ready(mode: .local, url: URL(string: "ws://127.0.0.1:\(port)")!, token: token, password: password) self.state = .ready(
mode: .local,
url: URL(string: "ws://127.0.0.1:\(port)")!,
token: token,
password: password)
case .remote: case .remote:
self.state = .unavailable(mode: .remote, reason: "Remote mode enabled but no active control tunnel") self.state = .unavailable(mode: .remote, reason: "Remote mode enabled but no active control tunnel")
case .unconfigured: case .unconfigured:
@@ -125,14 +129,22 @@ actor GatewayEndpointStore {
switch mode { switch mode {
case .local: case .local:
let port = self.deps.localPort() let port = self.deps.localPort()
self.setState(.ready(mode: .local, url: URL(string: "ws://127.0.0.1:\(port)")!, token: token, password: password)) self.setState(.ready(
mode: .local,
url: URL(string: "ws://127.0.0.1:\(port)")!,
token: token,
password: password))
case .remote: case .remote:
let port = await self.deps.remotePortIfRunning() let port = await self.deps.remotePortIfRunning()
guard let port else { guard let port else {
self.setState(.unavailable(mode: .remote, reason: "Remote mode enabled but no active control tunnel")) self.setState(.unavailable(mode: .remote, reason: "Remote mode enabled but no active control tunnel"))
return return
} }
self.setState(.ready(mode: .remote, url: URL(string: "ws://127.0.0.1:\(Int(port))")!, token: token, password: password)) self.setState(.ready(
mode: .remote,
url: URL(string: "ws://127.0.0.1:\(Int(port))")!,
token: token,
password: password))
case .unconfigured: case .unconfigured:
self.setState(.unavailable(mode: .unconfigured, reason: "Gateway not configured")) self.setState(.unavailable(mode: .unconfigured, reason: "Gateway not configured"))
} }
@@ -213,8 +225,8 @@ extension GatewayEndpointStore {
static func _testResolveGatewayPassword( static func _testResolveGatewayPassword(
isRemote: Bool, isRemote: Bool,
root: [String: Any], root: [String: Any],
env: [String: String] env: [String: String]) -> String?
) -> String? { {
self.resolveGatewayPassword(isRemote: isRemote, root: root, env: env) self.resolveGatewayPassword(isRemote: isRemote, root: root, env: env)
} }
} }

View File

@@ -24,7 +24,7 @@ enum GatewayLaunchAgentManager {
} }
private static func gatewayProgramArguments(bundlePath: String, port: Int, bind: String) -> [String] { private static func gatewayProgramArguments(bundlePath: String, port: Int, bind: String) -> [String] {
#if DEBUG #if DEBUG
let projectRoot = CommandResolver.projectRoot() let projectRoot = CommandResolver.projectRoot()
if let localBin = CommandResolver.projectClawdisExecutable(projectRoot: projectRoot) { if let localBin = CommandResolver.projectClawdisExecutable(projectRoot: projectRoot) {
return [localBin, "gateway", "--port", "\(port)", "--bind", bind] return [localBin, "gateway", "--port", "\(port)", "--bind", bind]
@@ -38,7 +38,7 @@ enum GatewayLaunchAgentManager {
subcommand: "gateway", subcommand: "gateway",
extraArgs: ["--port", "\(port)", "--bind", bind]) extraArgs: ["--port", "\(port)", "--bind", bind])
} }
#endif #endif
let gatewayBin = self.gatewayExecutablePath(bundlePath: bundlePath) let gatewayBin = self.gatewayExecutablePath(bundlePath: bundlePath)
return [gatewayBin, "gateway-daemon", "--port", "\(port)", "--bind", bind] return [gatewayBin, "gateway-daemon", "--port", "\(port)", "--bind", bind]
} }
@@ -51,7 +51,7 @@ enum GatewayLaunchAgentManager {
static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? { static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? {
if enabled { if enabled {
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(legacyGatewayLaunchdLabel)"]) _ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(self.legacyGatewayLaunchdLabel)"])
try? FileManager.default.removeItem(at: self.legacyPlistURL) try? FileManager.default.removeItem(at: self.legacyPlistURL)
let gatewayBin = self.gatewayExecutablePath(bundlePath: bundlePath) let gatewayBin = self.gatewayExecutablePath(bundlePath: bundlePath)
guard FileManager.default.isExecutableFile(atPath: gatewayBin) else { guard FileManager.default.isExecutableFile(atPath: gatewayBin) else {

View File

@@ -151,16 +151,16 @@ struct GeneralSettings: View {
private func requestLocationAuthorization(mode: ClawdisLocationMode) async -> Bool { private func requestLocationAuthorization(mode: ClawdisLocationMode) async -> Bool {
guard mode != .off else { return true } guard mode != .off else { return true }
let status = CLLocationManager.authorizationStatus() let status = CLLocationManager.authorizationStatus()
if status == .authorizedAlways || status == .authorizedWhenInUse { if status == .authorizedAlways || status == .authorized {
if mode == .always && status != .authorizedAlways { if mode == .always, status != .authorizedAlways {
let updated = await LocationPermissionRequester.shared.request(always: true) let updated = await LocationPermissionRequester.shared.request(always: true)
return updated == .authorizedAlways || updated == .authorizedWhenInUse return updated == .authorizedAlways || updated == .authorized
} }
return true return true
} }
let updated = await LocationPermissionRequester.shared.request(always: mode == .always) let updated = await LocationPermissionRequester.shared.request(always: mode == .always)
switch updated { switch updated {
case .authorizedAlways, .authorizedWhenInUse: case .authorizedAlways, .authorized:
return true return true
default: default:
return false return false

View File

@@ -6,6 +6,7 @@ enum LaunchAgentManager {
FileManager.default.homeDirectoryForCurrentUser FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/LaunchAgents/com.clawdis.mac.plist") .appendingPathComponent("Library/LaunchAgents/com.clawdis.mac.plist")
} }
private static var legacyPlistURL: URL { private static var legacyPlistURL: URL {
FileManager.default.homeDirectoryForCurrentUser FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/LaunchAgents/\(legacyLaunchdLabel).plist") .appendingPathComponent("Library/LaunchAgents/\(legacyLaunchdLabel).plist")
@@ -19,7 +20,7 @@ enum LaunchAgentManager {
static func set(enabled: Bool, bundlePath: String) async { static func set(enabled: Bool, bundlePath: String) async {
if enabled { if enabled {
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(legacyLaunchdLabel)"]) _ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(self.legacyLaunchdLabel)"])
try? FileManager.default.removeItem(at: self.legacyPlistURL) try? FileManager.default.removeItem(at: self.legacyPlistURL)
self.writePlist(bundlePath: bundlePath) self.writePlist(bundlePath: bundlePath)
_ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(launchdLabel)"]) _ = await self.runLaunchctl(["bootout", "gui/\(getuid())/\(launchdLabel)"])

View File

@@ -1,7 +1,7 @@
@_exported import Logging
import Foundation import Foundation
import OSLog @_exported import Logging
import os import os
import OSLog
typealias Logger = Logging.Logger typealias Logger = Logging.Logger
@@ -65,15 +65,15 @@ enum ClawdisLogging {
}() }()
static func bootstrapIfNeeded() { static func bootstrapIfNeeded() {
_ = Self.didBootstrap _ = self.didBootstrap
} }
static func makeLabel(subsystem: String, category: String) -> String { static func makeLabel(subsystem: String, category: String) -> String {
"\(subsystem)\(Self.labelSeparator)\(category)" "\(subsystem)\(self.labelSeparator)\(category)"
} }
static func parseLabel(_ label: String) -> (String, String) { static func parseLabel(_ label: String) -> (String, String) {
guard let range = label.range(of: Self.labelSeparator) else { guard let range = label.range(of: labelSeparator) else {
return ("com.clawdis", label) return ("com.clawdis", label)
} }
let subsystem = String(label[..<range.lowerBound]) let subsystem = String(label[..<range.lowerBound])
@@ -91,7 +91,7 @@ extension Logging.Logger {
} }
extension Logger.Message.StringInterpolation { extension Logger.Message.StringInterpolation {
mutating func appendInterpolation<T>(_ value: T, privacy: OSLogPrivacy) { mutating func appendInterpolation(_ value: some Any, privacy: OSLogPrivacy) {
self.appendInterpolation(String(describing: value)) self.appendInterpolation(String(describing: value))
} }
} }
@@ -132,15 +132,15 @@ struct ClawdisOSLogHandler: LogHandler {
private static func osLogType(for level: Logger.Level) -> OSLogType { private static func osLogType(for level: Logger.Level) -> OSLogType {
switch level { switch level {
case .trace, .debug: case .trace, .debug:
return .debug .debug
case .info, .notice: case .info, .notice:
return .info .info
case .warning: case .warning:
return .default .default
case .error: case .error:
return .error .error
case .critical: case .critical:
return .fault .fault
} }
} }
@@ -156,7 +156,7 @@ struct ClawdisOSLogHandler: LogHandler {
guard !metadata.isEmpty else { return message.description } guard !metadata.isEmpty else { return message.description }
let meta = metadata let meta = metadata
.sorted(by: { $0.key < $1.key }) .sorted(by: { $0.key < $1.key })
.map { "\($0.key)=\(stringify($0.value))" } .map { "\($0.key)=\(self.stringify($0.value))" }
.joined(separator: " ") .joined(separator: " ")
return "\(message.description) [\(meta)]" return "\(message.description) [\(meta)]"
} }
@@ -168,9 +168,9 @@ struct ClawdisOSLogHandler: LogHandler {
case let .stringConvertible(value): case let .stringConvertible(value):
String(describing: value) String(describing: value)
case let .array(values): case let .array(values):
"[" + values.map { stringify($0) }.joined(separator: ",") + "]" "[" + values.map { self.stringify($0) }.joined(separator: ",") + "]"
case let .dictionary(entries): case let .dictionary(entries):
"{" + entries.map { "\($0.key)=\(stringify($0.value))" }.joined(separator: ",") + "}" "{" + entries.map { "\($0.key)=\(self.stringify($0.value))" }.joined(separator: ",") + "}"
} }
} }
} }
@@ -224,9 +224,9 @@ struct ClawdisFileLogHandler: LogHandler {
case let .stringConvertible(value): case let .stringConvertible(value):
String(describing: value) String(describing: value)
case let .array(values): case let .array(values):
"[" + values.map { stringify($0) }.joined(separator: ",") + "]" "[" + values.map { self.stringify($0) }.joined(separator: ",") + "]"
case let .dictionary(entries): case let .dictionary(entries):
"{" + entries.map { "\($0.key)=\(stringify($0.value))" }.joined(separator: ",") + "}" "{" + entries.map { "\($0.key)=\(self.stringify($0.value))" }.joined(separator: ",") + "}"
} }
} }
} }

View File

@@ -172,7 +172,7 @@ struct MenuContent: View {
} }
@MainActor @MainActor
private static func buildAndSaveBrowserEnabled(_ enabled: Bool) async -> (Bool,()) { private static func buildAndSaveBrowserEnabled(_ enabled: Bool) async -> (Bool, ()) {
var root = await ConfigStore.load() var root = await ConfigStore.load()
var browser = root["browser"] as? [String: Any] ?? [:] var browser = root["browser"] as? [String: Any] ?? [:]
browser["enabled"] = enabled browser["enabled"] = enabled

View File

@@ -86,7 +86,6 @@ final class HighlightedMenuItemHostView: NSView {
self.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) self.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height))
self.invalidateIntrinsicContentSize() self.invalidateIntrinsicContentSize()
} }
} }
struct MenuHostedHighlightedItem: NSViewRepresentable { struct MenuHostedHighlightedItem: NSViewRepresentable {

View File

@@ -435,7 +435,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
compact.representedObject = row.key compact.representedObject = row.key
menu.addItem(compact) menu.addItem(compact)
if row.key != "main" && row.key != "global" { if row.key != "main", row.key != "global" {
let del = NSMenuItem(title: "Delete Session", action: #selector(self.deleteSession(_:)), keyEquivalent: "") let del = NSMenuItem(title: "Delete Session", action: #selector(self.deleteSession(_:)), keyEquivalent: "")
del.target = self del.target = self
del.representedObject = row.key del.representedObject = row.key
@@ -541,12 +541,14 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
menu.addItem(self.makeNodeDetailItem(label: "Paired", value: entry.isPaired ? "Yes" : "No")) menu.addItem(self.makeNodeDetailItem(label: "Paired", value: entry.isPaired ? "Yes" : "No"))
if let caps = entry.caps?.filter({ !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }), if let caps = entry.caps?.filter({ !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }),
!caps.isEmpty { !caps.isEmpty
{
menu.addItem(self.makeNodeCopyItem(label: "Caps", value: caps.joined(separator: ", "))) menu.addItem(self.makeNodeCopyItem(label: "Caps", value: caps.joined(separator: ", ")))
} }
if let commands = entry.commands?.filter({ !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }), if let commands = entry.commands?.filter({ !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }),
!commands.isEmpty { !commands.isEmpty
{
menu.addItem(self.makeNodeMultilineItem( menu.addItem(self.makeNodeMultilineItem(
label: "Commands", label: "Commands",
value: commands.joined(separator: ", "), value: commands.joined(separator: ", "),
@@ -589,6 +591,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
} }
return trimmed return trimmed
} }
@objc @objc
private func patchThinking(_ sender: NSMenuItem) { private func patchThinking(_ sender: NSMenuItem) {
guard let dict = sender.representedObject as? [String: Any], guard let dict = sender.representedObject as? [String: Any],
@@ -770,7 +773,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
} }
private func sortedNodeEntries() -> [NodeInfo] { private func sortedNodeEntries() -> [NodeInfo] {
let entries = self.nodesStore.nodes.filter { $0.isConnected } let entries = self.nodesStore.nodes.filter(\.isConnected)
return entries.sorted { lhs, rhs in return entries.sorted { lhs, rhs in
if lhs.isConnected != rhs.isConnected { return lhs.isConnected } if lhs.isConnected != rhs.isConnected { return lhs.isConnected }
if lhs.isPaired != rhs.isPaired { return lhs.isPaired } if lhs.isPaired != rhs.isPaired { return lhs.isPaired }
@@ -781,8 +784,6 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
} }
} }
// MARK: - Views // MARK: - Views
private func makeHostedView(rootView: AnyView, width: CGFloat, highlighted: Bool) -> NSView { private func makeHostedView(rootView: AnyView, width: CGFloat, highlighted: Bool) -> NSView {

View File

@@ -192,6 +192,4 @@ actor MacNodeBridgePairingClient {
} }
} }
} }
} }

View File

@@ -325,6 +325,4 @@ actor MacNodeBridgeSession {
]) ])
}) })
} }
} }

View File

@@ -3,7 +3,7 @@ import CoreLocation
import Foundation import Foundation
@MainActor @MainActor
final class MacNodeLocationService: NSObject, CLLocationManagerDelegate { final class MacNodeLocationService: NSObject {
enum Error: Swift.Error { enum Error: Swift.Error {
case timeout case timeout
case unavailable case unavailable
@@ -47,10 +47,8 @@ final class MacNodeLocationService: NSObject, CLLocationManagerDelegate {
} }
self.manager.desiredAccuracy = Self.accuracyValue(desiredAccuracy) self.manager.desiredAccuracy = Self.accuracyValue(desiredAccuracy)
let timeout = max(0, timeoutMs ?? 10_000) let timeout = max(0, timeoutMs ?? 10000)
return try await self.withTimeout(timeoutMs: timeout) { return try await self.requestLocationWithTimeout(timeoutMs: timeout)
try await self.requestLocation()
}
} }
private func requestLocation() async throws -> CLLocation { private func requestLocation() async throws -> CLLocation {
@@ -60,34 +58,50 @@ final class MacNodeLocationService: NSObject, CLLocationManagerDelegate {
} }
} }
private func withTimeout<T>( private func requestLocationWithTimeout(timeoutMs: Int) async throws -> CLLocation {
timeoutMs: Int,
operation: @escaping () async throws -> T) async throws -> T
{
if timeoutMs == 0 { if timeoutMs == 0 {
return try await operation() return try await self.requestLocation()
} }
return try await withThrowingTaskGroup(of: T.self) { group in let timeoutNs = UInt64(timeoutMs) * 1_000_000
group.addTask { try await operation() } return try await withCheckedThrowingContinuation { continuation in
group.addTask { let lock = NSLock()
try await Task.sleep(nanoseconds: UInt64(timeoutMs) * 1_000_000) var didResume = false
throw Error.timeout
func resume(_ result: Result<CLLocation, Swift.Error>) {
lock.lock()
defer { lock.unlock() }
guard !didResume else { return }
didResume = true
continuation.resume(with: result)
}
let timeoutTask = Task {
try await Task.sleep(nanoseconds: timeoutNs)
resume(.failure(Error.timeout))
}
Task { @MainActor in
do {
let location = try await self.requestLocation()
timeoutTask.cancel()
resume(.success(location))
} catch {
timeoutTask.cancel()
resume(.failure(error))
}
} }
let result = try await group.next()!
group.cancelAll()
return result
} }
} }
private static func accuracyValue(_ accuracy: ClawdisLocationAccuracy) -> CLLocationAccuracy { private static func accuracyValue(_ accuracy: ClawdisLocationAccuracy) -> CLLocationAccuracy {
switch accuracy { switch accuracy {
case .coarse: case .coarse:
return kCLLocationAccuracyKilometer kCLLocationAccuracyKilometer
case .balanced: case .balanced:
return kCLLocationAccuracyHundredMeters kCLLocationAccuracyHundredMeters
case .precise: case .precise:
return kCLLocationAccuracyBest kCLLocationAccuracyBest
} }
} }
@@ -107,3 +121,6 @@ final class MacNodeLocationService: NSObject, CLLocationManagerDelegate {
cont.resume(throwing: error) cont.resume(throwing: error)
} }
} }
@MainActor
extension MacNodeLocationService: @preconcurrency CLLocationManagerDelegate {}

View File

@@ -104,7 +104,7 @@ actor MacNodeRuntime {
} }
let params = (try? Self.decodeParams(ClawdisCameraSnapParams.self, from: req.paramsJSON)) ?? let params = (try? Self.decodeParams(ClawdisCameraSnapParams.self, from: req.paramsJSON)) ??
ClawdisCameraSnapParams() ClawdisCameraSnapParams()
let delayMs = min(10_000, max(0, params.delayMs ?? 2000)) let delayMs = min(10000, max(0, params.delayMs ?? 2000))
let res = try await self.cameraCapture.snap( let res = try await self.cameraCapture.snap(
facing: CameraFacing(rawValue: params.facing?.rawValue ?? "") ?? .front, facing: CameraFacing(rawValue: params.facing?.rawValue ?? "") ?? .front,
maxWidth: params.maxWidth, maxWidth: params.maxWidth,
@@ -184,7 +184,7 @@ actor MacNodeRuntime {
let desired = params.desiredAccuracy ?? let desired = params.desiredAccuracy ??
(Self.locationPreciseEnabled() ? .precise : .balanced) (Self.locationPreciseEnabled() ? .precise : .balanced)
let status = await self.locationService.authorizationStatus() let status = await self.locationService.authorizationStatus()
if status != .authorizedAlways && status != .authorizedWhenInUse { if status != .authorizedAlways, status != .authorized {
return BridgeInvokeResponse( return BridgeInvokeResponse(
id: req.id, id: req.id,
ok: false, ok: false,

View File

@@ -611,7 +611,7 @@ final class NodePairingApprovalPrompter {
private func updatePendingCounts() { private func updatePendingCounts() {
// Keep a cheap observable summary for the menu bar status line. // Keep a cheap observable summary for the menu bar status line.
self.pendingCount = self.queue.count self.pendingCount = self.queue.count
self.pendingRepairCount = self.queue.filter { $0.isRepair == true }.count self.pendingRepairCount = self.queue.count(where: { $0.isRepair == true })
} }
private func reconcileOnce(timeoutMs: Double) async { private func reconcileOnce(timeoutMs: Double) async {

View File

@@ -110,8 +110,8 @@ struct NodeMenuEntryFormatter {
guard !trimmed.isEmpty else { return trimmed } guard !trimmed.isEmpty else { return trimmed }
if let range = trimmed.range( if let range = trimmed.range(
of: #"\s*\([^)]*\d[^)]*\)$"#, of: #"\s*\([^)]*\d[^)]*\)$"#,
options: .regularExpression options: .regularExpression)
) { {
return String(trimmed[..<range.lowerBound]) return String(trimmed[..<range.lowerBound])
} }
return trimmed return trimmed
@@ -227,7 +227,6 @@ struct NodeMenuRowView: View {
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
.padding(.vertical, 8) .padding(.vertical, 8)
.padding(.leading, 18) .padding(.leading, 18)

View File

@@ -139,6 +139,7 @@ struct OnboardingView: View {
var isWizardBlocking: Bool { var isWizardBlocking: Bool {
self.activePageIndex == self.wizardPageIndex && !self.onboardingWizard.isComplete self.activePageIndex == self.wizardPageIndex && !self.onboardingWizard.isComplete
} }
var canAdvance: Bool { !self.isWizardBlocking } var canAdvance: Bool { !self.isWizardBlocking }
var devLinkCommand: String { var devLinkCommand: String {
let bundlePath = Bundle.main.bundlePath let bundlePath = Bundle.main.bundlePath

View File

@@ -86,7 +86,7 @@ extension OnboardingView {
var navigationBar: some View { var navigationBar: some View {
let wizardLockIndex = self.wizardPageOrderIndex let wizardLockIndex = self.wizardPageOrderIndex
HStack(spacing: 20) { return HStack(spacing: 20) {
ZStack(alignment: .leading) { ZStack(alignment: .leading) {
Button(action: {}, label: { Button(action: {}, label: {
Label("Back", systemImage: "chevron.left").labelStyle(.iconOnly) Label("Back", systemImage: "chevron.left").labelStyle(.iconOnly)
@@ -112,7 +112,8 @@ extension OnboardingView {
HStack(spacing: 8) { HStack(spacing: 8) {
ForEach(0..<self.pageCount, id: \.self) { index in ForEach(0..<self.pageCount, id: \.self) { index in
let isLocked = wizardLockIndex != nil && !self.onboardingWizard.isComplete && index > (wizardLockIndex ?? 0) let isLocked = wizardLockIndex != nil && !self.onboardingWizard
.isComplete && index > (wizardLockIndex ?? 0)
Button { Button {
withAnimation { self.currentPage = index } withAnimation { self.currentPage = index }
} label: { } label: {

View File

@@ -444,7 +444,7 @@ extension OnboardingView {
} }
func permissionsPage() -> some View { func permissionsPage() -> some View {
return self.onboardingPage { self.onboardingPage {
Text("Grant permissions") Text("Grant permissions")
.font(.largeTitle.weight(.semibold)) .font(.largeTitle.weight(.semibold))
Text("These macOS permissions let Clawdis automate apps and capture context on this Mac.") Text("These macOS permissions let Clawdis automate apps and capture context on this Mac.")

View File

@@ -1,3 +1,4 @@
import ClawdisProtocol
import Observation import Observation
import SwiftUI import SwiftUI
@@ -44,15 +45,15 @@ private struct OnboardingWizardCardContent: View {
private var state: CardState { private var state: CardState {
if let error = wizard.errorMessage { return .error(error) } if let error = wizard.errorMessage { return .error(error) }
if wizard.isStarting { return .starting } if self.wizard.isStarting { return .starting }
if let step = wizard.currentStep { return .step(step) } if let step = wizard.currentStep { return .step(step) }
if wizard.isComplete { return .complete } if self.wizard.isComplete { return .complete }
return .waiting return .waiting
} }
var body: some View { var body: some View {
switch state { switch self.state {
case .error(let error): case let .error(error):
Text("Wizard error") Text("Wizard error")
.font(.headline) .font(.headline)
Text(error) Text(error)
@@ -60,11 +61,11 @@ private struct OnboardingWizardCardContent: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
Button("Retry") { Button("Retry") {
wizard.reset() self.wizard.reset()
Task { Task {
await wizard.startIfNeeded( await self.wizard.startIfNeeded(
mode: mode, mode: self.mode,
workspace: workspacePath.isEmpty ? nil : workspacePath) workspace: self.workspacePath.isEmpty ? nil : self.workspacePath)
} }
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
@@ -74,12 +75,12 @@ private struct OnboardingWizardCardContent: View {
Text("Starting wizard…") Text("Starting wizard…")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
case .step(let step): case let .step(step):
OnboardingWizardStepView( OnboardingWizardStepView(
step: step, step: step,
isSubmitting: wizard.isSubmitting) isSubmitting: self.wizard.isSubmitting)
{ value in { value in
Task { await wizard.submit(step: step, value: value) } Task { await self.wizard.submit(step: step, value: value) }
} }
.id(step.id) .id(step.id)
case .complete: case .complete:

View File

@@ -101,7 +101,7 @@ extension OnboardingView {
do { do {
try await ConfigStore.save(root) try await ConfigStore.save(root)
return (true, nil) return (true, nil)
} catch let error { } catch {
let errorMessage = "Failed to save config: \(error.localizedDescription)" let errorMessage = "Failed to save config: \(error.localizedDescription)"
return (false, errorMessage) return (false, errorMessage)
} }

View File

@@ -4,6 +4,8 @@ import Observation
import OSLog import OSLog
import SwiftUI import SwiftUI
private typealias ProtocolAnyCodable = ClawdisProtocol.AnyCodable
private let onboardingWizardLogger = Logger(subsystem: "com.clawdis", category: "onboarding.wizard") private let onboardingWizardLogger = Logger(subsystem: "com.clawdis", category: "onboarding.wizard")
@MainActor @MainActor
@@ -43,7 +45,7 @@ final class OnboardingWizardModel {
let res: WizardStartResult = try await GatewayConnection.shared.requestDecoded( let res: WizardStartResult = try await GatewayConnection.shared.requestDecoded(
method: .wizardStart, method: .wizardStart,
params: params) params: params)
applyStartResult(res) self.applyStartResult(res)
} catch { } catch {
self.status = "error" self.status = "error"
self.errorMessage = error.localizedDescription self.errorMessage = error.localizedDescription
@@ -67,7 +69,7 @@ final class OnboardingWizardModel {
let res: WizardNextResult = try await GatewayConnection.shared.requestDecoded( let res: WizardNextResult = try await GatewayConnection.shared.requestDecoded(
method: .wizardNext, method: .wizardNext,
params: params) params: params)
applyNextResult(res) self.applyNextResult(res)
} catch { } catch {
self.status = "error" self.status = "error"
self.errorMessage = error.localizedDescription self.errorMessage = error.localizedDescription
@@ -81,7 +83,7 @@ final class OnboardingWizardModel {
let res: WizardStatusResult = try await GatewayConnection.shared.requestDecoded( let res: WizardStatusResult = try await GatewayConnection.shared.requestDecoded(
method: .wizardCancel, method: .wizardCancel,
params: ["sessionId": AnyCodable(sessionId)]) params: ["sessionId": AnyCodable(sessionId)])
applyStatusResult(res) self.applyStatusResult(res)
} catch { } catch {
self.status = "error" self.status = "error"
self.errorMessage = error.localizedDescription self.errorMessage = error.localizedDescription
@@ -103,7 +105,8 @@ final class OnboardingWizardModel {
self.currentStep = decodeWizardStep(res.step) self.currentStep = decodeWizardStep(res.step)
if res.done { self.currentStep = nil } if res.done { self.currentStep = nil }
if res.done || anyCodableStringValue(res.status) == "done" || anyCodableStringValue(res.status) == "cancelled" if res.done || anyCodableStringValue(res.status) == "done" || anyCodableStringValue(res.status) == "cancelled"
|| anyCodableStringValue(res.status) == "error" { || anyCodableStringValue(res.status) == "error"
{
self.sessionId = nil self.sessionId = nil
} }
} }
@@ -142,8 +145,7 @@ struct OnboardingWizardStepView: View {
let initialMulti = Set( let initialMulti = Set(
options.filter { option in options.filter { option in
anyCodableArray(step.initialvalue).contains { anyCodableEqual($0, option.option.value) } anyCodableArray(step.initialvalue).contains { anyCodableEqual($0, option.option.value) }
}.map { $0.index } }.map(\.index))
)
_textValue = State(initialValue: initialText) _textValue = State(initialValue: initialText)
_confirmValue = State(initialValue: initialConfirm) _confirmValue = State(initialValue: initialConfirm)
@@ -164,18 +166,18 @@ struct OnboardingWizardStepView: View {
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
} }
switch wizardStepType(step) { switch wizardStepType(self.step) {
case "note": case "note":
EmptyView() EmptyView()
case "text": case "text":
textField self.textField
case "confirm": case "confirm":
Toggle("", isOn: $confirmValue) Toggle("", isOn: self.$confirmValue)
.toggleStyle(.switch) .toggleStyle(.switch)
case "select": case "select":
selectOptions self.selectOptions
case "multiselect": case "multiselect":
multiselectOptions self.multiselectOptions
case "progress": case "progress":
ProgressView() ProgressView()
.controlSize(.small) .controlSize(.small)
@@ -186,25 +188,25 @@ struct OnboardingWizardStepView: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
Button(action: submit) { Button(action: self.submit) {
Text(wizardStepType(step) == "action" ? "Run" : "Continue") Text(wizardStepType(self.step) == "action" ? "Run" : "Continue")
.frame(minWidth: 120) .frame(minWidth: 120)
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.disabled(isSubmitting || isBlocked) .disabled(self.isSubmitting || self.isBlocked)
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
@ViewBuilder @ViewBuilder
private var textField: some View { private var textField: some View {
let isSensitive = step.sensitive == true let isSensitive = self.step.sensitive == true
if isSensitive { if isSensitive {
SecureField(step.placeholder ?? "", text: $textValue) SecureField(self.step.placeholder ?? "", text: self.$textValue)
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
.frame(maxWidth: 360) .frame(maxWidth: 360)
} else { } else {
TextField(step.placeholder ?? "", text: $textValue) TextField(self.step.placeholder ?? "", text: self.$textValue)
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
.frame(maxWidth: 360) .frame(maxWidth: 360)
} }
@@ -212,33 +214,21 @@ struct OnboardingWizardStepView: View {
private var selectOptions: some View { private var selectOptions: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
ForEach(optionItems) { item in ForEach(self.optionItems) { item in
Button { WizardOptionRow(
selectedIndex = item.index item: item,
} label: { isSelected: self.selectedIndex == item.index,
HStack(alignment: .top, spacing: 8) { onSelect: {
Image(systemName: selectedIndex == item.index ? "largecircle.fill.circle" : "circle") self.selectedIndex = item.index
.foregroundStyle(.accent) })
VStack(alignment: .leading, spacing: 2) {
Text(item.option.label)
.foregroundStyle(.primary)
if let hint = item.option.hint, !hint.isEmpty {
Text(hint)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
.buttonStyle(.plain)
} }
} }
} }
private var multiselectOptions: some View { private var multiselectOptions: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
ForEach(optionItems) { item in ForEach(self.optionItems) { item in
Toggle(isOn: bindingForOption(item)) { Toggle(isOn: self.bindingForOption(item)) {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(item.option.label) Text(item.option.label)
if let hint = item.option.hint, !hint.isEmpty { if let hint = item.option.hint, !hint.isEmpty {
@@ -254,65 +244,97 @@ struct OnboardingWizardStepView: View {
private func bindingForOption(_ item: WizardOptionItem) -> Binding<Bool> { private func bindingForOption(_ item: WizardOptionItem) -> Binding<Bool> {
Binding(get: { Binding(get: {
selectedIndices.contains(item.index) self.selectedIndices.contains(item.index)
}, set: { newValue in }, set: { newValue in
if newValue { if newValue {
selectedIndices.insert(item.index) self.selectedIndices.insert(item.index)
} else { } else {
selectedIndices.remove(item.index) self.selectedIndices.remove(item.index)
} }
}) })
} }
private var isBlocked: Bool { private var isBlocked: Bool {
let type = wizardStepType(step) let type = wizardStepType(step)
if type == "select" { return optionItems.isEmpty } if type == "select" { return self.optionItems.isEmpty }
if type == "multiselect" { return optionItems.isEmpty } if type == "multiselect" { return self.optionItems.isEmpty }
return false return false
} }
private func submit() { private func submit() {
switch wizardStepType(step) { switch wizardStepType(self.step) {
case "note", "progress": case "note", "progress":
onSubmit(nil) self.onSubmit(nil)
case "text": case "text":
onSubmit(AnyCodable(textValue)) self.onSubmit(AnyCodable(self.textValue))
case "confirm": case "confirm":
onSubmit(AnyCodable(confirmValue)) self.onSubmit(AnyCodable(self.confirmValue))
case "select": case "select":
guard optionItems.indices.contains(selectedIndex) else { guard self.optionItems.indices.contains(self.selectedIndex) else {
onSubmit(nil) self.onSubmit(nil)
return return
} }
let option = optionItems[selectedIndex].option let option = self.optionItems[self.selectedIndex].option
onSubmit(option.value ?? AnyCodable(option.label)) self.onSubmit(self.gatewayValue(option.value, fallback: option.label))
case "multiselect": case "multiselect":
let values = optionItems let values = self.optionItems
.filter { selectedIndices.contains($0.index) } .filter { self.selectedIndices.contains($0.index) }
.map { $0.option.value ?? AnyCodable($0.option.label) } .map { self.gatewayValue($0.option.value, fallback: $0.option.label) }
onSubmit(AnyCodable(values)) self.onSubmit(AnyCodable(values))
case "action": case "action":
onSubmit(AnyCodable(true)) self.onSubmit(AnyCodable(true))
default: default:
onSubmit(nil) self.onSubmit(nil)
} }
} }
private func gatewayValue(_ value: ProtocolAnyCodable?, fallback: String) -> AnyCodable {
if let value {
return AnyCodable(value.value)
}
return AnyCodable(fallback)
}
} }
private struct WizardOptionItem: Identifiable { private struct WizardOptionItem: Identifiable {
let index: Int let index: Int
let option: WizardOption let option: WizardOption
var id: Int { index } var id: Int { self.index }
}
private struct WizardOptionRow: View {
let item: WizardOptionItem
let isSelected: Bool
let onSelect: () -> Void
var body: some View {
Button(action: self.onSelect) {
HStack(alignment: .top, spacing: 8) {
Image(systemName: self.isSelected ? "largecircle.fill.circle" : "circle")
.foregroundStyle(Color.accentColor)
VStack(alignment: .leading, spacing: 2) {
Text(self.item.option.label)
.foregroundStyle(.primary)
if let hint = self.item.option.hint, !hint.isEmpty {
Text(hint)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
.buttonStyle(.plain)
}
} }
private struct WizardOption { private struct WizardOption {
let value: AnyCodable? let value: ProtocolAnyCodable?
let label: String let label: String
let hint: String? let hint: String?
} }
private func decodeWizardStep(_ raw: [String: AnyCodable]?) -> WizardStep? { private func decodeWizardStep(_ raw: [String: ProtocolAnyCodable]?) -> WizardStep? {
guard let raw else { return nil } guard let raw else { return nil }
do { do {
let data = try JSONEncoder().encode(raw) let data = try JSONEncoder().encode(raw)
@@ -323,7 +345,7 @@ private func decodeWizardStep(_ raw: [String: AnyCodable]?) -> WizardStep? {
} }
} }
private func parseWizardOptions(_ raw: [[String: AnyCodable]]?) -> [WizardOption] { private func parseWizardOptions(_ raw: [[String: ProtocolAnyCodable]]?) -> [WizardOption] {
guard let raw else { return [] } guard let raw else { return [] }
return raw.map { entry in return raw.map { entry in
let value = entry["value"] let value = entry["value"]
@@ -337,66 +359,66 @@ private func wizardStepType(_ step: WizardStep) -> String {
(step.type.value as? String) ?? "" (step.type.value as? String) ?? ""
} }
private func anyCodableString(_ value: AnyCodable?) -> String { private func anyCodableString(_ value: ProtocolAnyCodable?) -> String {
switch value?.value { switch value?.value {
case let string as String: case let string as String:
return string string
case let int as Int: case let int as Int:
return String(int) String(int)
case let double as Double: case let double as Double:
return String(double) String(double)
case let bool as Bool: case let bool as Bool:
return bool ? "true" : "false" bool ? "true" : "false"
default: default:
return "" ""
} }
} }
private func anyCodableStringValue(_ value: AnyCodable?) -> String? { private func anyCodableStringValue(_ value: ProtocolAnyCodable?) -> String? {
value?.value as? String value?.value as? String
} }
private func anyCodableBool(_ value: AnyCodable?) -> Bool { private func anyCodableBool(_ value: ProtocolAnyCodable?) -> Bool {
switch value?.value { switch value?.value {
case let bool as Bool: case let bool as Bool:
return bool bool
case let string as String: case let string as String:
return string.lowercased() == "true" string.lowercased() == "true"
default: default:
return false false
} }
} }
private func anyCodableArray(_ value: AnyCodable?) -> [AnyCodable] { private func anyCodableArray(_ value: ProtocolAnyCodable?) -> [ProtocolAnyCodable] {
switch value?.value { switch value?.value {
case let arr as [AnyCodable]: case let arr as [ProtocolAnyCodable]:
return arr arr
case let arr as [Any]: case let arr as [Any]:
return arr.map { AnyCodable($0) } arr.map { ProtocolAnyCodable($0) }
default: default:
return [] []
} }
} }
private func anyCodableEqual(_ lhs: AnyCodable?, _ rhs: AnyCodable?) -> Bool { private func anyCodableEqual(_ lhs: ProtocolAnyCodable?, _ rhs: ProtocolAnyCodable?) -> Bool {
switch (lhs?.value, rhs?.value) { switch (lhs?.value, rhs?.value) {
case let (l as String, r as String): case let (l as String, r as String):
return l == r l == r
case let (l as Int, r as Int): case let (l as Int, r as Int):
return l == r l == r
case let (l as Double, r as Double): case let (l as Double, r as Double):
return l == r l == r
case let (l as Bool, r as Bool): case let (l as Bool, r as Bool):
return l == r l == r
case let (l as String, r as Int): case let (l as String, r as Int):
return l == String(r) l == String(r)
case let (l as Int, r as String): case let (l as Int, r as String):
return String(l) == r String(l) == r
case let (l as String, r as Double): case let (l as String, r as Double):
return l == String(r) l == String(r)
case let (l as Double, r as String): case let (l as Double, r as String):
return String(l) == r String(l) == r
default: default:
return false false
} }
} }

View File

@@ -1,9 +1,9 @@
import Foundation import Foundation
import Security
import os import os
import PeekabooAutomationKit import PeekabooAutomationKit
import PeekabooBridge import PeekabooBridge
import PeekabooFoundation import PeekabooFoundation
import Security
@MainActor @MainActor
final class PeekabooBridgeHostCoordinator { final class PeekabooBridgeHostCoordinator {
@@ -80,7 +80,7 @@ final class PeekabooBridgeHostCoordinator {
staticCode, staticCode,
SecCSFlags(rawValue: kSecCSSigningInformation), SecCSFlags(rawValue: kSecCSSigningInformation),
&infoCF) == errSecSuccess, &infoCF) == errSecSuccess,
let info = infoCF as? [String: Any] let info = infoCF as? [String: Any]
else { else {
return nil return nil
} }

View File

@@ -140,12 +140,12 @@ enum PermissionManager {
private static func ensureLocation(interactive: Bool) async -> Bool { private static func ensureLocation(interactive: Bool) async -> Bool {
let status = CLLocationManager.authorizationStatus() let status = CLLocationManager.authorizationStatus()
switch status { switch status {
case .authorizedAlways, .authorizedWhenInUse: case .authorizedAlways, .authorized:
return true return true
case .notDetermined: case .notDetermined:
guard interactive else { return false } guard interactive else { return false }
let updated = await LocationPermissionRequester.shared.request(always: false) let updated = await LocationPermissionRequester.shared.request(always: false)
return updated == .authorizedAlways || updated == .authorizedWhenInUse return updated == .authorizedAlways || updated == .authorized
case .denied, .restricted: case .denied, .restricted:
if interactive { if interactive {
LocationPermissionHelper.openSettings() LocationPermissionHelper.openSettings()
@@ -198,9 +198,10 @@ enum PermissionManager {
case .camera: case .camera:
results[cap] = AVCaptureDevice.authorizationStatus(for: .video) == .authorized results[cap] = AVCaptureDevice.authorizationStatus(for: .video) == .authorized
case .location: case .location:
let status = CLLocationManager.authorizationStatus() let status = CLLocationManager.authorizationStatus()
results[cap] = status == .authorizedAlways || status == .authorizedWhenInUse results[cap] = status == .authorizedAlways || status == .authorized
} }
} }
return results return results
@@ -268,7 +269,7 @@ enum LocationPermissionHelper {
} }
@MainActor @MainActor
final class LocationPermissionRequester: NSObject, CLLocationManagerDelegate { final class LocationPermissionRequester: NSObject {
static let shared = LocationPermissionRequester() static let shared = LocationPermissionRequester()
private let manager = CLLocationManager() private let manager = CLLocationManager()
private var continuation: CheckedContinuation<CLAuthorizationStatus, Never>? private var continuation: CheckedContinuation<CLAuthorizationStatus, Never>?
@@ -296,6 +297,9 @@ final class LocationPermissionRequester: NSObject, CLLocationManagerDelegate {
} }
} }
@MainActor
extension LocationPermissionRequester: @preconcurrency CLLocationManagerDelegate {}
enum AppleScriptPermission { enum AppleScriptPermission {
private static let logger = Logger(subsystem: "com.clawdis", category: "AppleScriptPermission") private static let logger = Logger(subsystem: "com.clawdis", category: "AppleScriptPermission")

View File

@@ -174,12 +174,13 @@ struct SessionMenuPreviewView: View {
let timeoutMs = Int(Self.previewTimeoutSeconds * 1000) let timeoutMs = Int(Self.previewTimeoutSeconds * 1000)
let payload = try await AsyncTimeout.withTimeout( let payload = try await AsyncTimeout.withTimeout(
seconds: Self.previewTimeoutSeconds, seconds: Self.previewTimeoutSeconds,
onTimeout: { PreviewTimeoutError() }) { onTimeout: { PreviewTimeoutError() })
try await GatewayConnection.shared.chatHistory( {
sessionKey: self.sessionKey, try await GatewayConnection.shared.chatHistory(
limit: self.previewLimit, sessionKey: self.sessionKey,
timeoutMs: timeoutMs) limit: self.previewLimit,
} timeoutMs: timeoutMs)
}
let built = Self.previewItems(from: payload, maxItems: self.maxItems) let built = Self.previewItems(from: payload, maxItems: self.maxItems)
await SessionPreviewCache.shared.store(items: built, for: self.sessionKey) await SessionPreviewCache.shared.store(items: built, for: self.sessionKey)
await MainActor.run { await MainActor.run {
@@ -198,7 +199,9 @@ struct SessionMenuPreviewView: View {
self.status = .error("Preview unavailable") self.status = .error("Preview unavailable")
} }
} }
Self.logger.warning("Session preview failed session=\(self.sessionKey, privacy: .public) error=\(String(describing: error), privacy: .public)") Self.logger
.warning(
"Session preview failed session=\(self.sessionKey, privacy: .public) error=\(String(describing: error), privacy: .public)")
} }
} }

View File

@@ -285,8 +285,7 @@ struct TailscaleIntegrationSection: View {
requireCredentialsForServe: self.requireCredentialsForServe, requireCredentialsForServe: self.requireCredentialsForServe,
password: trimmedPassword, password: trimmedPassword,
connectionMode: self.connectionMode, connectionMode: self.connectionMode,
isPaused: self.isPaused isPaused: self.isPaused)
)
if !success, let errorMessage { if !success, let errorMessage {
self.statusMessage = errorMessage self.statusMessage = errorMessage
@@ -307,8 +306,8 @@ struct TailscaleIntegrationSection: View {
requireCredentialsForServe: Bool, requireCredentialsForServe: Bool,
password: String, password: String,
connectionMode: AppState.ConnectionMode, connectionMode: AppState.ConnectionMode,
isPaused: Bool isPaused: Bool) async -> (Bool, String?)
) async -> (Bool, String?) { {
var root = await ConfigStore.load() var root = await ConfigStore.load()
var gateway = root["gateway"] as? [String: Any] ?? [:] var gateway = root["gateway"] as? [String: Any] ?? [:]
var tailscale = gateway["tailscale"] as? [String: Any] ?? [:] var tailscale = gateway["tailscale"] as? [String: Any] ?? [:]
@@ -349,7 +348,7 @@ struct TailscaleIntegrationSection: View {
do { do {
try await ConfigStore.save(root) try await ConfigStore.save(root)
return (true, nil) return (true, nil)
} catch let error { } catch {
return (false, error.localizedDescription) return (false, error.localizedDescription)
} }
} }

View File

@@ -35,14 +35,14 @@ struct TalkOverlayView: View {
.frame(width: 18, height: 18) .frame(width: 18, height: 18)
.background(Color.black.opacity(0.4)) .background(Color.black.opacity(0.4))
.clipShape(Circle()) .clipShape(Circle())
}
.buttonStyle(.plain)
.contentShape(Circle())
.offset(x: -2, y: -2)
.opacity(self.hoveringWindow ? 1 : 0)
.animation(.easeOut(duration: 0.12), value: self.hoveringWindow)
} }
.buttonStyle(.plain) .onHover { self.hoveringWindow = $0 }
.contentShape(Circle())
.offset(x: -2, y: -2)
.opacity(self.hoveringWindow ? 1 : 0)
.animation(.easeOut(duration: 0.12), value: self.hoveringWindow)
}
.onHover { self.hoveringWindow = $0 }
} }
.frame( .frame(
width: TalkOverlayController.overlaySize, width: TalkOverlayController.overlaySize,
@@ -124,7 +124,7 @@ private final class OrbInteractionNSView: NSView {
} }
override func mouseUp(with event: NSEvent) { override func mouseUp(with event: NSEvent) {
if !self.didDrag && !self.suppressSingleClick { if !self.didDrag, !self.suppressSingleClick {
self.onSingleClick?() self.onSingleClick?()
} }
self.mouseDownEvent = nil self.mouseDownEvent = nil
@@ -148,8 +148,8 @@ private struct TalkOrbView: View {
} else { } else {
TimelineView(.animation) { context in TimelineView(.animation) { context in
let t = context.date.timeIntervalSinceReferenceDate let t = context.date.timeIntervalSinceReferenceDate
let listenScale = phase == .listening ? (1 + CGFloat(self.level) * 0.12) : 1 let listenScale = self.phase == .listening ? (1 + CGFloat(self.level) * 0.12) : 1
let pulse = phase == .speaking ? (1 + 0.06 * sin(t * 6)) : 1 let pulse = self.phase == .speaking ? (1 + 0.06 * sin(t * 6)) : 1
ZStack { ZStack {
Circle() Circle()
@@ -158,9 +158,9 @@ private struct TalkOrbView: View {
.shadow(color: Color.black.opacity(0.22), radius: 10, x: 0, y: 5) .shadow(color: Color.black.opacity(0.22), radius: 10, x: 0, y: 5)
.scaleEffect(pulse * listenScale) .scaleEffect(pulse * listenScale)
TalkWaveRings(phase: phase, level: level, time: t, accent: self.accent) TalkWaveRings(phase: self.phase, level: self.level, time: t, accent: self.accent)
if phase == .thinking { if self.phase == .thinking {
TalkOrbitArcs(time: t) TalkOrbitArcs(time: t)
} }
} }
@@ -186,11 +186,12 @@ private struct TalkWaveRings: View {
var body: some View { var body: some View {
ZStack { ZStack {
ForEach(0..<3, id: \.self) { idx in ForEach(0..<3, id: \.self) { idx in
let speed = phase == .speaking ? 1.4 : phase == .listening ? 0.9 : 0.6 let speed = self.phase == .speaking ? 1.4 : self.phase == .listening ? 0.9 : 0.6
let progress = (time * speed + Double(idx) * 0.28).truncatingRemainder(dividingBy: 1) let progress = (time * speed + Double(idx) * 0.28).truncatingRemainder(dividingBy: 1)
let amplitude = phase == .speaking ? 0.95 : phase == .listening ? 0.5 + level * 0.7 : 0.35 let amplitude = self.phase == .speaking ? 0.95 : self.phase == .listening ? 0.5 + self
let scale = 0.75 + progress * amplitude + (phase == .listening ? level * 0.15 : 0) .level * 0.7 : 0.35
let alpha = phase == .speaking ? 0.72 : phase == .listening ? 0.58 + level * 0.28 : 0.4 let scale = 0.75 + progress * amplitude + (self.phase == .listening ? self.level * 0.15 : 0)
let alpha = self.phase == .speaking ? 0.72 : self.phase == .listening ? 0.58 + self.level * 0.28 : 0.4
Circle() Circle()
.stroke(self.accent.opacity(alpha - progress * 0.3), lineWidth: 1.6) .stroke(self.accent.opacity(alpha - progress * 0.3), lineWidth: 1.6)
.scaleEffect(scale) .scaleEffect(scale)
@@ -208,11 +209,11 @@ private struct TalkOrbitArcs: View {
Circle() Circle()
.trim(from: 0.08, to: 0.26) .trim(from: 0.08, to: 0.26)
.stroke(Color.white.opacity(0.88), style: StrokeStyle(lineWidth: 1.6, lineCap: .round)) .stroke(Color.white.opacity(0.88), style: StrokeStyle(lineWidth: 1.6, lineCap: .round))
.rotationEffect(.degrees(time * 42)) .rotationEffect(.degrees(self.time * 42))
Circle() Circle()
.trim(from: 0.62, to: 0.86) .trim(from: 0.62, to: 0.86)
.stroke(Color.white.opacity(0.7), style: StrokeStyle(lineWidth: 1.4, lineCap: .round)) .stroke(Color.white.opacity(0.7), style: StrokeStyle(lineWidth: 1.4, lineCap: .round))
.rotationEffect(.degrees(-time * 35)) .rotationEffect(.degrees(-self.time * 35))
} }
.scaleEffect(1.08) .scaleEffect(1.08)
} }

View File

@@ -213,7 +213,7 @@ final class WorkActivityStore {
meta: String?, meta: String?,
args: [String: AnyCodable]?) -> String args: [String: AnyCodable]?) -> String
{ {
let wrappedArgs = 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)
if let detail = display.detailLine, !detail.isEmpty { if let detail = display.detailLine, !detail.isEmpty {
return "\(display.label): \(detail)" return "\(display.label): \(detail)"
@@ -223,22 +223,22 @@ final class WorkActivityStore {
private static func wrapToolArgs(_ args: [String: AnyCodable]?) -> ClawdisKit.AnyCodable? { private static func wrapToolArgs(_ args: [String: AnyCodable]?) -> ClawdisKit.AnyCodable? {
guard let args else { return nil } guard let args else { return nil }
let converted: [String: Any] = args.mapValues { unwrapJSONValue($0.value) } let converted: [String: Any] = args.mapValues { self.unwrapJSONValue($0.value) }
return ClawdisKit.AnyCodable(converted) return ClawdisKit.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: AnyCodable] {
return dict.mapValues { unwrapJSONValue($0.value) } return dict.mapValues { self.unwrapJSONValue($0.value) }
} }
if let array = value as? [AnyCodable] { if let array = value as? [AnyCodable] {
return array.map { unwrapJSONValue($0.value) } return array.map { self.unwrapJSONValue($0.value) }
} }
if let dict = value as? [String: Any] { if let dict = value as? [String: Any] {
return dict.mapValues { unwrapJSONValue($0) } return dict.mapValues { self.unwrapJSONValue($0) }
} }
if let array = value as? [Any] { if let array = value as? [Any] {
return array.map { unwrapJSONValue($0) } return array.map { self.unwrapJSONValue($0) }
} }
return value return value
} }

View File

@@ -826,7 +826,7 @@ public struct WizardStatusParams: Codable {
} }
} }
public struct WizardStep: Codable { public struct WizardStep: Codable, Sendable {
public let id: String public let id: String
public let type: AnyCodable public let type: AnyCodable
public let title: String? public let title: String?
@@ -871,7 +871,7 @@ public struct WizardStep: Codable {
} }
} }
public struct WizardNextResult: Codable { public struct WizardNextResult: Codable, Sendable {
public let done: Bool public let done: Bool
public let step: [String: AnyCodable]? public let step: [String: AnyCodable]?
public let status: AnyCodable? public let status: AnyCodable?
@@ -896,7 +896,7 @@ public struct WizardNextResult: Codable {
} }
} }
public struct WizardStartResult: Codable { public struct WizardStartResult: Codable, Sendable {
public let sessionid: String public let sessionid: String
public let done: Bool public let done: Bool
public let step: [String: AnyCodable]? public let step: [String: AnyCodable]?
@@ -925,7 +925,7 @@ public struct WizardStartResult: Codable {
} }
} }
public struct WizardStatusResult: Codable { public struct WizardStatusResult: Codable, Sendable {
public let status: AnyCodable public let status: AnyCodable
public let error: String? public let error: String?