mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2026-05-19 05:45:01 -04:00
feat: Introduce Spacedrive Companion App for macOS
- Added a new SwiftUI application to monitor Spacedrive daemon jobs in real-time. - Implemented job status tracking with visual indicators for running, completed, failed, and paused jobs. - Included build and run scripts for easy setup and execution. - Created a README with detailed instructions on building, running, and using the application. - Added integration with the Spacedrive daemon via Unix domain socket for real-time updates. - Introduced a translucent window design for a seamless macOS experience.
This commit is contained in:
41
.vscode/launch.json
vendored
41
.vscode/launch.json
vendored
@@ -19,9 +19,10 @@
|
||||
},
|
||||
"env": {
|
||||
"RUST_BACKTRACE": "short"
|
||||
// "RUST_LOG": "sd_core::invalidate-query=trace"
|
||||
},
|
||||
"sourceLanguages": ["rust"],
|
||||
"sourceLanguages": [
|
||||
"rust"
|
||||
],
|
||||
"preLaunchTask": "ui:dev"
|
||||
},
|
||||
{
|
||||
@@ -36,7 +37,9 @@
|
||||
],
|
||||
"problemMatcher": "$rustc"
|
||||
},
|
||||
"sourceLanguages": ["rust"],
|
||||
"sourceLanguages": [
|
||||
"rust"
|
||||
],
|
||||
"preLaunchTask": "ui:build"
|
||||
},
|
||||
{
|
||||
@@ -44,7 +47,12 @@
|
||||
"request": "launch",
|
||||
"name": "Debug unit tests in library 'sd-core'",
|
||||
"cargo": {
|
||||
"args": ["test", "--no-run", "--lib", "--package=sd-core"],
|
||||
"args": [
|
||||
"test",
|
||||
"--no-run",
|
||||
"--lib",
|
||||
"--package=sd-core"
|
||||
],
|
||||
"filter": {
|
||||
"name": "sd-core",
|
||||
"kind": "lib"
|
||||
@@ -58,7 +66,12 @@
|
||||
"request": "launch",
|
||||
"name": "Debug unit tests in library 'sd-crypto'",
|
||||
"cargo": {
|
||||
"args": ["test", "--no-run", "--lib", "--package=sd-crypto"],
|
||||
"args": [
|
||||
"test",
|
||||
"--no-run",
|
||||
"--lib",
|
||||
"--package=sd-crypto"
|
||||
],
|
||||
"filter": {
|
||||
"name": "sd-crypto",
|
||||
"kind": "lib"
|
||||
@@ -66,6 +79,24 @@
|
||||
},
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
{
|
||||
"type": "swift",
|
||||
"request": "launch",
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder:spacedrive}/apps/spacedrive-companion",
|
||||
"name": "Debug SpacedriveCompanion (apps/spacedrive-companion)",
|
||||
"program": "${workspaceFolder:spacedrive}/apps/spacedrive-companion/.build/debug/SpacedriveCompanion",
|
||||
"preLaunchTask": "swift: Build Debug SpacedriveCompanion (apps/spacedrive-companion)"
|
||||
},
|
||||
{
|
||||
"type": "swift",
|
||||
"request": "launch",
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder:spacedrive}/apps/spacedrive-companion",
|
||||
"name": "Release SpacedriveCompanion (apps/spacedrive-companion)",
|
||||
"program": "${workspaceFolder:spacedrive}/apps/spacedrive-companion/.build/release/SpacedriveCompanion",
|
||||
"preLaunchTask": "swift: Build Release SpacedriveCompanion (apps/spacedrive-companion)"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
28
apps/spacedrive-companion/.gitignore
vendored
Normal file
28
apps/spacedrive-companion/.gitignore
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
# Build artifacts
|
||||
.build/
|
||||
*.xcodeproj
|
||||
*.xcworkspace
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# Swift Package Manager
|
||||
Package.resolved
|
||||
|
||||
# Xcode
|
||||
*.pbxuser
|
||||
*.mode1v3
|
||||
*.mode2v3
|
||||
*.perspectivev3
|
||||
*.xcuserstate
|
||||
project.xcworkspace/
|
||||
xcuserdata/
|
||||
|
||||
# Build products
|
||||
build/
|
||||
DerivedData/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
|
||||
27
apps/spacedrive-companion/Package.swift
Normal file
27
apps/spacedrive-companion/Package.swift
Normal file
@@ -0,0 +1,27 @@
|
||||
// swift-tools-version: 5.9
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "SpacedriveCompanion",
|
||||
platforms: [
|
||||
.macOS(.v13)
|
||||
],
|
||||
products: [
|
||||
.executable(
|
||||
name: "SpacedriveCompanion",
|
||||
targets: ["SpacedriveCompanion"]
|
||||
),
|
||||
],
|
||||
dependencies: [
|
||||
// Add any external dependencies here if needed
|
||||
],
|
||||
targets: [
|
||||
.executableTarget(
|
||||
name: "SpacedriveCompanion",
|
||||
dependencies: [],
|
||||
path: "Sources"
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
90
apps/spacedrive-companion/README.md
Normal file
90
apps/spacedrive-companion/README.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# Spacedrive SwiftUI Companion App
|
||||
|
||||
A lightweight, native macOS companion application for monitoring Spacedrive daemon jobs in real-time.
|
||||
|
||||
## Overview
|
||||
|
||||
This SwiftUI app provides a translucent, always-on-top window that displays live updates of jobs running in the Spacedrive daemon. It connects directly to the daemon via Unix domain socket and subscribes to job events for real-time updates.
|
||||
|
||||
## Features
|
||||
|
||||
- **Translucent Window**: Blends seamlessly with the macOS desktop environment
|
||||
- **Real-time Job Monitoring**: Live updates via daemon event subscription
|
||||
- **Job Status Tracking**: Visual indicators for running, completed, failed, and paused jobs
|
||||
- **Progress Visualization**: Progress bars for active jobs
|
||||
- **Connection Status**: Visual indicator of daemon connection health
|
||||
- **Minimal Resource Usage**: Lightweight companion app design
|
||||
|
||||
## Requirements
|
||||
|
||||
- macOS 13.0 or later
|
||||
- Running Spacedrive daemon instance
|
||||
- Swift 5.9 or later
|
||||
|
||||
## Building and Running
|
||||
|
||||
### Using Swift Package Manager
|
||||
|
||||
```bash
|
||||
cd /path/to/spacedrive/apps/spacedrive-companion
|
||||
swift build
|
||||
swift run
|
||||
```
|
||||
|
||||
### Using Xcode
|
||||
|
||||
1. Open the `Package.swift` file in Xcode
|
||||
2. Build and run the project (⌘+R)
|
||||
|
||||
## Architecture
|
||||
|
||||
The app follows a clean MVVM architecture:
|
||||
|
||||
- **Models** (`JobModels.swift`): Codable structs for job data and RPC communication
|
||||
- **Views**:
|
||||
- `ContentView.swift`: Main app view
|
||||
- `JobMonitorView.swift`: Job list container
|
||||
- `JobRowView.swift`: Individual job display
|
||||
- **ViewModel** (`JobListViewModel.swift`): State management and data binding
|
||||
- **Services** (`DaemonConnector.swift`): Unix socket communication with daemon
|
||||
- **UI Components** (`TranslucentWindow.swift`): Custom translucent window implementation
|
||||
|
||||
## Daemon Communication
|
||||
|
||||
The app communicates with the Spacedrive daemon via:
|
||||
|
||||
1. **Unix Domain Socket**: `~/.local/share/spacedrive/daemon/daemon.sock`
|
||||
2. **RPC Protocol**: JSON-based request/response over the socket
|
||||
3. **Event Subscription**: Real-time job event stream
|
||||
4. **Initial State**: `jobs.list` query on connection
|
||||
|
||||
### Supported Events
|
||||
|
||||
- `JobStarted`: New job initiated
|
||||
- `JobProgress`: Job progress updates
|
||||
- `JobCompleted`: Job finished successfully
|
||||
- `JobFailed`: Job encountered an error
|
||||
- `JobPaused`: Job was paused
|
||||
|
||||
## Usage
|
||||
|
||||
1. Ensure the Spacedrive daemon is running
|
||||
2. Launch the companion app
|
||||
3. The app will automatically connect and display job status
|
||||
4. Use the refresh button to reconnect if needed
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- Job control buttons (pause/resume/cancel)
|
||||
- System tray integration
|
||||
- Push notifications for job completion
|
||||
- Multiple library support
|
||||
- Job filtering and search
|
||||
|
||||
## Development Notes
|
||||
|
||||
This is a Proof of Concept implementation focusing on read-only job monitoring. The architecture is designed to easily accommodate future interactive features.
|
||||
|
||||
The app uses modern SwiftUI patterns and follows macOS design guidelines for system utility applications.
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@StateObject private var viewModel = JobListViewModel()
|
||||
|
||||
var body: some View {
|
||||
JobMonitorView(viewModel: viewModel)
|
||||
.frame(minWidth: 300, minHeight: 400)
|
||||
.background(VisualEffectBackground())
|
||||
.onAppear {
|
||||
// The view model automatically connects when initialized
|
||||
}
|
||||
.onDisappear {
|
||||
viewModel.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ContentView()
|
||||
.frame(width: 400, height: 600)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
import Foundation
|
||||
import Darwin
|
||||
|
||||
class DaemonConnector: ObservableObject {
|
||||
@Published var connectionStatus: ConnectionStatus = .disconnected
|
||||
@Published var jobs: [JobInfo] = []
|
||||
|
||||
private let socketPath = "\(NSHomeDirectory())/Library/Application Support/spacedrive/daemon/daemon.sock"
|
||||
private let queue = DispatchQueue(label: "daemon-connector", qos: .userInitiated)
|
||||
private var isConnected = false
|
||||
|
||||
init() {
|
||||
connect()
|
||||
}
|
||||
|
||||
deinit {
|
||||
disconnect()
|
||||
}
|
||||
|
||||
func connect() {
|
||||
guard connectionStatus != .connecting && connectionStatus != .connected else {
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.connectionStatus = .connecting
|
||||
}
|
||||
|
||||
// Check if socket file exists
|
||||
guard FileManager.default.fileExists(atPath: socketPath) else {
|
||||
DispatchQueue.main.async {
|
||||
self.connectionStatus = .error("Daemon socket not found. Is Spacedrive daemon running?")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Try to establish connection by sending initial messages
|
||||
queue.async {
|
||||
self.subscribeToEvents()
|
||||
self.fetchInitialJobs()
|
||||
}
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
isConnected = false
|
||||
DispatchQueue.main.async {
|
||||
self.connectionStatus = .disconnected
|
||||
}
|
||||
}
|
||||
|
||||
private func subscribeToEvents() {
|
||||
let eventTypes = [
|
||||
"JobStarted",
|
||||
"JobProgress",
|
||||
"JobCompleted",
|
||||
"JobFailed",
|
||||
"JobPaused",
|
||||
"JobResumed"
|
||||
]
|
||||
let subscription = DaemonRequest.subscribe(eventTypes: eventTypes, filter: nil)
|
||||
sendMessage(subscription)
|
||||
}
|
||||
|
||||
private func fetchInitialJobs() {
|
||||
// For now, let's try a simple ping to test the connection
|
||||
let ping = DaemonRequest.ping
|
||||
sendMessage(ping)
|
||||
}
|
||||
|
||||
private func sendMessage<T: Codable>(_ message: T) {
|
||||
do {
|
||||
let jsonData = try JSONEncoder().encode(message)
|
||||
let messageString = String(data: jsonData, encoding: .utf8) ?? ""
|
||||
let messageWithNewline = messageString + "\n"
|
||||
|
||||
print("Sending message: \(messageString)")
|
||||
|
||||
// Use a simple approach - write to socket and read response
|
||||
sendAndReceive(messageWithNewline)
|
||||
} catch {
|
||||
print("Failed to encode message: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func sendAndReceive(_ message: String) {
|
||||
queue.async {
|
||||
// Create socket
|
||||
let socketFD = socket(AF_UNIX, SOCK_STREAM, 0)
|
||||
guard socketFD != -1 else {
|
||||
print("Failed to create socket")
|
||||
return
|
||||
}
|
||||
|
||||
defer { close(socketFD) }
|
||||
|
||||
// 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 {
|
||||
print("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 {
|
||||
print("Failed to connect to socket: \(String(cString: strerror(errno)))")
|
||||
return
|
||||
}
|
||||
|
||||
// Send message
|
||||
let messageData = message.data(using: .utf8) ?? Data()
|
||||
let sendResult = messageData.withUnsafeBytes { bytes in
|
||||
send(socketFD, bytes.bindMemory(to: UInt8.self).baseAddress, messageData.count, 0)
|
||||
}
|
||||
|
||||
guard sendResult != -1 else {
|
||||
print("Failed to send message: \(String(cString: strerror(errno)))")
|
||||
return
|
||||
}
|
||||
|
||||
// Read response
|
||||
var buffer = [UInt8](repeating: 0, count: 4096)
|
||||
let readResult = recv(socketFD, &buffer, buffer.count, 0)
|
||||
|
||||
guard readResult > 0 else {
|
||||
print("Failed to read response: \(String(cString: strerror(errno)))")
|
||||
return
|
||||
}
|
||||
|
||||
let responseData = Data(buffer.prefix(readResult))
|
||||
if let responseString = String(data: responseData, encoding: .utf8) {
|
||||
print("Received response: \(responseString)")
|
||||
self.processReceivedData(responseData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func processReceivedData(_ data: Data) {
|
||||
guard let string = String(data: data, encoding: .utf8) else {
|
||||
return
|
||||
}
|
||||
|
||||
// Split by newlines as messages are line-delimited
|
||||
let lines = string.components(separatedBy: .newlines).filter { !$0.isEmpty }
|
||||
|
||||
for line in lines {
|
||||
guard let lineData = line.data(using: .utf8) else { continue }
|
||||
processMessage(lineData)
|
||||
}
|
||||
}
|
||||
|
||||
private func processMessage(_ data: Data) {
|
||||
do {
|
||||
// Try to decode as DaemonResponse
|
||||
if let response = try? JSONDecoder().decode(DaemonResponse.self, from: data) {
|
||||
handleDaemonResponse(response)
|
||||
return
|
||||
}
|
||||
|
||||
// Log unhandled message for debugging
|
||||
if let jsonString = String(data: data, encoding: .utf8) {
|
||||
print("Unhandled message: \(jsonString)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleDaemonResponse(_ response: DaemonResponse) {
|
||||
// If we're receiving any response, the connection is working
|
||||
DispatchQueue.main.async {
|
||||
if self.connectionStatus != .connected {
|
||||
self.connectionStatus = .connected
|
||||
}
|
||||
}
|
||||
|
||||
switch response {
|
||||
case .pong:
|
||||
print("Received pong from daemon")
|
||||
case .ok(let bytes):
|
||||
print("Received OK response with \(bytes.count) bytes")
|
||||
// For now, we can't easily decode bincode in Swift, so we'll skip job list queries
|
||||
// and rely on events for job updates
|
||||
case .error(let error):
|
||||
print("Daemon error: \(error)")
|
||||
DispatchQueue.main.async {
|
||||
self.connectionStatus = .error(error)
|
||||
}
|
||||
case .subscribed:
|
||||
print("Successfully subscribed to events")
|
||||
case .unsubscribed:
|
||||
print("Unsubscribed from events")
|
||||
case .event(let event):
|
||||
handleEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleEvent(_ event: Event) {
|
||||
DispatchQueue.main.async {
|
||||
switch event {
|
||||
case .jobStarted(let jobId, let jobType):
|
||||
print("Job started: \(jobType) [\(String(jobId.prefix(8)))]")
|
||||
let jobInfo = JobInfo(
|
||||
id: jobId,
|
||||
name: jobType,
|
||||
status: .running,
|
||||
progress: 0.0,
|
||||
startedAt: Date(),
|
||||
completedAt: nil,
|
||||
errorMessage: nil
|
||||
)
|
||||
self.updateOrAddJob(jobInfo)
|
||||
|
||||
case .jobProgress(let jobId, let jobType, let progress, let message):
|
||||
print("Job progress: \(jobType) [\(String(jobId.prefix(8)))] - \(Int(progress * 100))%")
|
||||
let jobInfo = JobInfo(
|
||||
id: jobId,
|
||||
name: jobType,
|
||||
status: .running,
|
||||
progress: Double(progress),
|
||||
startedAt: Date(), // We don't have the original start time
|
||||
completedAt: nil,
|
||||
errorMessage: nil
|
||||
)
|
||||
self.updateOrAddJob(jobInfo)
|
||||
|
||||
case .jobCompleted(let jobId, let jobType):
|
||||
print("Job completed: \(jobType) [\(String(jobId.prefix(8)))]")
|
||||
let jobInfo = JobInfo(
|
||||
id: jobId,
|
||||
name: jobType,
|
||||
status: .completed,
|
||||
progress: 1.0,
|
||||
startedAt: Date(), // We don't have the original start time
|
||||
completedAt: Date(),
|
||||
errorMessage: nil
|
||||
)
|
||||
self.updateOrAddJob(jobInfo)
|
||||
|
||||
case .jobFailed(let jobId, let jobType, let error):
|
||||
print("Job failed: \(jobType) [\(String(jobId.prefix(8)))] - \(error)")
|
||||
let jobInfo = JobInfo(
|
||||
id: jobId,
|
||||
name: jobType,
|
||||
status: .failed,
|
||||
progress: 0.0,
|
||||
startedAt: Date(), // We don't have the original start time
|
||||
completedAt: Date(),
|
||||
errorMessage: error
|
||||
)
|
||||
self.updateOrAddJob(jobInfo)
|
||||
|
||||
case .jobPaused(let jobId):
|
||||
print("Job paused: [\(String(jobId.prefix(8)))]")
|
||||
if let index = self.jobs.firstIndex(where: { $0.id == jobId }) {
|
||||
var updatedJob = self.jobs[index]
|
||||
updatedJob = JobInfo(
|
||||
id: updatedJob.id,
|
||||
name: updatedJob.name,
|
||||
status: .paused,
|
||||
progress: updatedJob.progress,
|
||||
startedAt: updatedJob.startedAt,
|
||||
completedAt: updatedJob.completedAt,
|
||||
errorMessage: updatedJob.errorMessage
|
||||
)
|
||||
self.jobs[index] = updatedJob
|
||||
}
|
||||
|
||||
case .jobResumed(let jobId):
|
||||
print("Job resumed: [\(String(jobId.prefix(8)))]")
|
||||
if let index = self.jobs.firstIndex(where: { $0.id == jobId }) {
|
||||
var updatedJob = self.jobs[index]
|
||||
updatedJob = JobInfo(
|
||||
id: updatedJob.id,
|
||||
name: updatedJob.name,
|
||||
status: .running,
|
||||
progress: updatedJob.progress,
|
||||
startedAt: updatedJob.startedAt,
|
||||
completedAt: nil,
|
||||
errorMessage: updatedJob.errorMessage
|
||||
)
|
||||
self.jobs[index] = updatedJob
|
||||
}
|
||||
|
||||
case .jobCancelled(let jobId, let jobType):
|
||||
print("Job cancelled: \(jobType) [\(String(jobId.prefix(8)))]")
|
||||
// Remove cancelled jobs from the list
|
||||
self.jobs.removeAll { $0.id == jobId }
|
||||
|
||||
case .other:
|
||||
print("Received other event type")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func updateOrAddJob(_ jobInfo: JobInfo) {
|
||||
if let index = jobs.firstIndex(where: { $0.id == jobInfo.id }) {
|
||||
jobs[index] = jobInfo
|
||||
} else {
|
||||
jobs.append(jobInfo)
|
||||
}
|
||||
|
||||
// Sort jobs by start date (newest first)
|
||||
jobs.sort { $0.startedAt > $1.startedAt }
|
||||
}
|
||||
|
||||
func reconnect() {
|
||||
disconnect()
|
||||
DispatchQueue.global().asyncAfter(deadline: .now() + 1.0) {
|
||||
self.connect()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
class JobListViewModel: ObservableObject {
|
||||
@Published var jobs: [JobInfo] = []
|
||||
@Published var connectionStatus: ConnectionStatus = .disconnected
|
||||
|
||||
private var daemonConnector: DaemonConnector?
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init() {
|
||||
setupDaemonConnector()
|
||||
}
|
||||
|
||||
deinit {
|
||||
daemonConnector = nil
|
||||
}
|
||||
|
||||
private func setupDaemonConnector() {
|
||||
daemonConnector = DaemonConnector()
|
||||
|
||||
// Bind daemon connector's published properties to our own
|
||||
daemonConnector?.$jobs
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: \.jobs, on: self)
|
||||
.store(in: &cancellables)
|
||||
|
||||
daemonConnector?.$connectionStatus
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: \.connectionStatus, on: self)
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func reconnect() {
|
||||
daemonConnector?.reconnect()
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
daemonConnector?.disconnect()
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
var activeJobs: [JobInfo] {
|
||||
jobs.filter { job in
|
||||
job.status == .running || job.status == .queued
|
||||
}
|
||||
}
|
||||
|
||||
var completedJobs: [JobInfo] {
|
||||
jobs.filter { job in
|
||||
job.status == .completed
|
||||
}
|
||||
}
|
||||
|
||||
var failedJobs: [JobInfo] {
|
||||
jobs.filter { job in
|
||||
job.status == .failed
|
||||
}
|
||||
}
|
||||
|
||||
var jobCounts: (active: Int, completed: Int, failed: Int) {
|
||||
return (
|
||||
active: activeJobs.count,
|
||||
completed: completedJobs.count,
|
||||
failed: failedJobs.count
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Helper Methods
|
||||
|
||||
func job(withId id: String) -> JobInfo? {
|
||||
return jobs.first { $0.id == id }
|
||||
}
|
||||
|
||||
func removeCompletedJobs() {
|
||||
jobs.removeAll { job in
|
||||
job.status == .completed &&
|
||||
job.completedAt != nil &&
|
||||
Date().timeIntervalSince(job.completedAt!) > 3600 // Remove completed jobs older than 1 hour
|
||||
}
|
||||
}
|
||||
|
||||
func clearAllJobs() {
|
||||
jobs.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - Job Models
|
||||
|
||||
struct JobInfo: Codable, Identifiable {
|
||||
let id: String
|
||||
let name: String
|
||||
let status: JobStatus
|
||||
let progress: Double
|
||||
let startedAt: Date
|
||||
let completedAt: Date?
|
||||
let errorMessage: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case name
|
||||
case status
|
||||
case progress
|
||||
case startedAt = "started_at"
|
||||
case completedAt = "completed_at"
|
||||
case errorMessage = "error_message"
|
||||
}
|
||||
}
|
||||
|
||||
enum JobStatus: String, Codable, CaseIterable {
|
||||
case running = "Running"
|
||||
case completed = "Completed"
|
||||
case failed = "Failed"
|
||||
case paused = "Paused"
|
||||
case queued = "Queued"
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .running:
|
||||
return "Running"
|
||||
case .completed:
|
||||
return "Completed"
|
||||
case .failed:
|
||||
return "Failed"
|
||||
case .paused:
|
||||
return "Paused"
|
||||
case .queued:
|
||||
return "Queued"
|
||||
}
|
||||
}
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .running:
|
||||
return "⚡️"
|
||||
case .completed:
|
||||
return "✅"
|
||||
case .failed:
|
||||
return "❌"
|
||||
case .paused:
|
||||
return "⏸️"
|
||||
case .queued:
|
||||
return "⏳"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - RPC Models
|
||||
|
||||
// Daemon Request enum matching the Rust implementation
|
||||
enum DaemonRequest: Codable {
|
||||
case ping
|
||||
case query(method: String, payload: [UInt8])
|
||||
case subscribe(eventTypes: [String], filter: EventFilter?)
|
||||
case unsubscribe
|
||||
case shutdown
|
||||
|
||||
private struct QueryData: Codable {
|
||||
let method: String
|
||||
let payload: [UInt8]
|
||||
}
|
||||
|
||||
private struct SubscribeData: Codable {
|
||||
let event_types: [String]
|
||||
let filter: EventFilter?
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
|
||||
switch self {
|
||||
case .ping:
|
||||
try container.encode("Ping")
|
||||
case .query(let method, let payload):
|
||||
let queryDict = ["Query": QueryData(method: method, payload: payload)]
|
||||
try container.encode(queryDict)
|
||||
case .subscribe(let eventTypes, let filter):
|
||||
let subscribeDict = ["Subscribe": SubscribeData(event_types: eventTypes, filter: filter)]
|
||||
try container.encode(subscribeDict)
|
||||
case .unsubscribe:
|
||||
try container.encode("Unsubscribe")
|
||||
case .shutdown:
|
||||
try container.encode("Shutdown")
|
||||
}
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
// This is mainly for decoding responses, we don't expect to decode requests
|
||||
throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "DaemonRequest decoding not implemented"))
|
||||
}
|
||||
}
|
||||
|
||||
// Event filter for subscriptions
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
// Daemon Response enum
|
||||
enum DaemonResponse: Codable {
|
||||
case pong
|
||||
case ok([UInt8])
|
||||
case error(String)
|
||||
case subscribed
|
||||
case unsubscribed
|
||||
case event(Event)
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
|
||||
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)"))
|
||||
}
|
||||
} else if let dict = try? container.decode([String: AnyCodable].self) {
|
||||
if let okData = dict["Ok"] {
|
||||
if let bytes = okData.value as? [UInt8] {
|
||||
self = .ok(bytes)
|
||||
} else {
|
||||
throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Invalid Ok payload"))
|
||||
}
|
||||
} else if let errorMsg = dict["Error"] {
|
||||
if let errorString = errorMsg.value as? String {
|
||||
self = .error(errorString)
|
||||
} else {
|
||||
throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Invalid Error payload"))
|
||||
}
|
||||
} else if let eventData = dict["Event"] {
|
||||
// Try to decode the event
|
||||
let eventJson = try JSONSerialization.data(withJSONObject: eventData.value)
|
||||
let event = try JSONDecoder().decode(Event.self, from: eventJson)
|
||||
self = .event(event)
|
||||
} else {
|
||||
throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Unknown dict response"))
|
||||
}
|
||||
} else {
|
||||
throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Invalid response format"))
|
||||
}
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
// We don't need to encode responses, only decode them
|
||||
throw EncodingError.invalidValue(self, EncodingError.Context(codingPath: encoder.codingPath, debugDescription: "DaemonResponse encoding not implemented"))
|
||||
}
|
||||
}
|
||||
|
||||
// Helper for decoding arbitrary JSON values
|
||||
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 {
|
||||
throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Cannot decode value"))
|
||||
}
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
|
||||
if let intValue = value as? Int {
|
||||
try container.encode(intValue)
|
||||
} else if let doubleValue = value as? Double {
|
||||
try container.encode(doubleValue)
|
||||
} else if let stringValue = value as? String {
|
||||
try container.encode(stringValue)
|
||||
} else if let boolValue = value as? Bool {
|
||||
try container.encode(boolValue)
|
||||
} else {
|
||||
throw EncodingError.invalidValue(value, EncodingError.Context(codingPath: encoder.codingPath, debugDescription: "Cannot encode value"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Job List Query Input (for bincode encoding)
|
||||
struct JobListQueryInput: Codable {
|
||||
let status: String?
|
||||
|
||||
init(status: String? = nil) {
|
||||
self.status = status
|
||||
}
|
||||
}
|
||||
|
||||
// Job List Output (matches Rust JobListOutput)
|
||||
struct JobListOutput: Codable {
|
||||
let jobs: [JobListItem]
|
||||
}
|
||||
|
||||
struct JobListItem: Codable {
|
||||
let id: String
|
||||
let name: String
|
||||
let status: JobStatus
|
||||
let progress: Float
|
||||
}
|
||||
|
||||
// Event enum matching the Rust Event enum
|
||||
enum Event: Codable {
|
||||
case jobStarted(jobId: String, jobType: String)
|
||||
case jobProgress(jobId: String, jobType: String, progress: Float, message: String?)
|
||||
case jobCompleted(jobId: String, jobType: String)
|
||||
case jobFailed(jobId: String, jobType: String, error: String)
|
||||
case jobCancelled(jobId: String, jobType: String)
|
||||
case jobPaused(jobId: String)
|
||||
case jobResumed(jobId: String)
|
||||
case other
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case jobStarted = "JobStarted"
|
||||
case jobProgress = "JobProgress"
|
||||
case jobCompleted = "JobCompleted"
|
||||
case jobFailed = "JobFailed"
|
||||
case jobCancelled = "JobCancelled"
|
||||
case jobPaused = "JobPaused"
|
||||
case jobResumed = "JobResumed"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
if let jobStartedData = try? container.decode([String: String].self, forKey: .jobStarted) {
|
||||
self = .jobStarted(
|
||||
jobId: jobStartedData["job_id"] ?? "",
|
||||
jobType: jobStartedData["job_type"] ?? ""
|
||||
)
|
||||
} else if let jobProgressData = try? container.decode([String: AnyCodable].self, forKey: .jobProgress) {
|
||||
self = .jobProgress(
|
||||
jobId: (jobProgressData["job_id"]?.value as? String) ?? "",
|
||||
jobType: (jobProgressData["job_type"]?.value as? String) ?? "",
|
||||
progress: Float((jobProgressData["progress"]?.value as? Double) ?? 0.0),
|
||||
message: jobProgressData["message"]?.value as? String
|
||||
)
|
||||
} else if let jobCompletedData = try? container.decode([String: String].self, forKey: .jobCompleted) {
|
||||
self = .jobCompleted(
|
||||
jobId: jobCompletedData["job_id"] ?? "",
|
||||
jobType: jobCompletedData["job_type"] ?? ""
|
||||
)
|
||||
} else if let jobFailedData = try? container.decode([String: String].self, forKey: .jobFailed) {
|
||||
self = .jobFailed(
|
||||
jobId: jobFailedData["job_id"] ?? "",
|
||||
jobType: jobFailedData["job_type"] ?? "",
|
||||
error: jobFailedData["error"] ?? ""
|
||||
)
|
||||
} else if let jobCancelledData = try? container.decode([String: String].self, forKey: .jobCancelled) {
|
||||
self = .jobCancelled(
|
||||
jobId: jobCancelledData["job_id"] ?? "",
|
||||
jobType: jobCancelledData["job_type"] ?? ""
|
||||
)
|
||||
} else if let jobPausedData = try? container.decode([String: String].self, forKey: .jobPaused) {
|
||||
self = .jobPaused(jobId: jobPausedData["job_id"] ?? "")
|
||||
} else if let jobResumedData = try? container.decode([String: String].self, forKey: .jobResumed) {
|
||||
self = .jobResumed(jobId: jobResumedData["job_id"] ?? "")
|
||||
} else {
|
||||
self = .other
|
||||
}
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
// We don't need to encode events, only decode them
|
||||
throw EncodingError.invalidValue(self, EncodingError.Context(codingPath: encoder.codingPath, debugDescription: "Event encoding not implemented"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Connection Status
|
||||
|
||||
enum ConnectionStatus: Equatable {
|
||||
case disconnected
|
||||
case connecting
|
||||
case connected
|
||||
case error(String)
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .disconnected:
|
||||
return "Disconnected"
|
||||
case .connecting:
|
||||
return "Connecting..."
|
||||
case .connected:
|
||||
return "Connected"
|
||||
case .error(let message):
|
||||
return "Error: \(message)"
|
||||
}
|
||||
}
|
||||
|
||||
var color: String {
|
||||
switch self {
|
||||
case .disconnected, .error:
|
||||
return "red"
|
||||
case .connecting:
|
||||
return "yellow"
|
||||
case .connected:
|
||||
return "green"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import SwiftUI
|
||||
|
||||
struct JobMonitorView: View {
|
||||
@ObservedObject var viewModel: JobListViewModel
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Header with connection status
|
||||
headerView
|
||||
|
||||
Divider()
|
||||
.background(Color.gray.opacity(0.3))
|
||||
|
||||
// Job list
|
||||
if viewModel.jobs.isEmpty {
|
||||
emptyStateView
|
||||
} else {
|
||||
jobListView
|
||||
}
|
||||
}
|
||||
.background(Color.clear)
|
||||
}
|
||||
|
||||
private var headerView: some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Spacedrive Jobs")
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
|
||||
HStack(spacing: 6) {
|
||||
Circle()
|
||||
.fill(connectionStatusColor)
|
||||
.frame(width: 8, height: 8)
|
||||
|
||||
Text(viewModel.connectionStatus.displayName)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Refresh button
|
||||
Button(action: {
|
||||
viewModel.reconnect()
|
||||
}) {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.help("Reconnect to daemon")
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
|
||||
private var connectionStatusColor: Color {
|
||||
switch viewModel.connectionStatus {
|
||||
case .connected:
|
||||
return .green
|
||||
case .connecting:
|
||||
return .yellow
|
||||
case .disconnected, .error:
|
||||
return .red
|
||||
}
|
||||
}
|
||||
|
||||
private var emptyStateView: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "checkmark.circle")
|
||||
.font(.system(size: 48))
|
||||
.foregroundColor(.green.opacity(0.6))
|
||||
|
||||
VStack(spacing: 8) {
|
||||
Text("No Active Jobs")
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Text("All jobs are completed or no jobs are currently running.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding(32)
|
||||
}
|
||||
|
||||
private var jobListView: some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 12) {
|
||||
ForEach(viewModel.jobs) { job in
|
||||
JobRowView(job: job)
|
||||
.transition(.asymmetric(
|
||||
insertion: .scale.combined(with: .opacity),
|
||||
removal: .opacity
|
||||
))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.3), value: viewModel.jobs.count)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
let viewModel = JobListViewModel()
|
||||
|
||||
// Add some sample jobs for preview
|
||||
viewModel.jobs = [
|
||||
JobInfo(
|
||||
id: "1",
|
||||
name: "file_indexer",
|
||||
status: .running,
|
||||
progress: 0.65,
|
||||
startedAt: Date().addingTimeInterval(-300),
|
||||
completedAt: nil,
|
||||
errorMessage: nil
|
||||
),
|
||||
JobInfo(
|
||||
id: "2",
|
||||
name: "thumbnail_generator",
|
||||
status: .completed,
|
||||
progress: 1.0,
|
||||
startedAt: Date().addingTimeInterval(-600),
|
||||
completedAt: Date().addingTimeInterval(-60),
|
||||
errorMessage: nil
|
||||
),
|
||||
JobInfo(
|
||||
id: "3",
|
||||
name: "file_copy",
|
||||
status: .failed,
|
||||
progress: 0.3,
|
||||
startedAt: Date().addingTimeInterval(-120),
|
||||
completedAt: Date().addingTimeInterval(-30),
|
||||
errorMessage: "Permission denied"
|
||||
)
|
||||
]
|
||||
|
||||
return JobMonitorView(viewModel: viewModel)
|
||||
.frame(width: 400, height: 600)
|
||||
.background(Color.black.opacity(0.1))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import SwiftUI
|
||||
|
||||
struct JobRowView: View {
|
||||
let job: JobInfo
|
||||
|
||||
private var progressPercentage: String {
|
||||
return String(format: "%.1f%%", job.progress * 100)
|
||||
}
|
||||
|
||||
private var timeAgo: String {
|
||||
let formatter = RelativeDateTimeFormatter()
|
||||
formatter.unitsStyle = .abbreviated
|
||||
return formatter.localizedString(for: job.startedAt, relativeTo: Date())
|
||||
}
|
||||
|
||||
private var duration: String? {
|
||||
guard let completedAt = job.completedAt else { return nil }
|
||||
let duration = completedAt.timeIntervalSince(job.startedAt)
|
||||
|
||||
if duration < 60 {
|
||||
return String(format: "%.1fs", duration)
|
||||
} else if duration < 3600 {
|
||||
return String(format: "%.1fm", duration / 60)
|
||||
} else {
|
||||
return String(format: "%.1fh", duration / 3600)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
// Header with job name and status
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(job.name)
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Text("ID: \(String(job.id.prefix(8)))...")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack(spacing: 4) {
|
||||
Text(job.status.icon)
|
||||
.font(.title2)
|
||||
|
||||
Text(job.status.displayName)
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(statusColor)
|
||||
}
|
||||
}
|
||||
|
||||
// Progress bar (only show for running jobs)
|
||||
if job.status == .running {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text("Progress")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(progressPercentage)
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
}
|
||||
|
||||
ProgressView(value: job.progress, total: 1.0)
|
||||
.progressViewStyle(LinearProgressViewStyle())
|
||||
.scaleEffect(x: 1, y: 0.8, anchor: .center)
|
||||
}
|
||||
}
|
||||
|
||||
// Error message (if any)
|
||||
if let errorMessage = job.errorMessage, !errorMessage.isEmpty {
|
||||
Text(errorMessage)
|
||||
.font(.caption)
|
||||
.foregroundColor(.red)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color.red.opacity(0.1))
|
||||
.cornerRadius(4)
|
||||
}
|
||||
|
||||
// Timestamps
|
||||
HStack {
|
||||
Text("Started \(timeAgo)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Spacer()
|
||||
|
||||
if let duration = duration {
|
||||
Text("Duration: \(duration)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
.background(backgroundColorForStatus)
|
||||
.cornerRadius(8)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(borderColorForStatus, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
private var statusColor: Color {
|
||||
switch job.status {
|
||||
case .running:
|
||||
return .blue
|
||||
case .completed:
|
||||
return .green
|
||||
case .failed:
|
||||
return .red
|
||||
case .paused:
|
||||
return .orange
|
||||
case .queued:
|
||||
return .gray
|
||||
}
|
||||
}
|
||||
|
||||
private var backgroundColorForStatus: Color {
|
||||
switch job.status {
|
||||
case .running:
|
||||
return Color.blue.opacity(0.05)
|
||||
case .completed:
|
||||
return Color.green.opacity(0.05)
|
||||
case .failed:
|
||||
return Color.red.opacity(0.05)
|
||||
case .paused:
|
||||
return Color.orange.opacity(0.05)
|
||||
case .queued:
|
||||
return Color.gray.opacity(0.05)
|
||||
}
|
||||
}
|
||||
|
||||
private var borderColorForStatus: Color {
|
||||
switch job.status {
|
||||
case .running:
|
||||
return Color.blue.opacity(0.2)
|
||||
case .completed:
|
||||
return Color.green.opacity(0.2)
|
||||
case .failed:
|
||||
return Color.red.opacity(0.2)
|
||||
case .paused:
|
||||
return Color.orange.opacity(0.2)
|
||||
case .queued:
|
||||
return Color.gray.opacity(0.2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
VStack(spacing: 12) {
|
||||
JobRowView(job: JobInfo(
|
||||
id: "12345678-1234-1234-1234-123456789012",
|
||||
name: "file_indexer",
|
||||
status: .running,
|
||||
progress: 0.65,
|
||||
startedAt: Date().addingTimeInterval(-300),
|
||||
completedAt: nil,
|
||||
errorMessage: nil
|
||||
))
|
||||
|
||||
JobRowView(job: JobInfo(
|
||||
id: "87654321-4321-4321-4321-210987654321",
|
||||
name: "file_copy",
|
||||
status: .completed,
|
||||
progress: 1.0,
|
||||
startedAt: Date().addingTimeInterval(-600),
|
||||
completedAt: Date().addingTimeInterval(-60),
|
||||
errorMessage: nil
|
||||
))
|
||||
|
||||
JobRowView(job: JobInfo(
|
||||
id: "11111111-2222-3333-4444-555555555555",
|
||||
name: "thumbnail_generator",
|
||||
status: .failed,
|
||||
progress: 0.3,
|
||||
startedAt: Date().addingTimeInterval(-120),
|
||||
completedAt: Date().addingTimeInterval(-30),
|
||||
errorMessage: "Failed to process image: unsupported format"
|
||||
))
|
||||
}
|
||||
.padding()
|
||||
.background(Color.black.opacity(0.1))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
class TranslucentWindow: NSWindow {
|
||||
override init(contentRect: NSRect, styleMask style: NSWindow.StyleMask, backing backingStoreType: NSWindow.BackingStoreType, defer flag: Bool) {
|
||||
super.init(contentRect: contentRect, styleMask: style, backing: backingStoreType, defer: flag)
|
||||
|
||||
setupWindow()
|
||||
}
|
||||
|
||||
private func setupWindow() {
|
||||
// Make the window translucent
|
||||
self.isOpaque = false
|
||||
self.backgroundColor = NSColor.clear
|
||||
|
||||
// Add visual effect view for blur
|
||||
let visualEffectView = NSVisualEffectView()
|
||||
visualEffectView.material = .hudWindow
|
||||
visualEffectView.blendingMode = .behindWindow
|
||||
visualEffectView.state = .active
|
||||
|
||||
// Set the visual effect view as the content view's background
|
||||
if let contentView = self.contentView {
|
||||
visualEffectView.frame = contentView.bounds
|
||||
visualEffectView.autoresizingMask = [.width, .height]
|
||||
contentView.addSubview(visualEffectView, positioned: .below, relativeTo: nil)
|
||||
}
|
||||
|
||||
// Window behavior
|
||||
self.level = .floating
|
||||
self.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
||||
|
||||
// Make window movable by background
|
||||
self.isMovableByWindowBackground = true
|
||||
|
||||
// Set minimum size
|
||||
self.minSize = NSSize(width: 300, height: 400)
|
||||
}
|
||||
}
|
||||
|
||||
struct VisualEffectBackground: NSViewRepresentable {
|
||||
func makeNSView(context: Context) -> NSVisualEffectView {
|
||||
let view = NSVisualEffectView()
|
||||
view.material = .hudWindow
|
||||
view.blendingMode = .behindWindow
|
||||
view.state = .active
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: NSVisualEffectView, context: Context) {
|
||||
// No updates needed
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
struct SpacedriveCompanionApp: App {
|
||||
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||
|
||||
var body: some Scene {
|
||||
Settings {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
var window: NSWindow?
|
||||
var jobListViewModel: JobListViewModel?
|
||||
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
setupWindow()
|
||||
}
|
||||
|
||||
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
||||
return true
|
||||
}
|
||||
|
||||
private func setupWindow() {
|
||||
let contentView = ContentView()
|
||||
|
||||
// Create the translucent window
|
||||
window = TranslucentWindow(
|
||||
contentRect: NSRect(x: 100, y: 100, width: 400, height: 600),
|
||||
styleMask: [.titled, .closable, .resizable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
|
||||
window?.title = "Spacedrive Jobs"
|
||||
window?.contentView = NSHostingView(rootView: contentView)
|
||||
window?.makeKeyAndOrderFront(nil)
|
||||
window?.level = .floating
|
||||
|
||||
// Keep window on top
|
||||
window?.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
||||
}
|
||||
}
|
||||
|
||||
// Main entry point
|
||||
SpacedriveCompanionApp.main()
|
||||
31
apps/spacedrive-companion/build.sh
Executable file
31
apps/spacedrive-companion/build.sh
Executable file
@@ -0,0 +1,31 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Spacedrive Companion App Build Script
|
||||
|
||||
set -e
|
||||
|
||||
echo "Building Spacedrive SwiftUI Companion App..."
|
||||
|
||||
# Check if we're in the right directory
|
||||
if [ ! -f "Package.swift" ]; then
|
||||
echo "Error: Package.swift not found. Please run this script from the spacedrive-companion directory."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Build the app
|
||||
echo "Building with Swift Package Manager..."
|
||||
swift build -c release
|
||||
|
||||
# Check if build was successful
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✅ Build successful!"
|
||||
echo ""
|
||||
echo "To run the app:"
|
||||
echo " swift run SpacedriveCompanion"
|
||||
echo ""
|
||||
echo "Or build and run in one command:"
|
||||
echo " swift run"
|
||||
else
|
||||
echo "❌ Build failed!"
|
||||
exit 1
|
||||
fi
|
||||
26
apps/spacedrive-companion/run.sh
Executable file
26
apps/spacedrive-companion/run.sh
Executable file
@@ -0,0 +1,26 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Spacedrive Companion App Launch Script
|
||||
|
||||
set -e
|
||||
|
||||
echo "Starting Spacedrive SwiftUI Companion App..."
|
||||
|
||||
# Check if we're in the right directory
|
||||
if [ ! -f "Package.swift" ]; then
|
||||
echo "Error: Package.swift not found. Please run this script from the spacedrive-companion directory."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if Spacedrive daemon socket exists
|
||||
SOCKET_PATH="$HOME/Library/Application Support/spacedrive/daemon/daemon.sock"
|
||||
if [ ! -S "$SOCKET_PATH" ]; then
|
||||
echo "⚠️ Warning: Spacedrive daemon socket not found at $SOCKET_PATH"
|
||||
echo " Make sure the Spacedrive daemon is running before launching the companion app."
|
||||
echo " You can start the daemon with: cargo run --bin sd-cli -- daemon"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Run the app
|
||||
echo "🚀 Launching companion app..."
|
||||
swift run SpacedriveCompanion
|
||||
@@ -102,7 +102,7 @@ case $COMMAND in
|
||||
combine_files "$root_path" "*.rs" "combined_rust_files.txt" "Combined Rust Files" "rust" "true"
|
||||
;;
|
||||
cli)
|
||||
combine_files "../../apps/cli" "*.rs" "combined_cli_rust_files.txt" "Combined CLI Rust Files" "rust" "true"
|
||||
combine_files "./apps/cli" "*.rs" "combined_cli_rust_files.txt" "Combined CLI Rust Files" "rust" "true"
|
||||
;;
|
||||
tasks)
|
||||
combine_files "../.tasks" "*.md" "combined_tasks.txt" "Combined Task Files" "markdown" "false"
|
||||
|
||||
446
core/src/testing/integration_utils.rs
Normal file
446
core/src/testing/integration_utils.rs
Normal file
@@ -0,0 +1,446 @@
|
||||
//! Integration test utilities for setting up isolated test environments
|
||||
//!
|
||||
//! This module provides utilities for creating isolated test environments with:
|
||||
//! - Custom data directories per test
|
||||
//! - Structured logging to library/logs
|
||||
//! - Configurable AppConfig for different test scenarios
|
||||
//! - Automatic cleanup and resource management
|
||||
//!
|
||||
//! ## Example Usage
|
||||
//!
|
||||
//! ### Basic Setup
|
||||
//! ```rust,no_run
|
||||
//! use sd_core::testing::integration_utils::IntegrationTestSetup;
|
||||
//!
|
||||
//! #[tokio::test]
|
||||
//! async fn my_integration_test() {
|
||||
//! // Create test environment with default config
|
||||
//! let setup = IntegrationTestSetup::new("my_test").await.unwrap();
|
||||
//!
|
||||
//! // Create core using the test setup's configuration
|
||||
//! let core = setup.create_core().await.unwrap();
|
||||
//!
|
||||
//! // Your test logic here...
|
||||
//! // The core will use the test setup's custom configuration settings
|
||||
//!
|
||||
//! // Logs are automatically saved to test_data/my_test/library/logs/my_test.log
|
||||
//! // Job logs go to test_data/my_test/library/job_logs/
|
||||
//!
|
||||
//! // Cleanup is automatic when setup is dropped
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! ### Custom Configuration
|
||||
//! ```rust,no_run
|
||||
//! use sd_core::testing::integration_utils::IntegrationTestSetup;
|
||||
//!
|
||||
//! #[tokio::test]
|
||||
//! async fn test_with_custom_config() {
|
||||
//! let setup = IntegrationTestSetup::with_config("custom_test", |builder| {
|
||||
//! builder
|
||||
//! .log_level("debug")
|
||||
//! .networking_enabled(true)
|
||||
//! .volume_monitoring_enabled(true)
|
||||
//! }).await.unwrap();
|
||||
//!
|
||||
//! let core = setup.create_core().await.unwrap();
|
||||
//! // Test with networking and volume monitoring enabled...
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! ### Custom Tracing
|
||||
//! ```rust,no_run
|
||||
//! use sd_core::testing::integration_utils::IntegrationTestSetup;
|
||||
//!
|
||||
//! #[tokio::test]
|
||||
//! async fn test_with_debug_logging() {
|
||||
//! let setup = IntegrationTestSetup::with_tracing(
|
||||
//! "debug_test",
|
||||
//! "debug,sd_core=trace,my_module=info"
|
||||
//! ).await.unwrap();
|
||||
//!
|
||||
//! // Test with detailed debug logging...
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
use crate::config::{AppConfig, ServiceConfig, JobLoggingConfig, Preferences};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Once;
|
||||
use tracing::{info, warn};
|
||||
use tracing_appender::rolling::{RollingFileAppender, Rotation};
|
||||
use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
|
||||
|
||||
/// Test environment configuration
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TestEnvironment {
|
||||
/// Root directory for all test data (e.g., "core/test_data")
|
||||
pub test_root: PathBuf,
|
||||
/// Specific test data directory (e.g., "core/test_data/test_job_resumption_001")
|
||||
pub test_data_dir: PathBuf,
|
||||
/// Library data directory within the test (e.g., "core/test_data/test_job_resumption_001/library")
|
||||
pub library_data_dir: PathBuf,
|
||||
/// Logs directory (e.g., "core/test_data/test_job_resumption_001/library/logs")
|
||||
pub logs_dir: PathBuf,
|
||||
/// Test name for identification
|
||||
pub test_name: String,
|
||||
}
|
||||
|
||||
impl TestEnvironment {
|
||||
/// Create a new test environment with the given name
|
||||
pub fn new(test_name: impl Into<String>) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
let test_name = test_name.into();
|
||||
let test_root = PathBuf::from("test_data");
|
||||
let test_data_dir = test_root.join(&test_name);
|
||||
let library_data_dir = test_data_dir.join("library");
|
||||
let logs_dir = library_data_dir.join("logs");
|
||||
|
||||
// Create all necessary directories
|
||||
std::fs::create_dir_all(&logs_dir)?;
|
||||
|
||||
Ok(Self {
|
||||
test_root,
|
||||
test_data_dir,
|
||||
library_data_dir,
|
||||
logs_dir,
|
||||
test_name,
|
||||
})
|
||||
}
|
||||
|
||||
/// Clean the test environment (remove all data)
|
||||
pub fn clean(&self) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if self.test_data_dir.exists() {
|
||||
std::fs::remove_dir_all(&self.test_data_dir)?;
|
||||
info!("Cleaned test environment: {}", self.test_data_dir.display());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the path for a specific log file within this test environment
|
||||
pub fn log_file_path(&self, filename: &str) -> PathBuf {
|
||||
self.logs_dir.join(filename)
|
||||
}
|
||||
|
||||
/// Get the job log path for a specific job ID
|
||||
pub fn job_log_path(&self, job_id: uuid::Uuid) -> PathBuf {
|
||||
self.library_data_dir.join("job_logs").join(format!("{}.log", job_id))
|
||||
}
|
||||
}
|
||||
|
||||
/// Test configuration builder for creating custom AppConfigs
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TestConfigBuilder {
|
||||
data_dir: PathBuf,
|
||||
log_level: String,
|
||||
networking_enabled: bool,
|
||||
volume_monitoring_enabled: bool,
|
||||
location_watcher_enabled: bool,
|
||||
job_logging_enabled: bool,
|
||||
telemetry_enabled: bool,
|
||||
}
|
||||
|
||||
impl TestConfigBuilder {
|
||||
/// Create a new test config builder with sensible defaults for testing
|
||||
pub fn new(data_dir: PathBuf) -> Self {
|
||||
Self {
|
||||
data_dir,
|
||||
log_level: "warn".to_string(), // Reduce log noise by default
|
||||
networking_enabled: false, // Disable for faster tests
|
||||
volume_monitoring_enabled: false, // Disable for faster tests
|
||||
location_watcher_enabled: true, // Usually needed for indexing tests
|
||||
job_logging_enabled: true, // Usually needed for job tests
|
||||
telemetry_enabled: false, // Disable for tests
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the log level (default: "warn")
|
||||
pub fn log_level(mut self, level: impl Into<String>) -> Self {
|
||||
self.log_level = level.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Enable/disable networking (default: false)
|
||||
pub fn networking_enabled(mut self, enabled: bool) -> Self {
|
||||
self.networking_enabled = enabled;
|
||||
self
|
||||
}
|
||||
|
||||
/// Enable/disable volume monitoring (default: false)
|
||||
pub fn volume_monitoring_enabled(mut self, enabled: bool) -> Self {
|
||||
self.volume_monitoring_enabled = enabled;
|
||||
self
|
||||
}
|
||||
|
||||
/// Enable/disable location watcher (default: true)
|
||||
pub fn location_watcher_enabled(mut self, enabled: bool) -> Self {
|
||||
self.location_watcher_enabled = enabled;
|
||||
self
|
||||
}
|
||||
|
||||
/// Enable/disable job logging (default: true)
|
||||
pub fn job_logging_enabled(mut self, enabled: bool) -> Self {
|
||||
self.job_logging_enabled = enabled;
|
||||
self
|
||||
}
|
||||
|
||||
/// Enable/disable telemetry (default: false)
|
||||
pub fn telemetry_enabled(mut self, enabled: bool) -> Self {
|
||||
self.telemetry_enabled = enabled;
|
||||
self
|
||||
}
|
||||
|
||||
/// Build the AppConfig
|
||||
pub fn build(self) -> AppConfig {
|
||||
AppConfig {
|
||||
version: 3,
|
||||
data_dir: self.data_dir,
|
||||
log_level: self.log_level,
|
||||
telemetry_enabled: self.telemetry_enabled,
|
||||
preferences: Preferences::default(),
|
||||
job_logging: JobLoggingConfig {
|
||||
enabled: self.job_logging_enabled,
|
||||
log_directory: "job_logs".to_string(),
|
||||
max_file_size: 10 * 1024 * 1024, // 10MB
|
||||
include_debug: false,
|
||||
},
|
||||
services: ServiceConfig {
|
||||
networking_enabled: self.networking_enabled,
|
||||
volume_monitoring_enabled: self.volume_monitoring_enabled,
|
||||
location_watcher_enabled: self.location_watcher_enabled,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Build and save the AppConfig to the data directory
|
||||
pub async fn build_and_save(self) -> Result<AppConfig, Box<dyn std::error::Error>> {
|
||||
let config = self.build();
|
||||
|
||||
// Ensure the data directory exists
|
||||
std::fs::create_dir_all(&config.data_dir)?;
|
||||
|
||||
// Save the config so Core::new_with_config() will load our custom settings
|
||||
config.save()?;
|
||||
info!("Created test configuration at: {} with custom settings", config.data_dir.display());
|
||||
info!(" - Log level: {}", config.log_level);
|
||||
info!(" - Networking enabled: {}", config.services.networking_enabled);
|
||||
info!(" - Volume monitoring enabled: {}", config.services.volume_monitoring_enabled);
|
||||
info!(" - Location watcher enabled: {}", config.services.location_watcher_enabled);
|
||||
info!(" - Job logging enabled: {}", config.job_logging.enabled);
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize structured logging for integration tests
|
||||
pub fn initialize_test_tracing(
|
||||
test_env: &TestEnvironment,
|
||||
rust_log_override: Option<&str>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
static INIT: Once = Once::new();
|
||||
let mut result: Result<(), Box<dyn std::error::Error>> = Ok(());
|
||||
|
||||
INIT.call_once(|| {
|
||||
// Set up environment filter with detailed logging for tests
|
||||
let env_filter = rust_log_override
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| std::env::var("RUST_LOG").ok())
|
||||
.unwrap_or_else(|| format!("warn,sd_core=info,{}=info", test_env.test_name));
|
||||
|
||||
// Create file appender that rotates daily in the test's log directory
|
||||
let file_appender = RollingFileAppender::new(
|
||||
Rotation::DAILY,
|
||||
&test_env.logs_dir,
|
||||
format!("{}.log", test_env.test_name)
|
||||
);
|
||||
|
||||
// Set up layered subscriber with stdout and file output
|
||||
if let Err(e) = tracing_subscriber::registry()
|
||||
.with(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(env_filter)))
|
||||
.with(
|
||||
fmt::layer()
|
||||
.with_target(true)
|
||||
.with_thread_ids(true)
|
||||
.with_line_number(true)
|
||||
.with_writer(std::io::stdout),
|
||||
)
|
||||
.with(
|
||||
fmt::layer()
|
||||
.with_target(true)
|
||||
.with_thread_ids(true)
|
||||
.with_line_number(true)
|
||||
.with_ansi(false) // No ANSI colors in log files
|
||||
.with_writer(file_appender),
|
||||
)
|
||||
.try_init()
|
||||
{
|
||||
result = Err(format!("Failed to initialize tracing: {}", e).into());
|
||||
}
|
||||
});
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Complete test setup utility that combines environment, config, and tracing
|
||||
pub struct IntegrationTestSetup {
|
||||
pub environment: TestEnvironment,
|
||||
pub config: AppConfig,
|
||||
}
|
||||
|
||||
impl IntegrationTestSetup {
|
||||
/// Create a new integration test setup with default configuration
|
||||
pub async fn new(test_name: impl Into<String>) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
let environment = TestEnvironment::new(test_name)?;
|
||||
|
||||
// Clean any existing data
|
||||
environment.clean()?;
|
||||
|
||||
// Recreate directories
|
||||
std::fs::create_dir_all(&environment.logs_dir)?;
|
||||
|
||||
// Initialize tracing
|
||||
initialize_test_tracing(&environment, None)?;
|
||||
|
||||
// Create default config
|
||||
let config = TestConfigBuilder::new(environment.library_data_dir.clone())
|
||||
.build_and_save()
|
||||
.await?;
|
||||
|
||||
Ok(Self { environment, config })
|
||||
}
|
||||
|
||||
/// Create a new integration test setup with custom configuration
|
||||
pub async fn with_config<F>(
|
||||
test_name: impl Into<String>,
|
||||
config_builder: F,
|
||||
) -> Result<Self, Box<dyn std::error::Error>>
|
||||
where
|
||||
F: FnOnce(TestConfigBuilder) -> TestConfigBuilder,
|
||||
{
|
||||
let environment = TestEnvironment::new(test_name)?;
|
||||
|
||||
// Clean any existing data
|
||||
environment.clean()?;
|
||||
|
||||
// Recreate directories
|
||||
std::fs::create_dir_all(&environment.logs_dir)?;
|
||||
|
||||
// Initialize tracing
|
||||
initialize_test_tracing(&environment, None)?;
|
||||
|
||||
// Create custom config
|
||||
let builder = TestConfigBuilder::new(environment.library_data_dir.clone());
|
||||
let config = config_builder(builder).build_and_save().await?;
|
||||
|
||||
Ok(Self { environment, config })
|
||||
}
|
||||
|
||||
/// Create a new integration test setup with custom tracing
|
||||
pub async fn with_tracing(
|
||||
test_name: impl Into<String>,
|
||||
rust_log_override: &str,
|
||||
) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
let environment = TestEnvironment::new(test_name)?;
|
||||
|
||||
// Clean any existing data
|
||||
environment.clean()?;
|
||||
|
||||
// Recreate directories
|
||||
std::fs::create_dir_all(&environment.logs_dir)?;
|
||||
|
||||
// Initialize custom tracing
|
||||
initialize_test_tracing(&environment, Some(rust_log_override))?;
|
||||
|
||||
// Create default config
|
||||
let config = TestConfigBuilder::new(environment.library_data_dir.clone())
|
||||
.build_and_save()
|
||||
.await?;
|
||||
|
||||
Ok(Self { environment, config })
|
||||
}
|
||||
|
||||
/// Get the data directory for core initialization
|
||||
pub fn data_dir(&self) -> &PathBuf {
|
||||
&self.config.data_dir
|
||||
}
|
||||
|
||||
/// Get a reference to the test environment
|
||||
pub fn env(&self) -> &TestEnvironment {
|
||||
&self.environment
|
||||
}
|
||||
|
||||
/// Create a Core instance using the test setup's configuration
|
||||
///
|
||||
/// This method ensures that the custom AppConfig settings from the test setup
|
||||
/// are properly applied when initializing the Core.
|
||||
pub async fn create_core(&self) -> Result<crate::Core, Box<dyn std::error::Error>> {
|
||||
info!("Creating Core with test configuration from: {}", self.data_dir().display());
|
||||
|
||||
// Core::new_with_config() will load our saved AppConfig from disk
|
||||
let core = crate::Core::new_with_config(self.data_dir().clone()).await?;
|
||||
|
||||
// Verify our config was loaded correctly
|
||||
{
|
||||
let config_arc = core.config();
|
||||
let loaded_config = config_arc.read().await;
|
||||
info!("Core initialized with config:");
|
||||
info!(" - Log level: {}", loaded_config.log_level);
|
||||
info!(" - Networking enabled: {}", loaded_config.services.networking_enabled);
|
||||
info!(" - Volume monitoring enabled: {}", loaded_config.services.volume_monitoring_enabled);
|
||||
info!(" - Location watcher enabled: {}", loaded_config.services.location_watcher_enabled);
|
||||
info!(" - Job logging enabled: {}", loaded_config.job_logging.enabled);
|
||||
}
|
||||
|
||||
Ok(core)
|
||||
}
|
||||
|
||||
/// Clean up the test environment
|
||||
pub fn cleanup(self) -> Result<(), Box<dyn std::error::Error>> {
|
||||
self.environment.clean()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_environment_creation() {
|
||||
let env = TestEnvironment::new("test_example").unwrap();
|
||||
|
||||
assert!(env.test_data_dir.ends_with("test_data/test_example"));
|
||||
assert!(env.library_data_dir.ends_with("test_data/test_example/library"));
|
||||
assert!(env.logs_dir.ends_with("test_data/test_example/library/logs"));
|
||||
assert!(env.logs_dir.exists());
|
||||
|
||||
// Cleanup
|
||||
env.clean().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_config_builder() {
|
||||
let temp_dir = std::env::temp_dir().join("test_config_builder");
|
||||
std::fs::create_dir_all(&temp_dir).unwrap();
|
||||
|
||||
let config = TestConfigBuilder::new(temp_dir.clone())
|
||||
.log_level("debug")
|
||||
.networking_enabled(true)
|
||||
.build();
|
||||
|
||||
assert_eq!(config.log_level, "debug");
|
||||
assert_eq!(config.services.networking_enabled, true);
|
||||
assert_eq!(config.services.volume_monitoring_enabled, false); // default
|
||||
|
||||
// Cleanup
|
||||
std::fs::remove_dir_all(&temp_dir).ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_integration_setup() {
|
||||
let setup = IntegrationTestSetup::new("test_integration_setup").await.unwrap();
|
||||
|
||||
assert!(setup.data_dir().exists());
|
||||
assert!(setup.env().logs_dir.exists());
|
||||
|
||||
// Cleanup
|
||||
setup.cleanup().unwrap();
|
||||
}
|
||||
}
|
||||
@@ -5,5 +5,6 @@
|
||||
//! as the subprocess executor, coordinated via environment variables.
|
||||
|
||||
pub mod runner;
|
||||
pub mod integration_utils;
|
||||
|
||||
pub use runner::CargoTestRunner;
|
||||
|
||||
@@ -10,6 +10,7 @@ use sd_core::{
|
||||
indexing::IndexMode,
|
||||
locations::add::{action::LocationAddAction, action::LocationAddInput},
|
||||
},
|
||||
testing::integration_utils::IntegrationTestSetup,
|
||||
};
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
@@ -24,15 +25,7 @@ use tokio::{
|
||||
time::{sleep, timeout},
|
||||
};
|
||||
use tracing::{info, warn};
|
||||
// use std::process::Command;
|
||||
use uuid::Uuid;
|
||||
use sd_core::config::{AppConfig, ServiceConfig, JobLoggingConfig, Preferences};
|
||||
use tracing_appender::rolling::{RollingFileAppender, Rotation};
|
||||
use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
|
||||
use std::sync::Once;
|
||||
|
||||
/// Test data directory in the repo for inspection
|
||||
const TEST_DATA_DIR: &str = "data";
|
||||
|
||||
/// Benchmark recipe name to use for test data generation
|
||||
/// Using existing generated data from desktop_complex (or fallback to shape_medium if available)
|
||||
@@ -62,59 +55,9 @@ struct TestResult {
|
||||
success: bool,
|
||||
error: Option<String>,
|
||||
job_log_path: Option<PathBuf>,
|
||||
daemon_log_path: Option<PathBuf>,
|
||||
test_log_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// Initialize tracing with both console and file logging for test debugging
|
||||
fn initialize_test_tracing() -> Result<(), Box<dyn std::error::Error>> {
|
||||
static INIT: Once = Once::new();
|
||||
let mut result: Result<(), Box<dyn std::error::Error>> = Ok(());
|
||||
|
||||
INIT.call_once(|| {
|
||||
// Ensure test logs directory exists
|
||||
let test_logs_dir = PathBuf::from(TEST_DATA_DIR).join("test_logs");
|
||||
if let Err(e) = std::fs::create_dir_all(&test_logs_dir) {
|
||||
result = Err(format!("Failed to create test logs directory: {}", e).into());
|
||||
return;
|
||||
}
|
||||
|
||||
// Set up environment filter with detailed logging for tests
|
||||
let env_filter = std::env::var("RUST_LOG")
|
||||
.unwrap_or_else(|_| "warn,sd_core=info,job_resumption_integration_test=info".to_string());
|
||||
|
||||
// Create file appender that rotates daily
|
||||
let file_appender = RollingFileAppender::new(
|
||||
Rotation::DAILY,
|
||||
test_logs_dir,
|
||||
"job_resumption_test.log"
|
||||
);
|
||||
|
||||
// Set up layered subscriber with stdout, file output
|
||||
if let Err(e) = tracing_subscriber::registry()
|
||||
.with(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(env_filter)))
|
||||
.with(
|
||||
fmt::layer()
|
||||
.with_target(true)
|
||||
.with_thread_ids(true)
|
||||
.with_line_number(true)
|
||||
.with_writer(std::io::stdout),
|
||||
)
|
||||
.with(
|
||||
fmt::layer()
|
||||
.with_target(true)
|
||||
.with_thread_ids(true)
|
||||
.with_line_number(true)
|
||||
.with_ansi(false) // No ANSI colors in log files
|
||||
.with_writer(file_appender),
|
||||
)
|
||||
.try_init()
|
||||
{
|
||||
result = Err(format!("Failed to initialize tracing: {}", e).into());
|
||||
}
|
||||
});
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Main integration test for job resumption with realistic desktop-scale data
|
||||
///
|
||||
@@ -130,9 +73,6 @@ fn initialize_test_tracing() -> Result<(), Box<dyn std::error::Error>> {
|
||||
/// - Each interrupted job should cleanly pause and resume from where it left off
|
||||
#[tokio::test]
|
||||
async fn test_job_resumption_at_various_points() {
|
||||
// Initialize tracing for test debugging with file logging
|
||||
initialize_test_tracing().expect("Failed to initialize tracing");
|
||||
|
||||
info!("Starting job resumption integration test");
|
||||
|
||||
// Generate benchmark data (or use existing data)
|
||||
@@ -184,7 +124,7 @@ async fn test_job_resumption_at_various_points() {
|
||||
}
|
||||
|
||||
info!("All job resumption tests passed! 🎉");
|
||||
info!("Test logs available at: {}/test_logs/job_resumption_test.log", TEST_DATA_DIR);
|
||||
info!("Test logs and data available in: test_data/");
|
||||
}
|
||||
|
||||
/// Generate test data using benchmark data generation
|
||||
@@ -241,31 +181,6 @@ async fn generate_test_data() -> Result<PathBuf, Box<dyn std::error::Error>> {
|
||||
Ok(indexing_data_path)
|
||||
}
|
||||
|
||||
/// Create a test configuration with services disabled for faster testing
|
||||
async fn create_test_config(data_dir: &PathBuf) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let config = AppConfig {
|
||||
version: 3,
|
||||
data_dir: data_dir.clone(),
|
||||
log_level: "warn".to_string(), // Reduce log noise
|
||||
telemetry_enabled: false,
|
||||
preferences: Preferences::default(),
|
||||
job_logging: JobLoggingConfig {
|
||||
enabled: true,
|
||||
log_directory: "job_logs".to_string(),
|
||||
max_file_size: 10 * 1024 * 1024,
|
||||
include_debug: false,
|
||||
},
|
||||
services: ServiceConfig {
|
||||
networking_enabled: false, // Disable networking for faster tests
|
||||
volume_monitoring_enabled: false, // Disable volume monitoring
|
||||
location_watcher_enabled: true, // Keep location watcher for indexing
|
||||
},
|
||||
};
|
||||
|
||||
config.save()?;
|
||||
info!("Created test configuration with disabled services at: {}", data_dir.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test a single interruption point scenario
|
||||
async fn test_single_interruption_point(
|
||||
@@ -274,20 +189,29 @@ async fn test_single_interruption_point(
|
||||
test_index: usize,
|
||||
) -> TestResult {
|
||||
let test_name = format!("test_{:02}_{:?}", test_index, interruption_point);
|
||||
let test_data_path = PathBuf::from(TEST_DATA_DIR);
|
||||
let core_data_path = test_data_path.join(&test_name);
|
||||
|
||||
// Clean core data directory for this test
|
||||
if core_data_path.exists() {
|
||||
let _ = std::fs::remove_dir_all(&core_data_path);
|
||||
}
|
||||
std::fs::create_dir_all(&core_data_path).expect("Failed to create core data directory");
|
||||
// Create test environment with custom tracing
|
||||
let test_setup = match IntegrationTestSetup::with_tracing(
|
||||
&test_name,
|
||||
"warn,sd_core=info,job_resumption_integration_test=info"
|
||||
).await {
|
||||
Ok(setup) => setup,
|
||||
Err(error) => {
|
||||
return TestResult {
|
||||
interruption_point,
|
||||
success: false,
|
||||
error: Some(format!("Failed to create test setup: {}", error)),
|
||||
job_log_path: None,
|
||||
test_log_path: None,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
info!("Testing {} with data at {}", test_name, indexing_data_path.display());
|
||||
|
||||
// Phase 1: Start indexing and interrupt at specified point
|
||||
let interrupt_result = start_and_interrupt_job(
|
||||
&core_data_path,
|
||||
&test_setup,
|
||||
indexing_data_path,
|
||||
&interruption_point,
|
||||
).await;
|
||||
@@ -300,7 +224,7 @@ async fn test_single_interruption_point(
|
||||
success: false,
|
||||
error: Some(format!("Failed to interrupt job: {}", error)),
|
||||
job_log_path: None,
|
||||
daemon_log_path: None,
|
||||
test_log_path: None,
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -310,42 +234,39 @@ async fn test_single_interruption_point(
|
||||
|
||||
// Phase 2: Resume and complete the job
|
||||
let resume_result = resume_and_complete_job(
|
||||
&core_data_path,
|
||||
&test_setup,
|
||||
indexing_data_path,
|
||||
job_id,
|
||||
).await;
|
||||
|
||||
match resume_result {
|
||||
Ok((job_log_path, daemon_log_path)) => TestResult {
|
||||
Ok((job_log_path, test_log_path)) => TestResult {
|
||||
interruption_point,
|
||||
success: true,
|
||||
error: None,
|
||||
job_log_path: Some(job_log_path),
|
||||
daemon_log_path: Some(daemon_log_path),
|
||||
test_log_path: Some(test_log_path),
|
||||
},
|
||||
Err(error) => TestResult {
|
||||
interruption_point,
|
||||
success: false,
|
||||
error: Some(format!("Failed to resume job: {}", error)),
|
||||
job_log_path: None,
|
||||
daemon_log_path: None,
|
||||
test_log_path: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Start indexing job and interrupt at specified point
|
||||
async fn start_and_interrupt_job(
|
||||
core_data_path: &PathBuf,
|
||||
test_setup: &IntegrationTestSetup,
|
||||
indexing_data_path: &PathBuf,
|
||||
interruption_point: &InterruptionPoint,
|
||||
) -> Result<Uuid, Box<dyn std::error::Error>> {
|
||||
info!("Starting job and waiting for interruption point: {:?}", interruption_point);
|
||||
|
||||
// Create custom config with services disabled for faster testing
|
||||
create_test_config(core_data_path).await?;
|
||||
|
||||
// Create core with isolated data directory
|
||||
let core = sd_core::Core::new_with_config(core_data_path.clone()).await?;
|
||||
// Create core using the test setup's configuration
|
||||
let core = test_setup.create_core().await?;
|
||||
let core_context = core.context.clone();
|
||||
|
||||
// Create library
|
||||
@@ -494,9 +415,8 @@ async fn start_and_interrupt_job(
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for interruption point or timeout (increased for large dataset)
|
||||
// With 500k files, indexing can take 10+ minutes, so allow plenty of time for interruption
|
||||
let interrupt_timeout = timeout(Duration::from_secs(600), interrupt_rx.recv()).await;
|
||||
// Wait for interruption point or timeout
|
||||
let interrupt_timeout = timeout(Duration::from_secs(30), interrupt_rx.recv()).await;
|
||||
|
||||
match interrupt_timeout {
|
||||
Ok(Some(())) => {
|
||||
@@ -519,15 +439,14 @@ async fn start_and_interrupt_job(
|
||||
|
||||
/// Resume and complete the interrupted job
|
||||
async fn resume_and_complete_job(
|
||||
core_data_path: &PathBuf,
|
||||
test_setup: &IntegrationTestSetup,
|
||||
_indexing_data_path: &PathBuf,
|
||||
job_id: Uuid,
|
||||
) -> Result<(PathBuf, PathBuf), Box<dyn std::error::Error>> {
|
||||
info!("Resuming job {} and waiting for completion", job_id);
|
||||
|
||||
// The test config should already exist from the first phase
|
||||
// Create core again (simulating daemon restart)
|
||||
let core = sd_core::Core::new_with_config(core_data_path.clone()).await?;
|
||||
let core = test_setup.create_core().await?;
|
||||
let core_context = core.context.clone();
|
||||
|
||||
// Get the library (should auto-load)
|
||||
@@ -551,13 +470,13 @@ async fn resume_and_complete_job(
|
||||
info!("Job {} already completed during startup, no need to wait for events", job_id);
|
||||
|
||||
// Collect log paths for inspection
|
||||
let job_log_path = core_data_path.join("job_logs").join(format!("{}.log", job_id));
|
||||
let daemon_log_path = PathBuf::from(TEST_DATA_DIR).join("test_logs").join("job_resumption_test.log");
|
||||
let job_log_path = test_setup.env().job_log_path(job_id);
|
||||
let test_log_path = test_setup.env().log_file_path(&format!("{}.log", test_setup.env().test_name));
|
||||
|
||||
// Shutdown core
|
||||
core.shutdown().await?;
|
||||
|
||||
return Ok((job_log_path, daemon_log_path));
|
||||
return Ok((job_log_path, test_log_path));
|
||||
},
|
||||
sd_core::infra::job::types::JobStatus::Failed => {
|
||||
core.shutdown().await?;
|
||||
@@ -666,13 +585,13 @@ async fn resume_and_complete_job(
|
||||
info!("Job completed successfully");
|
||||
|
||||
// Collect log paths for inspection
|
||||
let job_log_path = core_data_path.join("job_logs").join(format!("{}.log", job_id));
|
||||
let daemon_log_path = PathBuf::from(TEST_DATA_DIR).join("test_logs").join("job_resumption_test.log");
|
||||
let job_log_path = test_setup.env().job_log_path(job_id);
|
||||
let test_log_path = test_setup.env().log_file_path(&format!("{}.log", test_setup.env().test_name));
|
||||
|
||||
// Shutdown core
|
||||
core.shutdown().await?;
|
||||
|
||||
Ok((job_log_path, daemon_log_path))
|
||||
Ok((job_log_path, test_log_path))
|
||||
},
|
||||
Ok(Some(Err(error))) => {
|
||||
core.shutdown().await?;
|
||||
@@ -710,8 +629,8 @@ fn analyze_test_results(results: &[TestResult]) {
|
||||
if let Some(job_log) = &result.job_log_path {
|
||||
warn!(" Job log: {}", job_log.display());
|
||||
}
|
||||
if let Some(daemon_log) = &result.daemon_log_path {
|
||||
warn!(" Daemon log: {}", daemon_log.display());
|
||||
if let Some(test_log) = &result.test_log_path {
|
||||
warn!(" Test log: {}", test_log.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -735,6 +654,6 @@ fn analyze_test_results(results: &[TestResult]) {
|
||||
info!(" {}: {}/{} passed", phase, phase_passed, phase_total);
|
||||
}
|
||||
|
||||
info!("Test data and logs available in: {}", TEST_DATA_DIR);
|
||||
info!("Test data and logs available in: test_data/");
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user