mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2026-04-21 23:18:06 -04:00
916 lines
39 KiB
Swift
916 lines
39 KiB
Swift
#if os(macOS)
|
|
import Foundation
|
|
import Darwin
|
|
|
|
/// Main client for interacting with the Spacedrive daemon
|
|
///
|
|
/// This client provides a clean, type-safe interface for executing queries,
|
|
/// actions, and subscribing to events from the Spacedrive core.
|
|
public class SpacedriveClient {
|
|
private let socketPath: String
|
|
|
|
/// The currently active library ID
|
|
/// This is used for library-scoped operations
|
|
private var currentLibraryId: String?
|
|
|
|
/// Thread-safe access to current library ID
|
|
private let libraryIdQueue = DispatchQueue(
|
|
label: "com.spacedrive.library-id", attributes: .concurrent)
|
|
|
|
/// Initialize a new Spacedrive client
|
|
/// - Parameter socketPath: Path to the Unix domain socket for the daemon
|
|
public init(socketPath: String) {
|
|
self.socketPath = socketPath
|
|
}
|
|
|
|
// MARK: - API Namespaces
|
|
|
|
/// Core API operations (device management, network, etc.)
|
|
public lazy var core = CoreAPI(client: self)
|
|
|
|
/// Device management operations
|
|
public lazy var devices = DevicesAPI(client: self)
|
|
|
|
/// Library management operations
|
|
public lazy var libraries = LibrariesAPI(client: self)
|
|
|
|
/// Job management operations
|
|
public lazy var jobs = JobsAPI(client: self)
|
|
|
|
/// Location management operations
|
|
public lazy var locations = LocationsAPI(client: self)
|
|
|
|
/// Media operations
|
|
public lazy var media = MediaAPI(client: self)
|
|
|
|
/// Network operations
|
|
public lazy var network = NetworkAPI(client: self)
|
|
|
|
/// Search operations
|
|
public lazy var search = SearchAPI(client: self)
|
|
|
|
/// Tag operations
|
|
public lazy var tags = TagsAPI(client: self)
|
|
|
|
/// Volume operations
|
|
public lazy var volumes = VolumesAPI(client: self)
|
|
|
|
/// File operations
|
|
public lazy var files = FilesAPI(client: self)
|
|
|
|
// MARK: - Library Management
|
|
|
|
/// Get the currently active library ID
|
|
/// - Returns: The current library ID, or nil if no library is active
|
|
public func getCurrentLibraryId() -> String? {
|
|
return libraryIdQueue.sync { currentLibraryId }
|
|
}
|
|
|
|
/// Set the currently active library
|
|
/// - Parameter libraryId: The ID of the library to make active
|
|
public func setCurrentLibrary(_ libraryId: String) {
|
|
libraryIdQueue.async(flags: .barrier) {
|
|
self.currentLibraryId = libraryId
|
|
}
|
|
}
|
|
|
|
/// Clear the currently active library (set to nil)
|
|
public func clearCurrentLibrary() {
|
|
libraryIdQueue.async(flags: .barrier) {
|
|
self.currentLibraryId = nil
|
|
}
|
|
}
|
|
|
|
/// Switch to a library by ID
|
|
/// - Parameter libraryId: The ID of the library to switch to
|
|
/// - Throws: SpacedriveError if the library doesn't exist or can't be accessed
|
|
public func switchToLibrary(_ libraryId: String) async throws {
|
|
// Check if the library exists in the list (core-scoped query)
|
|
let libraries = try await getLibraries()
|
|
let libraryExists = libraries.contains { $0.id == libraryId }
|
|
|
|
if !libraryExists {
|
|
throw SpacedriveError.invalidResponse("Library with ID '\(libraryId)' not found")
|
|
}
|
|
|
|
// Set as current library
|
|
setCurrentLibrary(libraryId)
|
|
}
|
|
|
|
/// Switch to a library by name
|
|
/// - Parameter libraryName: The name of the library to switch to
|
|
/// - Throws: SpacedriveError if the library doesn't exist or multiple libraries have the same name
|
|
public func switchToLibrary(named libraryName: String) async throws {
|
|
let libraries = try await getLibraries()
|
|
let matchingLibraries = libraries.filter { $0.name == libraryName }
|
|
|
|
switch matchingLibraries.count {
|
|
case 0:
|
|
throw SpacedriveError.invalidResponse("No library found with name '\(libraryName)'")
|
|
case 1:
|
|
setCurrentLibrary(matchingLibraries[0].id)
|
|
default:
|
|
throw SpacedriveError.invalidResponse(
|
|
"Multiple libraries found with name '\(libraryName)'. Use switchToLibrary(id:) instead."
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Get information about the currently active library
|
|
/// - Returns: LibraryInfo for the current library, or nil if no library is active
|
|
/// - Throws: SpacedriveError if the library can't be accessed
|
|
public func getCurrentLibraryInfo() async throws -> LibraryInfo? {
|
|
guard let libraryId = getCurrentLibraryId() else {
|
|
return nil
|
|
}
|
|
|
|
let libraries = try await getLibraries()
|
|
return libraries.first { $0.id == libraryId }
|
|
}
|
|
|
|
/// Check if a library operation can be performed (requires current library)
|
|
/// - Throws: SpacedriveError if no library is currently active
|
|
private func requireCurrentLibrary() throws {
|
|
guard getCurrentLibraryId() != nil else {
|
|
throw SpacedriveError.invalidResponse(
|
|
"This operation requires an active library. Use switchToLibrary() or createAndSwitchToLibrary() first."
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Get the current library ID or throw an error if none is set
|
|
/// - Returns: The current library ID
|
|
/// - Throws: SpacedriveError if no library is currently active
|
|
private func getCurrentLibraryIdOrThrow() throws -> String {
|
|
guard let libraryId = getCurrentLibraryId() else {
|
|
throw SpacedriveError.invalidResponse(
|
|
"This operation requires an active library. Use switchToLibrary() or createAndSwitchToLibrary() first."
|
|
)
|
|
}
|
|
return libraryId
|
|
}
|
|
|
|
// MARK: - Core API Methods
|
|
|
|
/// Internal method to execute both queries and actions
|
|
/// - Parameters:
|
|
/// - requestPayload: The input payload (can be empty struct for parameterless operations)
|
|
/// - method: The method identifier (e.g., "query:core.status" or "action:libraries.create.input")
|
|
/// - responseType: The expected response type
|
|
/// - libraryId: Optional library ID to override the current library (for library-scoped operations)
|
|
/// - Returns: The operation result
|
|
internal func execute<Request: Codable, Response: Codable>(
|
|
_ requestPayload: Request,
|
|
method: String,
|
|
responseType: Response.Type,
|
|
libraryId: String? = nil
|
|
) async throws -> Response {
|
|
// 1. Handle unit types (Empty) specially - they should send null payload for unit types
|
|
let jsonPayload: [String: Any]?
|
|
if requestPayload is Empty {
|
|
jsonPayload = nil // Unit types should have null payload, not empty object
|
|
} else {
|
|
// Encode input to JSON for non-unit types
|
|
let requestData: Data
|
|
do {
|
|
requestData = try JSONEncoder().encode(requestPayload)
|
|
} catch {
|
|
throw SpacedriveError.serializationError("Failed to encode request: \(error)")
|
|
}
|
|
jsonPayload = try JSONSerialization.jsonObject(with: requestData) as? [String: Any]
|
|
}
|
|
|
|
// 3. Determine request type from method prefix and include library ID if needed
|
|
let request: DaemonRequest
|
|
let effectiveLibraryId = libraryId ?? getCurrentLibraryId()
|
|
|
|
if method.hasPrefix("query:") {
|
|
// Core queries (like core.status) don't need library ID
|
|
let queryLibraryId = method.hasPrefix("query:core.") ? nil : effectiveLibraryId
|
|
request = DaemonRequest.query(
|
|
method: method, libraryId: queryLibraryId, payload: jsonPayload ?? [:])
|
|
} else if method.hasPrefix("action:") {
|
|
request = DaemonRequest.action(
|
|
method: method, libraryId: effectiveLibraryId, payload: jsonPayload ?? [:])
|
|
} else {
|
|
throw SpacedriveError.invalidResponse("Invalid method format: \(method)")
|
|
}
|
|
|
|
// 4. Send to daemon and get response
|
|
let response = try await sendRequest(request)
|
|
|
|
// 5. Handle response
|
|
switch response {
|
|
case .jsonOk(let jsonData):
|
|
do {
|
|
let jsonResponseData = try JSONSerialization.data(
|
|
withJSONObject: jsonData.value)
|
|
return try JSONDecoder().decode(responseType, from: jsonResponseData)
|
|
} catch {
|
|
throw SpacedriveError.serializationError(
|
|
"Failed to decode JSON response: \(error)")
|
|
}
|
|
case .error(let error):
|
|
print("Daemon error: \(error)")
|
|
throw SpacedriveError.daemonError(error)
|
|
case .pong, .event, .subscribed, .unsubscribed:
|
|
print("Unexpected response: \(response)")
|
|
throw SpacedriveError.invalidResponse("Unexpected response to operation")
|
|
}
|
|
}
|
|
|
|
/// Subscribe to events from the daemon
|
|
/// - Parameter eventTypes: Array of event type names to subscribe to
|
|
/// - Returns: An async stream of events
|
|
public func subscribe(
|
|
to eventTypes: [String] = []
|
|
) -> AsyncThrowingStream<Event, Error> {
|
|
AsyncThrowingStream { continuation in
|
|
Task { [self] in
|
|
do {
|
|
// 1. Establish persistent connection to daemon
|
|
let connection = try await createConnection()
|
|
|
|
// 2. Send subscription request
|
|
let request = DaemonRequest.subscribe(eventTypes: eventTypes, filter: nil)
|
|
try await sendRequestOverConnection(request, connection: connection)
|
|
|
|
// 3. Stream events as they arrive
|
|
while true {
|
|
let response = try await readStreamingResponseFromConnection(connection)
|
|
if case .event(let event) = response {
|
|
continuation.yield(event)
|
|
}
|
|
}
|
|
} catch {
|
|
print("Event subscription error: \(error)")
|
|
continuation.finish(throwing: error)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Private Implementation
|
|
|
|
/// Send a request to the daemon and wait for response
|
|
private func sendRequest(_ request: DaemonRequest) async throws -> DaemonResponse {
|
|
let connection = try await createConnection()
|
|
defer {
|
|
close(connection)
|
|
}
|
|
|
|
// Extract method for logging
|
|
let method: String?
|
|
switch request {
|
|
case .query(let m, _, _), .action(let m, _, _):
|
|
method = m
|
|
case .ping, .subscribe, .unsubscribe, .shutdown:
|
|
method = nil
|
|
}
|
|
|
|
try await sendRequestOverConnection(request, connection: connection)
|
|
let response = try await readResponseFromConnection(connection, method: method)
|
|
return response
|
|
}
|
|
|
|
/// Create a Unix domain socket connection to the daemon
|
|
private func createConnection() async throws -> Int32 {
|
|
print("Creating BSD socket connection to: \(socketPath)")
|
|
|
|
return try await withCheckedThrowingContinuation { continuation in
|
|
DispatchQueue.global().async {
|
|
do {
|
|
// Create socket
|
|
let socketFD = socket(AF_UNIX, SOCK_STREAM, 0)
|
|
guard socketFD != -1 else {
|
|
continuation.resume(
|
|
throwing: SpacedriveError.connectionFailed(
|
|
"Failed to create socket"))
|
|
return
|
|
}
|
|
|
|
// Set up address
|
|
var addr = sockaddr_un()
|
|
addr.sun_family = sa_family_t(AF_UNIX)
|
|
let pathBytes = self.socketPath.utf8CString
|
|
let pathSize = MemoryLayout.size(ofValue: addr.sun_path)
|
|
guard pathBytes.count <= pathSize else {
|
|
close(socketFD)
|
|
continuation.resume(
|
|
throwing: SpacedriveError.connectionFailed("Socket path too long"))
|
|
return
|
|
}
|
|
|
|
// Copy path bytes to sun_path
|
|
withUnsafeMutablePointer(to: &addr.sun_path.0) { pathPtr in
|
|
for (index, byte) in pathBytes.enumerated() {
|
|
if index >= pathSize { break }
|
|
pathPtr.advanced(by: index).pointee = byte
|
|
}
|
|
}
|
|
|
|
// Connect
|
|
let connectResult = withUnsafePointer(to: &addr) { ptr in
|
|
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in
|
|
Darwin.connect(
|
|
socketFD, sockaddrPtr, socklen_t(MemoryLayout<sockaddr_un>.size)
|
|
)
|
|
}
|
|
}
|
|
|
|
guard connectResult == 0 else {
|
|
let errorMsg = String(cString: strerror(errno))
|
|
close(socketFD)
|
|
continuation.resume(
|
|
throwing: SpacedriveError.connectionFailed(
|
|
"Failed to connect: \(errorMsg)"))
|
|
return
|
|
}
|
|
|
|
print("BSD socket connected successfully!")
|
|
continuation.resume(returning: socketFD)
|
|
} catch {
|
|
continuation.resume(throwing: error)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Send a request over an existing connection
|
|
private func sendRequestOverConnection(_ request: DaemonRequest, connection: Int32)
|
|
async throws
|
|
{
|
|
let requestData: Data
|
|
|
|
// Handle JSON requests manually to avoid Swift Codable issues with Any types
|
|
switch request {
|
|
case .ping:
|
|
requestData = Data("\"Ping\"".utf8)
|
|
|
|
case .query(let method, let libraryId, let payload):
|
|
// Build the JSON string properly, handling trailing commas
|
|
var queryParts: [String] = ["\"method\":\"\(method)\""]
|
|
if let libraryId = libraryId {
|
|
queryParts.append("\"library_id\":\"\(libraryId)\"")
|
|
} else {
|
|
// Always include library_id field, even if null
|
|
queryParts.append("\"library_id\":null")
|
|
}
|
|
// Include payload field - send null for unit types (Empty payloads)
|
|
if payload.isEmpty && method.hasPrefix("query:core.") {
|
|
queryParts.append("\"payload\":null")
|
|
} else {
|
|
queryParts.append("\"payload\":\(try jsonStringFromDictionary(payload))")
|
|
}
|
|
|
|
let jsonString = """
|
|
{"Query":{\(queryParts.joined(separator: ","))}}
|
|
"""
|
|
print("Sending query request: \(jsonString)")
|
|
requestData = Data(jsonString.utf8)
|
|
|
|
case .action(let method, let libraryId, let payload):
|
|
let libraryIdJson = libraryId.map { "\"library_id\":\"\($0)\"," } ?? ""
|
|
let jsonString = """
|
|
{"Action":{"method":"\(method)",\(libraryIdJson)"payload":\(try jsonStringFromDictionary(payload))}}
|
|
"""
|
|
requestData = Data(jsonString.utf8)
|
|
|
|
case .subscribe(let eventTypes, let filter):
|
|
let subscribeRequest = SubscribeRequest(event_types: eventTypes, filter: filter)
|
|
requestData = try JSONEncoder().encode(["Subscribe": subscribeRequest])
|
|
|
|
case .unsubscribe:
|
|
requestData = Data("\"Unsubscribe\"".utf8)
|
|
|
|
case .shutdown:
|
|
requestData = Data("\"Shutdown\"".utf8)
|
|
}
|
|
|
|
let requestLine = requestData + Data("\n".utf8)
|
|
|
|
try await withCheckedThrowingContinuation {
|
|
(continuation: CheckedContinuation<Void, Error>) in
|
|
DispatchQueue.global().async {
|
|
let sendResult = requestLine.withUnsafeBytes { bytes in
|
|
send(
|
|
connection, bytes.bindMemory(to: UInt8.self).baseAddress,
|
|
requestLine.count, 0)
|
|
}
|
|
|
|
if sendResult == -1 {
|
|
let errorMsg = String(cString: strerror(errno))
|
|
continuation.resume(
|
|
throwing: SpacedriveError.connectionFailed("Send failed: \(errorMsg)"))
|
|
} else {
|
|
continuation.resume()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Read a response from an existing connection
|
|
private func readResponseFromConnection(_ connection: Int32, method: String? = nil)
|
|
async throws -> DaemonResponse
|
|
{
|
|
return try await withCheckedThrowingContinuation { continuation in
|
|
DispatchQueue.global().async {
|
|
print("Starting to read response from connection...")
|
|
|
|
var allData = Data()
|
|
let bufferSize = 65536
|
|
var buffer = [UInt8](repeating: 0, count: bufferSize)
|
|
var totalBytesRead = 0
|
|
|
|
// Keep reading until we get a complete line (ending with newline)
|
|
while true {
|
|
let readResult = recv(connection, &buffer, buffer.count, 0)
|
|
print("Socket read result: \(readResult) bytes")
|
|
|
|
guard readResult > 0 else {
|
|
let errorMsg = String(cString: strerror(errno))
|
|
print("Socket read failed: \(errorMsg)")
|
|
continuation.resume(
|
|
throwing: SpacedriveError.connectionFailed(
|
|
"Receive failed: \(errorMsg)"))
|
|
return
|
|
}
|
|
|
|
allData.append(Data(buffer.prefix(readResult)))
|
|
totalBytesRead += readResult
|
|
print("Total bytes read so far: \(totalBytesRead)")
|
|
|
|
// Check if we have a complete line (ending with newline)
|
|
if let responseString = String(data: allData, encoding: .utf8) {
|
|
if responseString.contains("\n") {
|
|
print("Found newline, stopping read")
|
|
break
|
|
}
|
|
}
|
|
|
|
// Safety check to prevent infinite loop
|
|
if totalBytesRead > 10 * 1024 * 1024 { // 10MB limit
|
|
print("Response too large, stopping read")
|
|
continuation.resume(
|
|
throwing: SpacedriveError.invalidResponse("Response too large"))
|
|
return
|
|
}
|
|
}
|
|
|
|
print("Final total bytes: \(allData.count)")
|
|
|
|
do {
|
|
// Find the newline delimiter and parse JSON
|
|
if let responseString = String(data: allData, encoding: .utf8) {
|
|
print("Response string length: \(responseString.count) characters")
|
|
print("First 200 chars: \(String(responseString.prefix(200)))")
|
|
|
|
let lines = responseString.components(separatedBy: .newlines).filter {
|
|
!$0.isEmpty
|
|
}
|
|
print("Found \(lines.count) lines in response")
|
|
|
|
if let firstLine = lines.first {
|
|
print("First line length: \(firstLine.count) characters")
|
|
print("First line preview: \(String(firstLine.prefix(100)))...")
|
|
|
|
let lineData = Data(firstLine.utf8)
|
|
print("Attempting to decode JSON from \(lineData.count) bytes...")
|
|
|
|
let response = try JSONDecoder().decode(
|
|
DaemonResponse.self, from: lineData)
|
|
print("Successfully decoded response")
|
|
|
|
if let method = method {
|
|
print("Daemon response for \(method): \(response)")
|
|
} else {
|
|
print("Daemon response: \(response)")
|
|
}
|
|
continuation.resume(returning: response)
|
|
} else {
|
|
print("No valid response line found")
|
|
continuation.resume(
|
|
throwing: SpacedriveError.invalidResponse(
|
|
"No valid response line"))
|
|
}
|
|
} else {
|
|
print("Invalid UTF-8 response")
|
|
continuation.resume(
|
|
throwing: SpacedriveError.invalidResponse("Invalid UTF-8 response"))
|
|
}
|
|
} catch {
|
|
print("JSON decoding failed: \(error)")
|
|
print("Error details: \(error.localizedDescription)")
|
|
continuation.resume(
|
|
throwing: SpacedriveError.serializationError(
|
|
"Failed to decode response: \(error)"))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Read a complete line-delimited JSON response from a streaming connection
|
|
/// This properly handles JSON messages that span multiple socket reads
|
|
private func readStreamingResponseFromConnection(_ connection: Int32) async throws
|
|
-> DaemonResponse
|
|
{
|
|
return try await withCheckedThrowingContinuation { continuation in
|
|
DispatchQueue.global().async {
|
|
var lineBuffer = Data()
|
|
var tempBuffer = [UInt8](repeating: 0, count: 1024)
|
|
|
|
// Read byte by byte until we find a complete line (ending with \n)
|
|
while true {
|
|
let readResult = recv(connection, &tempBuffer, 1, 0) // Read 1 byte at a time
|
|
|
|
guard readResult > 0 else {
|
|
let errorMsg = String(cString: strerror(errno))
|
|
print("Stream receive failed: \(errorMsg)")
|
|
continuation.resume(
|
|
throwing: SpacedriveError.connectionFailed(
|
|
"Stream receive failed: \(errorMsg)"))
|
|
return
|
|
}
|
|
|
|
let byte = tempBuffer[0]
|
|
|
|
// Check for newline (end of JSON message)
|
|
if byte == 10 { // ASCII newline
|
|
// We have a complete line, try to parse it
|
|
if let lineString = String(data: lineBuffer, encoding: .utf8) {
|
|
let trimmedLine = lineString.trimmingCharacters(
|
|
in: .whitespacesAndNewlines)
|
|
if !trimmedLine.isEmpty {
|
|
|
|
do {
|
|
let response = try JSONDecoder().decode(
|
|
DaemonResponse.self, from: Data(trimmedLine.utf8))
|
|
continuation.resume(returning: response)
|
|
return
|
|
} catch {
|
|
print("Failed to decode JSON line: \(error)")
|
|
print("Raw line: \(trimmedLine)")
|
|
continuation.resume(
|
|
throwing: SpacedriveError.serializationError(
|
|
"Failed to decode JSON: \(error)"))
|
|
return
|
|
}
|
|
}
|
|
} else {
|
|
print("Invalid UTF-8 in line buffer")
|
|
continuation.resume(
|
|
throwing: SpacedriveError.invalidResponse(
|
|
"Invalid UTF-8 in response"))
|
|
return
|
|
}
|
|
} else {
|
|
// Accumulate byte into line buffer
|
|
lineBuffer.append(byte)
|
|
|
|
// Safety check to prevent infinite accumulation
|
|
if lineBuffer.count > 10 * 1024 * 1024 { // 10MB limit
|
|
print("JSON line too large (\(lineBuffer.count) bytes)")
|
|
continuation.resume(
|
|
throwing: SpacedriveError.invalidResponse("JSON line too large")
|
|
)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Helper to convert dictionary to JSON string
|
|
private func jsonStringFromDictionary(_ dict: [String: Any]) throws -> String {
|
|
let jsonData = try JSONSerialization.data(withJSONObject: dict)
|
|
return String(data: jsonData, encoding: .utf8) ?? "{}"
|
|
}
|
|
}
|
|
|
|
// MARK: - Daemon Protocol Types
|
|
|
|
/// Request types that match the Rust daemon protocol
|
|
internal enum DaemonRequest {
|
|
case ping
|
|
case action(method: String, libraryId: String?, payload: [String: Any])
|
|
case query(method: String, libraryId: String?, payload: [String: Any])
|
|
case subscribe(eventTypes: [String], filter: EventFilter?)
|
|
case unsubscribe
|
|
case shutdown
|
|
}
|
|
|
|
/// Helper structs for proper JSON encoding
|
|
|
|
private struct SubscribeRequest: Codable {
|
|
let event_types: [String]
|
|
let filter: EventFilter?
|
|
}
|
|
|
|
/// Response types that match the Rust daemon protocol
|
|
internal enum DaemonResponse: Codable {
|
|
case pong
|
|
case jsonOk(AnyCodable)
|
|
case error(String)
|
|
case event(Event)
|
|
case subscribed
|
|
case unsubscribed
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let container = try decoder.singleValueContainer()
|
|
|
|
// Try to decode as a simple string first (for Pong, Subscribed, Unsubscribed)
|
|
if let stringValue = try? container.decode(String.self) {
|
|
switch stringValue {
|
|
case "Pong":
|
|
self = .pong
|
|
case "Subscribed":
|
|
self = .subscribed
|
|
case "Unsubscribed":
|
|
self = .unsubscribed
|
|
default:
|
|
throw DecodingError.dataCorrupted(
|
|
DecodingError.Context(
|
|
codingPath: decoder.codingPath,
|
|
debugDescription: "Unknown string response: \(stringValue)")
|
|
)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Try to decode as an object with variants
|
|
let variantContainer = try decoder.container(keyedBy: VariantKeys.self)
|
|
|
|
if variantContainer.contains(.jsonOk) {
|
|
// JsonOk contains a JSON value that we need to decode manually
|
|
let jsonValue = try variantContainer.decode(AnyCodable.self, forKey: .jsonOk)
|
|
self = .jsonOk(jsonValue)
|
|
} else if variantContainer.contains(.error) {
|
|
// Decode error as a structured object
|
|
let errorObject = try variantContainer.decode(AnyCodable.self, forKey: .error)
|
|
// Convert the error object to a string representation
|
|
if let errorData = try? JSONEncoder().encode(errorObject),
|
|
let errorString = String(data: errorData, encoding: .utf8)
|
|
{
|
|
self = .error(errorString)
|
|
} else {
|
|
self = .error("Unknown error")
|
|
}
|
|
} else if variantContainer.contains(.event) {
|
|
let event = try variantContainer.decode(Event.self, forKey: .event)
|
|
self = .event(event)
|
|
} else {
|
|
throw DecodingError.dataCorrupted(
|
|
DecodingError.Context(
|
|
codingPath: decoder.codingPath, debugDescription: "Unknown variant response"
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
enum VariantKeys: String, CodingKey {
|
|
case jsonOk = "JsonOk"
|
|
case error = "Error"
|
|
case event = "Event"
|
|
}
|
|
}
|
|
|
|
/// Event filter for subscriptions
|
|
internal struct EventFilter: Codable {
|
|
let libraryId: String?
|
|
let jobId: String?
|
|
let deviceId: String?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case libraryId = "library_id"
|
|
case jobId = "job_id"
|
|
case deviceId = "device_id"
|
|
}
|
|
}
|
|
|
|
// MARK: - Convenience Types
|
|
// (Shared types are now in SpacedriveShared.swift)
|
|
|
|
// MARK: - API Namespace Structs
|
|
// These are automatically generated by the Rust build process
|
|
// See SpacedriveAPI.swift for the actual implementations
|
|
|
|
/// Helper for decoding Any values from JSON
|
|
internal struct AnyCodable: Codable {
|
|
let value: Any
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let container = try decoder.singleValueContainer()
|
|
|
|
if let intValue = try? container.decode(Int.self) {
|
|
value = intValue
|
|
} else if let doubleValue = try? container.decode(Double.self) {
|
|
value = doubleValue
|
|
} else if let stringValue = try? container.decode(String.self) {
|
|
value = stringValue
|
|
} else if let boolValue = try? container.decode(Bool.self) {
|
|
value = boolValue
|
|
} else if let arrayValue = try? container.decode([AnyCodable].self) {
|
|
value = arrayValue.map { $0.value }
|
|
} else if let dictValue = try? container.decode([String: AnyCodable].self) {
|
|
value = dictValue.mapValues { $0.value }
|
|
} else {
|
|
value = NSNull()
|
|
}
|
|
}
|
|
|
|
func encode(to encoder: Encoder) throws {
|
|
// Not needed for our use case
|
|
throw EncodingError.invalidValue(
|
|
value,
|
|
EncodingError.Context(
|
|
codingPath: encoder.codingPath,
|
|
debugDescription: "AnyCodable encoding not implemented"))
|
|
}
|
|
}
|
|
|
|
// MARK: - Convenience Methods
|
|
|
|
extension SpacedriveClient {
|
|
|
|
/// Create a library using generated types
|
|
public func createLibrary(name: String, path: String? = nil) async throws
|
|
-> LibraryCreateOutput
|
|
{
|
|
let input = LibraryCreateInput(name: name, path: path)
|
|
|
|
return try await execute(
|
|
input,
|
|
method: "action:libraries.create.input",
|
|
responseType: LibraryCreateOutput.self
|
|
)
|
|
}
|
|
|
|
/// Create a library and automatically set it as the current library
|
|
/// - Parameters:
|
|
/// - name: The name of the library to create
|
|
/// - path: Optional path for the library
|
|
/// - setAsCurrent: Whether to automatically set the new library as current (default: true)
|
|
/// - Returns: The created library information
|
|
/// - Throws: SpacedriveError if creation fails
|
|
public func createAndSwitchToLibrary(
|
|
name: String, path: String? = nil, setAsCurrent: Bool = true
|
|
) async throws -> LibraryCreateOutput {
|
|
let result = try await createLibrary(name: name, path: path)
|
|
|
|
if setAsCurrent {
|
|
setCurrentLibrary(result.libraryId)
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
/// Get list of libraries using generated types
|
|
public func getLibraries(includeStats: Bool = false) async throws -> [LibraryInfo] {
|
|
struct LibraryListQuery: Codable {
|
|
let include_stats: Bool
|
|
}
|
|
|
|
let query = LibraryListQuery(include_stats: includeStats)
|
|
|
|
return try await execute(
|
|
query,
|
|
method: "query:libraries.list",
|
|
responseType: [LibraryInfo].self
|
|
)
|
|
}
|
|
|
|
/// Get list of jobs using generated types
|
|
public func getJobs(status: JobStatus? = nil) async throws -> JobListOutput {
|
|
struct JobListQuery: Codable {
|
|
let status: String?
|
|
}
|
|
|
|
let query = JobListQuery(status: nil) // TODO: Convert JobStatus to string
|
|
|
|
return try await execute(
|
|
query,
|
|
method: "query:jobs.list",
|
|
responseType: JobListOutput.self
|
|
)
|
|
}
|
|
|
|
/// Get jobs for the current library
|
|
/// - Parameter status: Optional job status filter
|
|
/// - Returns: List of jobs for the current library
|
|
/// - Throws: SpacedriveError if no library is active or operation fails
|
|
public func getCurrentLibraryJobs(status: JobStatus? = nil) async throws -> JobListOutput {
|
|
let libraryId = try getCurrentLibraryIdOrThrow()
|
|
|
|
struct JobListQuery: Codable {
|
|
let status: String?
|
|
}
|
|
|
|
let query = JobListQuery(status: nil) // TODO: Convert JobStatus to string
|
|
|
|
return try await execute(
|
|
query,
|
|
method: "query:jobs.list",
|
|
responseType: JobListOutput.self,
|
|
libraryId: libraryId
|
|
)
|
|
}
|
|
|
|
/// Get the current library status
|
|
/// - Returns: A string describing the current library state
|
|
public func getCurrentLibraryStatus() -> String {
|
|
guard let libraryId = getCurrentLibraryId() else {
|
|
return "No library is currently active"
|
|
}
|
|
return "Library '\(libraryId)' is currently active"
|
|
}
|
|
|
|
/// Check if a library is currently active
|
|
/// - Returns: True if a library is active, false otherwise
|
|
public func hasActiveLibrary() -> Bool {
|
|
return getCurrentLibraryId() != nil
|
|
}
|
|
|
|
/// Execute a library query
|
|
/// - Parameter query: The query object to execute
|
|
/// - Returns: The query result
|
|
public func query<T: Codable>(_ query: T) async throws -> T {
|
|
// For now, we'll use a simple approach - this should be improved
|
|
// to automatically determine the wire method from the query type
|
|
let wireMethod = "query:files.by_path" // This should be dynamic
|
|
|
|
// Encode the query to JSON
|
|
let queryData = try JSONEncoder().encode(query)
|
|
let queryDict =
|
|
try JSONSerialization.jsonObject(with: queryData) as? [String: Any] ?? [:]
|
|
|
|
let response = try await sendRequest(
|
|
.query(method: wireMethod, libraryId: getCurrentLibraryId(), payload: queryDict))
|
|
|
|
switch response {
|
|
case .jsonOk(let anyCodable):
|
|
// Convert AnyCodable to Data, then decode
|
|
let data = try JSONEncoder().encode(anyCodable)
|
|
let result = try JSONDecoder().decode(T.self, from: data)
|
|
return result
|
|
case .error(let error):
|
|
throw SpacedriveError.daemonError("Query failed: \(error)")
|
|
default:
|
|
throw SpacedriveError.invalidResponse("Unexpected response to query")
|
|
}
|
|
}
|
|
|
|
/// Execute a FileByPathQuery and return the File result
|
|
public func queryFileByPath(_ query: FileByPathQuery) async throws -> File? {
|
|
let wireMethod = "query:files.by_path"
|
|
|
|
// Encode the query to JSON
|
|
let queryData = try JSONEncoder().encode(query)
|
|
let queryDict =
|
|
try JSONSerialization.jsonObject(with: queryData) as? [String: Any] ?? [:]
|
|
|
|
let response = try await sendRequest(
|
|
.query(method: wireMethod, libraryId: getCurrentLibraryId(), payload: queryDict))
|
|
|
|
switch response {
|
|
case .jsonOk(let jsonData):
|
|
print("Decoding File from JSON data: \(jsonData.value)")
|
|
do {
|
|
let jsonResponseData = try JSONSerialization.data(
|
|
withJSONObject: jsonData.value)
|
|
let result = try JSONDecoder().decode(File.self, from: jsonResponseData)
|
|
print("Successfully decoded File: \(result.name)")
|
|
return result
|
|
} catch {
|
|
print("Failed to decode File: \(error)")
|
|
print("JSON data: \(jsonData.value)")
|
|
throw SpacedriveError.invalidResponse("Failed to decode File: \(error)")
|
|
}
|
|
case .error(let error):
|
|
throw SpacedriveError.daemonError("Query failed: \(error)")
|
|
default:
|
|
throw SpacedriveError.invalidResponse("Unexpected response to query")
|
|
}
|
|
}
|
|
|
|
/// Ping the daemon to test connectivity
|
|
public func ping() async throws {
|
|
print("Sending ping request...")
|
|
let response = try await sendRequest(.ping)
|
|
print("Received ping response: \(response)")
|
|
switch response {
|
|
case .pong:
|
|
print("Ping successful!")
|
|
return
|
|
case .error(let error):
|
|
print("Ping failed with daemon error: \(error)")
|
|
throw SpacedriveError.daemonError("Ping failed: \(error)")
|
|
case .jsonOk, .event, .subscribed, .unsubscribed:
|
|
print("Ping received unexpected response")
|
|
throw SpacedriveError.invalidResponse("Unexpected response to ping")
|
|
}
|
|
}
|
|
}
|
|
|
|
#endif
|