sync app filled in
This commit is contained in:
263
apps/macos-sync/Sources/CairnquireSync/API/Client.swift
Normal file
263
apps/macos-sync/Sources/CairnquireSync/API/Client.swift
Normal file
@@ -0,0 +1,263 @@
|
||||
import Foundation
|
||||
|
||||
public typealias UploadProgressHandler = @Sendable (Double) -> Void
|
||||
|
||||
private final class UploadProgressTaskDelegate: NSObject, URLSessionTaskDelegate {
|
||||
private let onProgress: UploadProgressHandler
|
||||
|
||||
init(onProgress: @escaping UploadProgressHandler) {
|
||||
self.onProgress = onProgress
|
||||
}
|
||||
|
||||
func urlSession(
|
||||
_ session: URLSession,
|
||||
task: URLSessionTask,
|
||||
didSendBodyData bytesSent: Int64,
|
||||
totalBytesSent: Int64,
|
||||
totalBytesExpectedToSend: Int64
|
||||
) {
|
||||
guard totalBytesExpectedToSend > 0 else { return }
|
||||
let progress = min(max(Double(totalBytesSent) / Double(totalBytesExpectedToSend), 0), 1)
|
||||
onProgress(progress)
|
||||
}
|
||||
}
|
||||
|
||||
public actor APIClient {
|
||||
public let baseURL: URL
|
||||
public var apiToken: String?
|
||||
|
||||
private let session: URLSession
|
||||
private let decoder: JSONDecoder
|
||||
private let encoder: JSONEncoder
|
||||
|
||||
public init(baseURL: URL, apiToken: String? = nil) {
|
||||
self.baseURL = baseURL
|
||||
self.apiToken = apiToken
|
||||
if self.apiToken == nil && Self.isLocalhost(baseURL) {
|
||||
self.apiToken = "dev"
|
||||
}
|
||||
self.session = URLSession(configuration: .default)
|
||||
|
||||
self.decoder = JSONDecoder()
|
||||
self.decoder.dateDecodingStrategy = .iso8601
|
||||
|
||||
self.encoder = JSONEncoder()
|
||||
self.encoder.dateEncodingStrategy = .iso8601
|
||||
}
|
||||
|
||||
private static func isLocalhost(_ url: URL) -> Bool {
|
||||
guard let host = url.host else { return false }
|
||||
return host == "localhost" || host == "127.0.0.1"
|
||||
}
|
||||
|
||||
public func setAPIToken(_ token: String?) {
|
||||
self.apiToken = token
|
||||
}
|
||||
|
||||
private func request(for path: String) -> URLRequest {
|
||||
var request = URLRequest(url: baseURL.appendingPathComponent(path))
|
||||
if let token = apiToken {
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
return request
|
||||
}
|
||||
|
||||
private func decodeResponse<T: Decodable>(data: Data, response: URLResponse) throws -> T {
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw APIError(error: "invalid response")
|
||||
}
|
||||
|
||||
if httpResponse.statusCode >= 400 {
|
||||
if let error = try? decoder.decode(APIError.self, from: data) {
|
||||
throw error
|
||||
}
|
||||
throw APIError(error: "HTTP \(httpResponse.statusCode)")
|
||||
}
|
||||
|
||||
if T.self == EmptyResponse.self {
|
||||
return EmptyResponse() as! T
|
||||
}
|
||||
|
||||
return try decoder.decode(T.self, from: data)
|
||||
}
|
||||
|
||||
private func perform<T: Decodable>(_ request: URLRequest) async throws -> T {
|
||||
let (data, response) = try await session.data(for: request)
|
||||
return try decodeResponse(data: data, response: response)
|
||||
}
|
||||
|
||||
private func performUpload<T: Decodable>(
|
||||
_ request: URLRequest,
|
||||
body: Data,
|
||||
onProgress: UploadProgressHandler?
|
||||
) async throws -> T {
|
||||
onProgress?(0)
|
||||
let delegate = onProgress.map { UploadProgressTaskDelegate(onProgress: $0) }
|
||||
let (data, response) = try await session.upload(for: request, from: body, delegate: delegate)
|
||||
onProgress?(1)
|
||||
return try decodeResponse(data: data, response: response)
|
||||
}
|
||||
|
||||
// MARK: - Uploads
|
||||
|
||||
public func uploadFile(
|
||||
url: URL,
|
||||
folder: String? = nil,
|
||||
duplicateAction: UploadDuplicateAction? = nil,
|
||||
onProgress: UploadProgressHandler? = nil
|
||||
) async throws -> UploadResponse {
|
||||
guard let token = apiToken else {
|
||||
throw APIError(error: "no API token configured")
|
||||
}
|
||||
|
||||
let boundary = UUID().uuidString
|
||||
var request = URLRequest(url: baseURL.appendingPathComponent("api/uploads"))
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
let data = try Data(contentsOf: url)
|
||||
let filename = url.lastPathComponent
|
||||
|
||||
var body = Data()
|
||||
body.append("--\(boundary)\r\n".data(using: .utf8)!)
|
||||
if let folder, !folder.isEmpty {
|
||||
body.append("Content-Disposition: form-data; name=\"folder\"\r\n\r\n".data(using: .utf8)!)
|
||||
body.append(folder.data(using: .utf8)!)
|
||||
body.append("\r\n--\(boundary)\r\n".data(using: .utf8)!)
|
||||
}
|
||||
if let duplicateAction {
|
||||
body.append("Content-Disposition: form-data; name=\"duplicateAction\"\r\n\r\n".data(using: .utf8)!)
|
||||
body.append(duplicateAction.rawValue.data(using: .utf8)!)
|
||||
body.append("\r\n--\(boundary)\r\n".data(using: .utf8)!)
|
||||
}
|
||||
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\n".data(using: .utf8)!)
|
||||
body.append("Content-Type: application/octet-stream\r\n\r\n".data(using: .utf8)!)
|
||||
body.append(data)
|
||||
body.append("\r\n".data(using: .utf8)!)
|
||||
body.append("--\(boundary)--\r\n".data(using: .utf8)!)
|
||||
|
||||
return try await performUpload(request, body: body, onProgress: onProgress)
|
||||
}
|
||||
|
||||
public func uploadData(
|
||||
_ data: Data,
|
||||
filename: String,
|
||||
folder: String? = nil,
|
||||
duplicateAction: UploadDuplicateAction? = nil,
|
||||
onProgress: UploadProgressHandler? = nil
|
||||
) async throws -> UploadResponse {
|
||||
guard let token = apiToken else {
|
||||
throw APIError(error: "no API token configured")
|
||||
}
|
||||
|
||||
let boundary = UUID().uuidString
|
||||
var request = URLRequest(url: baseURL.appendingPathComponent("api/uploads"))
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
var body = Data()
|
||||
body.append("--\(boundary)\r\n".data(using: .utf8)!)
|
||||
if let folder, !folder.isEmpty {
|
||||
body.append("Content-Disposition: form-data; name=\"folder\"\r\n\r\n".data(using: .utf8)!)
|
||||
body.append(folder.data(using: .utf8)!)
|
||||
body.append("\r\n--\(boundary)\r\n".data(using: .utf8)!)
|
||||
}
|
||||
if let duplicateAction {
|
||||
body.append("Content-Disposition: form-data; name=\"duplicateAction\"\r\n\r\n".data(using: .utf8)!)
|
||||
body.append(duplicateAction.rawValue.data(using: .utf8)!)
|
||||
body.append("\r\n--\(boundary)\r\n".data(using: .utf8)!)
|
||||
}
|
||||
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\n".data(using: .utf8)!)
|
||||
body.append("Content-Type: application/octet-stream\r\n\r\n".data(using: .utf8)!)
|
||||
body.append(data)
|
||||
body.append("\r\n".data(using: .utf8)!)
|
||||
body.append("--\(boundary)--\r\n".data(using: .utf8)!)
|
||||
|
||||
return try await performUpload(request, body: body, onProgress: onProgress)
|
||||
}
|
||||
|
||||
// MARK: - Attachments
|
||||
|
||||
public func listAttachments() async throws -> [AttachmentRecord] {
|
||||
let request = self.request(for: "api/attachments")
|
||||
let response: AttachmentsListResponse = try await perform(request)
|
||||
return response.attachments
|
||||
}
|
||||
|
||||
public func attachmentURL(hash: String) -> URL {
|
||||
baseURL.appendingPathComponent("attachments/\(hash)")
|
||||
}
|
||||
|
||||
// MARK: - Documents
|
||||
|
||||
public func listDocuments() async throws -> [DocumentRecord] {
|
||||
let request = self.request(for: "api/documents")
|
||||
let response: DocumentsListResponse = try await perform(request)
|
||||
return response.documents
|
||||
}
|
||||
|
||||
public func saveDocument(path: String, content: String, baseHash: String? = nil) async throws -> DocumentSaveResponse {
|
||||
var request = self.request(for: "api/documents/\(path)")
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
let body = DocumentSaveRequest(content: content, baseHash: baseHash)
|
||||
request.httpBody = try encoder.encode(body)
|
||||
|
||||
return try await perform(request)
|
||||
}
|
||||
|
||||
// MARK: - Sync
|
||||
|
||||
public func syncInit(deviceId: String, rootPath: String) async throws -> SyncInitResponse {
|
||||
var request = self.request(for: "api/sync/init")
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
let body = SyncInitRequest(deviceId: deviceId, rootPath: rootPath)
|
||||
request.httpBody = try encoder.encode(body)
|
||||
|
||||
return try await perform(request)
|
||||
}
|
||||
|
||||
public func syncDelta(snapshotId: String, clientDelta: [Change]) async throws -> DeltaResponse {
|
||||
var request = self.request(for: "api/sync/delta")
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
let body = DeltaRequest(snapshotId: snapshotId, clientDelta: clientDelta)
|
||||
request.httpBody = try encoder.encode(body)
|
||||
|
||||
return try await perform(request)
|
||||
}
|
||||
|
||||
public func fetchContent(hash: String) async throws -> Data {
|
||||
let request = self.request(for: "api/content/\(hash)")
|
||||
let (data, response) = try await session.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw APIError(error: "invalid response")
|
||||
}
|
||||
if httpResponse.statusCode >= 400 {
|
||||
if let error = try? decoder.decode(APIError.self, from: data) {
|
||||
throw error
|
||||
}
|
||||
throw APIError(error: "HTTP \(httpResponse.statusCode)")
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// MARK: - Health
|
||||
|
||||
public func healthCheck() async throws -> Bool {
|
||||
let request = self.request(for: "health")
|
||||
let (_, response) = try await session.data(for: request)
|
||||
return (response as? HTTPURLResponse)?.statusCode == 200
|
||||
}
|
||||
}
|
||||
|
||||
public struct EmptyResponse: Decodable {}
|
||||
185
apps/macos-sync/Sources/CairnquireSync/API/Models.swift
Normal file
185
apps/macos-sync/Sources/CairnquireSync/API/Models.swift
Normal file
@@ -0,0 +1,185 @@
|
||||
import Foundation
|
||||
|
||||
public struct AttachmentRecord: Codable, Identifiable {
|
||||
public var id: String { hash }
|
||||
public let hash: String
|
||||
public let originalName: String
|
||||
public let contentType: String
|
||||
public let sizeBytes: Int
|
||||
public let createdAt: Date
|
||||
public let url: String
|
||||
public let kind: String?
|
||||
public let documentPath: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case hash
|
||||
case originalName
|
||||
case contentType
|
||||
case sizeBytes
|
||||
case createdAt
|
||||
case url
|
||||
case kind
|
||||
case documentPath
|
||||
}
|
||||
}
|
||||
|
||||
public struct UploadResponse: Codable {
|
||||
public let hash: String
|
||||
public let contentType: String
|
||||
public let attachmentUrl: String
|
||||
public let url: String?
|
||||
public let kind: String?
|
||||
}
|
||||
|
||||
public struct DocumentRecord: Codable, Identifiable {
|
||||
public let id: String
|
||||
public let path: String
|
||||
public let title: String
|
||||
public let hash: String
|
||||
public let createdAt: Date?
|
||||
public let updatedAt: Date?
|
||||
}
|
||||
|
||||
public struct DocumentSaveRequest: Codable {
|
||||
public let content: String
|
||||
public let baseHash: String?
|
||||
}
|
||||
|
||||
public struct DocumentSaveResponse: Codable {
|
||||
public let status: String
|
||||
public let path: String
|
||||
public let title: String
|
||||
public let hash: String
|
||||
}
|
||||
|
||||
public struct SyncInitRequest: Codable {
|
||||
public let deviceId: String
|
||||
public let rootPath: String
|
||||
}
|
||||
|
||||
public struct SyncInitResponse: Codable {
|
||||
public let snapshotId: String
|
||||
public let serverSnapshot: [FileEntry]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case snapshotId
|
||||
case serverSnapshot
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
snapshotId = try container.decode(String.self, forKey: .snapshotId)
|
||||
serverSnapshot = try container.decodeIfPresent([FileEntry].self, forKey: .serverSnapshot) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
public struct FileEntry: Codable {
|
||||
public let path: String
|
||||
public let hash: String
|
||||
public let size: Int64
|
||||
public let modified: Date
|
||||
}
|
||||
|
||||
public struct DeltaRequest: Codable {
|
||||
public let snapshotId: String
|
||||
public let clientDelta: [Change]
|
||||
}
|
||||
|
||||
public struct DeltaResponse: Codable {
|
||||
public let serverDelta: [Change]
|
||||
public let conflicts: [Conflict]
|
||||
public let newSnapshotId: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case serverDelta
|
||||
case conflicts
|
||||
case newSnapshotId
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
serverDelta = try container.decodeIfPresent([Change].self, forKey: .serverDelta) ?? []
|
||||
conflicts = try container.decodeIfPresent([Conflict].self, forKey: .conflicts) ?? []
|
||||
newSnapshotId = try container.decodeIfPresent(String.self, forKey: .newSnapshotId)
|
||||
}
|
||||
}
|
||||
|
||||
public struct Change: Codable {
|
||||
public let type: String
|
||||
public let path: String
|
||||
public let oldPath: String?
|
||||
public let hash: String?
|
||||
public let content: String?
|
||||
public let size: Int64?
|
||||
public let modified: Date?
|
||||
|
||||
public init(
|
||||
type: String,
|
||||
path: String,
|
||||
oldPath: String?,
|
||||
hash: String?,
|
||||
content: String? = nil,
|
||||
size: Int64?,
|
||||
modified: Date?
|
||||
) {
|
||||
self.type = type
|
||||
self.path = path
|
||||
self.oldPath = oldPath
|
||||
self.hash = hash
|
||||
self.content = content
|
||||
self.size = size
|
||||
self.modified = modified
|
||||
}
|
||||
}
|
||||
|
||||
public struct Conflict: Codable {
|
||||
public let path: String
|
||||
public let serverHash: String
|
||||
public let clientHash: String
|
||||
public let serverModified: Date
|
||||
public let clientModified: Date
|
||||
}
|
||||
|
||||
public struct APIError: Error, Codable {
|
||||
public let error: String
|
||||
public let conflictType: String?
|
||||
public let existingHash: String?
|
||||
public let existingPath: String?
|
||||
public let existingUrl: String?
|
||||
|
||||
public init(
|
||||
error: String,
|
||||
conflictType: String? = nil,
|
||||
existingHash: String? = nil,
|
||||
existingPath: String? = nil,
|
||||
existingUrl: String? = nil
|
||||
) {
|
||||
self.error = error
|
||||
self.conflictType = conflictType
|
||||
self.existingHash = existingHash
|
||||
self.existingPath = existingPath
|
||||
self.existingUrl = existingUrl
|
||||
}
|
||||
|
||||
public var isUploadConflict: Bool {
|
||||
conflictType == "document" || conflictType == "attachment"
|
||||
}
|
||||
}
|
||||
|
||||
extension APIError: LocalizedError {
|
||||
public var errorDescription: String? { error }
|
||||
}
|
||||
|
||||
public struct AttachmentsListResponse: Codable {
|
||||
public let attachments: [AttachmentRecord]
|
||||
}
|
||||
|
||||
public struct DocumentsListResponse: Codable {
|
||||
public let documents: [DocumentRecord]
|
||||
}
|
||||
|
||||
public enum UploadDuplicateAction: String {
|
||||
case overwrite
|
||||
case rename
|
||||
case reuse
|
||||
}
|
||||
152
apps/macos-sync/Sources/CairnquireSync/Settings/Config.swift
Normal file
152
apps/macos-sync/Sources/CairnquireSync/Settings/Config.swift
Normal file
@@ -0,0 +1,152 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
public struct SyncConfig: Codable, Equatable {
|
||||
public var serverURL: String
|
||||
public var apiToken: String?
|
||||
public var syncFolder: String?
|
||||
public var defaultUploadFolder: String?
|
||||
public var autoSync: Bool
|
||||
public var syncInterval: Int // seconds
|
||||
public var deviceId: String
|
||||
public var iconName: String // SF Symbol name
|
||||
|
||||
public static let iconOptions = [
|
||||
"tray.circle.fill",
|
||||
"arrow.triangle.2.circlepath",
|
||||
"arrow.up.doc",
|
||||
"doc.badge.arrow.up",
|
||||
"arrow.up.and.down.and.arrow.left.and.right",
|
||||
"cloud",
|
||||
"externaldrive.connected.to.line.below",
|
||||
"note.text",
|
||||
"doc.text",
|
||||
"arrow.clockwise.circle",
|
||||
"rectangle.stack.badge.plus"
|
||||
]
|
||||
|
||||
public init(
|
||||
serverURL: String = "http://localhost:8080",
|
||||
apiToken: String? = nil,
|
||||
syncFolder: String? = nil,
|
||||
defaultUploadFolder: String? = nil,
|
||||
autoSync: Bool = true,
|
||||
syncInterval: Int = 30,
|
||||
deviceId: String? = nil,
|
||||
iconName: String = "tray.circle.fill"
|
||||
) {
|
||||
self.serverURL = serverURL
|
||||
self.apiToken = apiToken
|
||||
self.syncFolder = syncFolder
|
||||
self.defaultUploadFolder = defaultUploadFolder
|
||||
self.autoSync = autoSync
|
||||
self.syncInterval = syncInterval
|
||||
self.deviceId = deviceId ?? SyncConfig.generateDeviceId()
|
||||
self.iconName = iconName
|
||||
}
|
||||
|
||||
private static func generateDeviceId() -> String {
|
||||
if let existing = UserDefaults.standard.string(forKey: "cairnquire_device_id") {
|
||||
return existing
|
||||
}
|
||||
let id = "mac-\(UUID().uuidString.replacingOccurrences(of: "-", with: "").prefix(12))"
|
||||
UserDefaults.standard.set(id, forKey: "cairnquire_device_id")
|
||||
return id
|
||||
}
|
||||
|
||||
public var serverBaseURL: URL? {
|
||||
URL(string: serverURL)
|
||||
}
|
||||
}
|
||||
|
||||
public actor ConfigStore {
|
||||
public static let shared = ConfigStore()
|
||||
|
||||
private let defaults = UserDefaults.standard
|
||||
private let keychainService = "com.cairnquire.sync"
|
||||
private let configKey = "cairnquire_config"
|
||||
|
||||
public init() {}
|
||||
|
||||
nonisolated public func load() -> SyncConfig {
|
||||
let defaults = UserDefaults.standard
|
||||
if let data = defaults.data(forKey: configKey),
|
||||
let config = try? JSONDecoder().decode(SyncConfig.self, from: data) {
|
||||
var config = config
|
||||
// Load token from keychain
|
||||
if let token = loadTokenFromKeychain() {
|
||||
config.apiToken = token
|
||||
}
|
||||
return config
|
||||
}
|
||||
return SyncConfig()
|
||||
}
|
||||
|
||||
public func save(_ config: SyncConfig) {
|
||||
let defaults = UserDefaults.standard
|
||||
var configToSave = config
|
||||
let token = config.apiToken
|
||||
configToSave.apiToken = nil
|
||||
|
||||
if let data = try? JSONEncoder().encode(configToSave) {
|
||||
defaults.set(data, forKey: configKey)
|
||||
}
|
||||
|
||||
if let token = token {
|
||||
saveTokenToKeychain(token)
|
||||
} else {
|
||||
deleteTokenFromKeychain()
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated func saveTokenToKeychain(_ token: String) {
|
||||
let data = token.data(using: .utf8)!
|
||||
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: keychainService,
|
||||
kSecAttrAccount as String: "api_token"
|
||||
]
|
||||
|
||||
let attributes: [String: Any] = [
|
||||
kSecValueData as String: data,
|
||||
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
|
||||
]
|
||||
|
||||
if SecItemUpdate(query as CFDictionary, attributes as CFDictionary) == errSecItemNotFound {
|
||||
var newItem = query
|
||||
newItem.merge(attributes) { _, new in new }
|
||||
SecItemAdd(newItem as CFDictionary, nil)
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated func loadTokenFromKeychain() -> String? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: keychainService,
|
||||
kSecAttrAccount as String: "api_token",
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne
|
||||
]
|
||||
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
|
||||
guard status == errSecSuccess,
|
||||
let data = result as? Data,
|
||||
let token = String(data: data, encoding: .utf8) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return token
|
||||
}
|
||||
|
||||
private nonisolated func deleteTokenFromKeychain() {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: keychainService,
|
||||
kSecAttrAccount as String: "api_token"
|
||||
]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
}
|
||||
}
|
||||
103
apps/macos-sync/Sources/CairnquireSync/Sync/FolderWatcher.swift
Normal file
103
apps/macos-sync/Sources/CairnquireSync/Sync/FolderWatcher.swift
Normal file
@@ -0,0 +1,103 @@
|
||||
import Foundation
|
||||
import CoreServices
|
||||
|
||||
public protocol FolderWatcherDelegate: AnyObject {
|
||||
func folderWatcher(_ watcher: FolderWatcher, didDetectChanges paths: [String])
|
||||
}
|
||||
|
||||
public class FolderWatcher {
|
||||
public weak var delegate: FolderWatcherDelegate?
|
||||
|
||||
private var stream: FSEventStreamRef?
|
||||
private let folderPath: String
|
||||
private var debounceTimer: Timer?
|
||||
private var pendingPaths: Set<String> = []
|
||||
private let queue = DispatchQueue(label: "com.cairnquire.folderwatcher")
|
||||
|
||||
public init(folderPath: String) {
|
||||
self.folderPath = folderPath
|
||||
}
|
||||
|
||||
public func start() {
|
||||
stop()
|
||||
|
||||
let pathsToWatch = [folderPath as CFString]
|
||||
var context = FSEventStreamContext(
|
||||
version: 0,
|
||||
info: Unmanaged.passUnretained(self).toOpaque(),
|
||||
retain: nil,
|
||||
release: nil,
|
||||
copyDescription: nil
|
||||
)
|
||||
|
||||
let callback: FSEventStreamCallback = { (streamRef, clientCallBackInfo, numEvents, eventPaths, eventFlags, eventIds) in
|
||||
let watcher = Unmanaged<FolderWatcher>.fromOpaque(clientCallBackInfo!).takeUnretainedValue()
|
||||
watcher.handleEvents(numEvents: numEvents, eventPaths: eventPaths, eventFlags: eventFlags)
|
||||
}
|
||||
|
||||
stream = FSEventStreamCreate(
|
||||
kCFAllocatorDefault,
|
||||
callback,
|
||||
&context,
|
||||
pathsToWatch as CFArray,
|
||||
FSEventStreamEventId(kFSEventStreamEventIdSinceNow),
|
||||
0.5, // latency in seconds
|
||||
FSEventStreamCreateFlags(kFSEventStreamCreateFlagFileEvents | kFSEventStreamCreateFlagUseCFTypes)
|
||||
)
|
||||
|
||||
if let stream = stream {
|
||||
FSEventStreamSetDispatchQueue(stream, queue)
|
||||
FSEventStreamStart(stream)
|
||||
}
|
||||
}
|
||||
|
||||
public func stop() {
|
||||
if let stream = stream {
|
||||
FSEventStreamStop(stream)
|
||||
FSEventStreamInvalidate(stream)
|
||||
FSEventStreamRelease(stream)
|
||||
self.stream = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func handleEvents(numEvents: Int, eventPaths: UnsafeMutableRawPointer, eventFlags: UnsafePointer<FSEventStreamEventFlags>) {
|
||||
guard let paths = Unmanaged<CFArray>.fromOpaque(eventPaths).takeUnretainedValue() as? [String] else { return }
|
||||
|
||||
var changedPaths: [String] = []
|
||||
for i in 0..<numEvents {
|
||||
let path = paths[i]
|
||||
let flags = eventFlags[i]
|
||||
|
||||
// Filter for relevant file events
|
||||
if flags & UInt32(kFSEventStreamEventFlagItemCreated) != 0 ||
|
||||
flags & UInt32(kFSEventStreamEventFlagItemModified) != 0 ||
|
||||
flags & UInt32(kFSEventStreamEventFlagItemRemoved) != 0 ||
|
||||
flags & UInt32(kFSEventStreamEventFlagItemRenamed) != 0 {
|
||||
|
||||
// Only track markdown files
|
||||
if path.hasSuffix(".md") {
|
||||
changedPaths.append(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
guard !changedPaths.isEmpty else { return }
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
self.pendingPaths.formUnion(changedPaths)
|
||||
self.debounceTimer?.invalidate()
|
||||
self.debounceTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: false) { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
let paths = Array(self.pendingPaths)
|
||||
self.pendingPaths.removeAll()
|
||||
self.delegate?.folderWatcher(self, didDetectChanges: paths)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
stop()
|
||||
}
|
||||
}
|
||||
269
apps/macos-sync/Sources/CairnquireSync/Sync/SyncEngine.swift
Normal file
269
apps/macos-sync/Sources/CairnquireSync/Sync/SyncEngine.swift
Normal file
@@ -0,0 +1,269 @@
|
||||
import Foundation
|
||||
|
||||
public actor SyncEngine {
|
||||
private let client: APIClient
|
||||
private let config: SyncConfig
|
||||
private let localState: LocalSyncState
|
||||
private var snapshotId: String?
|
||||
|
||||
public init(client: APIClient, config: SyncConfig) {
|
||||
self.client = client
|
||||
self.config = config
|
||||
self.localState = LocalSyncState(deviceId: config.deviceId)
|
||||
}
|
||||
|
||||
public func performSync() async throws {
|
||||
guard let syncFolder = config.syncFolder else {
|
||||
return
|
||||
}
|
||||
|
||||
let folderURL = URL(fileURLWithPath: syncFolder)
|
||||
try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true)
|
||||
|
||||
// 1. Initialize sync
|
||||
if snapshotId == nil {
|
||||
let initResponse = try await client.syncInit(deviceId: config.deviceId, rootPath: syncFolder)
|
||||
self.snapshotId = initResponse.snapshotId
|
||||
try await applyInitialServerSnapshot(initResponse.serverSnapshot, baseURL: folderURL)
|
||||
try await localState.updateSnapshot(initResponse.serverSnapshot)
|
||||
}
|
||||
|
||||
guard let currentSnapshotId = snapshotId else { return }
|
||||
|
||||
// 2. Compute local delta
|
||||
let localFiles = try scanLocalFolder(folderURL)
|
||||
let localDelta = try await localState.computeDelta(localFiles: localFiles)
|
||||
let outboundDelta = try addContent(to: localDelta, baseURL: folderURL)
|
||||
|
||||
// 3. Send local changes and check for server changes
|
||||
let deltaResponse = try await client.syncDelta(snapshotId: currentSnapshotId, clientDelta: outboundDelta)
|
||||
|
||||
// 4. Apply server changes
|
||||
try await applyServerDelta(deltaResponse.serverDelta, baseURL: folderURL)
|
||||
|
||||
// The server returns its current bytes in serverDelta for conflicts.
|
||||
// Until the native app has a merge UI, use the server copy locally.
|
||||
self.snapshotId = deltaResponse.newSnapshotId ?? currentSnapshotId
|
||||
|
||||
// 5. Update local state after applying inbound changes
|
||||
try await localState.updateSnapshot(scanLocalFolder(folderURL))
|
||||
}
|
||||
|
||||
private func scanLocalFolder(_ folderURL: URL) throws -> [FileEntry] {
|
||||
var files: [FileEntry] = []
|
||||
let fileManager = FileManager.default
|
||||
|
||||
guard let enumerator = fileManager.enumerator(
|
||||
at: folderURL,
|
||||
includingPropertiesForKeys: [.contentModificationDateKey, .fileSizeKey],
|
||||
options: [.skipsHiddenFiles]
|
||||
) else {
|
||||
return files
|
||||
}
|
||||
|
||||
for case let fileURL as URL in enumerator {
|
||||
let relativePath = fileURL.path.replacingOccurrences(of: folderURL.path + "/", with: "")
|
||||
|
||||
// Only sync markdown files for now
|
||||
guard fileURL.pathExtension == "md" else { continue }
|
||||
|
||||
let attributes = try fileManager.attributesOfItem(atPath: fileURL.path)
|
||||
let modificationDate = attributes[.modificationDate] as? Date ?? Date()
|
||||
let fileSize = attributes[.size] as? Int64 ?? 0
|
||||
|
||||
// Compute hash
|
||||
let content = try Data(contentsOf: fileURL)
|
||||
let hash = SHA256.hash(data: content).hexString
|
||||
|
||||
files.append(FileEntry(
|
||||
path: relativePath,
|
||||
hash: hash,
|
||||
size: fileSize,
|
||||
modified: modificationDate
|
||||
))
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
private func applyServerDelta(_ changes: [Change], baseURL: URL) async throws {
|
||||
for change in changes {
|
||||
let fileURL = try localFileURL(for: change.path, baseURL: baseURL)
|
||||
|
||||
switch change.type {
|
||||
case "create", "update":
|
||||
guard let hash = change.hash else {
|
||||
throw SyncEngineError.missingHash(change.path)
|
||||
}
|
||||
try await writeServerContent(hash: hash, to: fileURL)
|
||||
case "delete":
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
case "rename":
|
||||
if let oldPath = change.oldPath {
|
||||
let oldURL = try localFileURL(for: oldPath, baseURL: baseURL)
|
||||
try FileManager.default.createDirectory(at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
try? FileManager.default.moveItem(at: oldURL, to: fileURL)
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func applyInitialServerSnapshot(_ files: [FileEntry], baseURL: URL) async throws {
|
||||
for file in files {
|
||||
let fileURL = try localFileURL(for: file.path, baseURL: baseURL)
|
||||
if !FileManager.default.fileExists(atPath: fileURL.path) {
|
||||
try await writeServerContent(hash: file.hash, to: fileURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func addContent(to changes: [Change], baseURL: URL) throws -> [Change] {
|
||||
try changes.map { change in
|
||||
guard change.type == "create" || change.type == "update" else {
|
||||
return change
|
||||
}
|
||||
|
||||
let fileURL = try localFileURL(for: change.path, baseURL: baseURL)
|
||||
let data = try Data(contentsOf: fileURL)
|
||||
guard let content = String(data: data, encoding: .utf8) else {
|
||||
throw SyncEngineError.invalidUTF8(change.path)
|
||||
}
|
||||
|
||||
return Change(
|
||||
type: change.type,
|
||||
path: change.path,
|
||||
oldPath: change.oldPath,
|
||||
hash: change.hash,
|
||||
content: content,
|
||||
size: change.size,
|
||||
modified: change.modified
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func writeServerContent(hash: String, to fileURL: URL) async throws {
|
||||
let content = try await client.fetchContent(hash: hash)
|
||||
guard SHA256.hash(data: content).hexString == hash.lowercased() else {
|
||||
throw SyncEngineError.hashMismatch(fileURL.lastPathComponent)
|
||||
}
|
||||
try FileManager.default.createDirectory(at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
try content.write(to: fileURL, options: .atomic)
|
||||
}
|
||||
|
||||
private func localFileURL(for path: String, baseURL: URL) throws -> URL {
|
||||
let root = baseURL.standardizedFileURL
|
||||
let fileURL = root.appendingPathComponent(path).standardizedFileURL
|
||||
guard fileURL.path.hasPrefix(root.path + "/"),
|
||||
fileURL.pathExtension.lowercased() == "md" else {
|
||||
throw SyncEngineError.invalidPath(path)
|
||||
}
|
||||
return fileURL
|
||||
}
|
||||
}
|
||||
|
||||
public enum SyncEngineError: LocalizedError {
|
||||
case invalidPath(String)
|
||||
case invalidUTF8(String)
|
||||
case missingHash(String)
|
||||
case hashMismatch(String)
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidPath(let path):
|
||||
return "Invalid sync path: \(path)"
|
||||
case .invalidUTF8(let path):
|
||||
return "Sync only supports UTF-8 markdown files: \(path)"
|
||||
case .missingHash(let path):
|
||||
return "Server sync response is missing a content hash: \(path)"
|
||||
case .hashMismatch(let path):
|
||||
return "Downloaded sync content failed hash verification: \(path)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SHA256 Helper
|
||||
|
||||
import CryptoKit
|
||||
|
||||
extension SHA256.Digest {
|
||||
var hexString: String {
|
||||
map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Local Sync State
|
||||
|
||||
public actor LocalSyncState {
|
||||
private let deviceId: String
|
||||
private var lastSnapshot: [String: FileEntry] = [:] // path -> entry
|
||||
|
||||
public init(deviceId: String) {
|
||||
self.deviceId = deviceId
|
||||
}
|
||||
|
||||
public func updateSnapshot(_ files: [FileEntry]) async throws {
|
||||
lastSnapshot = Dictionary(uniqueKeysWithValues: files.map { ($0.path, $0) })
|
||||
try persist()
|
||||
}
|
||||
|
||||
public func computeDelta(localFiles: [FileEntry]) async throws -> [Change] {
|
||||
var changes: [Change] = []
|
||||
let localMap = Dictionary(uniqueKeysWithValues: localFiles.map { ($0.path, $0) })
|
||||
|
||||
// Check for new or modified files
|
||||
for file in localFiles {
|
||||
if let last = lastSnapshot[file.path] {
|
||||
if last.hash != file.hash {
|
||||
changes.append(Change(
|
||||
type: "update",
|
||||
path: file.path,
|
||||
oldPath: nil,
|
||||
hash: file.hash,
|
||||
size: file.size,
|
||||
modified: file.modified
|
||||
))
|
||||
}
|
||||
} else {
|
||||
changes.append(Change(
|
||||
type: "create",
|
||||
path: file.path,
|
||||
oldPath: nil,
|
||||
hash: file.hash,
|
||||
size: file.size,
|
||||
modified: file.modified
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// Check for deleted files
|
||||
for (path, _) in lastSnapshot where localMap[path] == nil {
|
||||
changes.append(Change(
|
||||
type: "delete",
|
||||
path: path,
|
||||
oldPath: nil,
|
||||
hash: nil,
|
||||
size: nil,
|
||||
modified: nil
|
||||
))
|
||||
}
|
||||
|
||||
return changes
|
||||
}
|
||||
|
||||
private func persist() throws {
|
||||
// Persist to local JSON file
|
||||
let entries = Array(lastSnapshot.values)
|
||||
let data = try JSONEncoder().encode(entries)
|
||||
let url = stateFileURL()
|
||||
try data.write(to: url)
|
||||
}
|
||||
|
||||
private func stateFileURL() -> URL {
|
||||
let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
||||
let dir = appSupport.appendingPathComponent("CairnquireSync", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
return dir.appendingPathComponent("sync_state_\(deviceId).json")
|
||||
}
|
||||
}
|
||||
141
apps/macos-sync/Sources/CairnquireSync/UI/DropZoneView.swift
Normal file
141
apps/macos-sync/Sources/CairnquireSync/UI/DropZoneView.swift
Normal file
@@ -0,0 +1,141 @@
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
public struct DropZoneView: View {
|
||||
@State private var isDragging = false
|
||||
@State private var uploadResult: UploadResult?
|
||||
@State private var error: String?
|
||||
@State private var isUploading = false
|
||||
@State private var uploadProgress: Double = 0
|
||||
|
||||
public let uploadManager: UploadManager
|
||||
|
||||
public init(uploadManager: UploadManager) {
|
||||
self.uploadManager = uploadManager
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
Image(systemName: "arrow.up.doc.fill")
|
||||
.font(.system(size: 48))
|
||||
.foregroundColor(isDragging ? .blue : .secondary)
|
||||
|
||||
Text("Drop files here")
|
||||
.font(.title2)
|
||||
|
||||
if isUploading {
|
||||
ProgressView(value: uploadProgress)
|
||||
.frame(width: 180)
|
||||
Text("\(Int(uploadProgress * 100))%")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
} else if let result = uploadResult {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 32))
|
||||
.foregroundColor(.green)
|
||||
|
||||
Text("Uploaded \(result.filename)")
|
||||
.font(.headline)
|
||||
|
||||
Text("URL copied to clipboard")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text(result.url.absoluteString)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.blue)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
} else if let error = error {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.system(size: 32))
|
||||
.foregroundColor(.red)
|
||||
|
||||
Text("Upload failed")
|
||||
.font(.headline)
|
||||
|
||||
Text(error)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: 300, height: 250)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(isDragging ? Color.blue.opacity(0.1) : Color.secondary.opacity(0.1))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.strokeBorder(isDragging ? Color.blue : Color.secondary.opacity(0.3), style: StrokeStyle(lineWidth: 2, dash: [8]))
|
||||
)
|
||||
)
|
||||
.onDrop(of: [.fileURL], isTargeted: $isDragging) { providers in
|
||||
guard let provider = providers.first else { return false }
|
||||
|
||||
_ = provider.loadObject(ofClass: URL.self) { url, error in
|
||||
guard let url = url else { return }
|
||||
|
||||
Task {
|
||||
await MainActor.run {
|
||||
isUploading = true
|
||||
uploadProgress = 0
|
||||
self.error = nil
|
||||
self.uploadResult = nil
|
||||
}
|
||||
|
||||
do {
|
||||
let result = try await uploadFileResolvingDuplicates(url, onProgress: { progress in
|
||||
Task { @MainActor in
|
||||
uploadProgress = min(max(progress, 0), 1)
|
||||
}
|
||||
})
|
||||
await MainActor.run {
|
||||
uploadResult = result
|
||||
isUploading = false
|
||||
uploadProgress = 0
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
self.error = error.localizedDescription
|
||||
isUploading = false
|
||||
uploadProgress = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private func uploadFileResolvingDuplicates(
|
||||
_ url: URL,
|
||||
onProgress: UploadProgressHandler?
|
||||
) async throws -> UploadResult {
|
||||
var duplicateAction: UploadDuplicateAction?
|
||||
|
||||
while true {
|
||||
do {
|
||||
return try await uploadManager.uploadFile(
|
||||
url,
|
||||
duplicateAction: duplicateAction,
|
||||
onProgress: onProgress
|
||||
)
|
||||
} catch let error as APIError where error.isUploadConflict {
|
||||
guard let action = promptForUploadDuplicate(error, filename: url.lastPathComponent) else {
|
||||
throw UploadCancelledError()
|
||||
}
|
||||
duplicateAction = action
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
// This preview won't work without a real UploadManager
|
||||
Text("DropZoneView Preview")
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
public struct ClipboardNotice: Identifiable, Equatable {
|
||||
public let id = UUID()
|
||||
public let filename: String
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public class MenuBarController: NSObject, ObservableObject, NSWindowDelegate {
|
||||
@Published public var config: SyncConfig
|
||||
@Published public var isSyncing = false
|
||||
@Published public var lastSyncTime: Date?
|
||||
@Published public var syncStatus: String = "Ready"
|
||||
@Published public var pendingChanges = 0
|
||||
@Published public var uploadProgress: Double? = nil
|
||||
@Published public private(set) var recentUploads: [AttachmentRecord] = []
|
||||
@Published public private(set) var isLoadingRecentUploads = false
|
||||
@Published public private(set) var recentUploadsError: String?
|
||||
@Published public private(set) var clipboardNotice: ClipboardNotice?
|
||||
|
||||
public var cancellables = Set<AnyCancellable>()
|
||||
|
||||
private let configStore = ConfigStore.shared
|
||||
private var apiClient: APIClient
|
||||
private var syncEngine: SyncEngine?
|
||||
private var uploadManager: UploadManager
|
||||
private var folderWatcher: FolderWatcher?
|
||||
private var syncTimer: Timer?
|
||||
|
||||
private var settingsWindow: NSWindow?
|
||||
private var uploadsWindow: NSWindow?
|
||||
private var dropZoneWindow: NSWindow?
|
||||
|
||||
public override init() {
|
||||
let config = ConfigStore.shared.load()
|
||||
self.config = config
|
||||
self.apiClient = APIClient(baseURL: config.serverBaseURL ?? URL(string: "http://localhost:8080")!, apiToken: config.apiToken)
|
||||
self.uploadManager = UploadManager(client: apiClient, config: config)
|
||||
self.syncEngine = SyncEngine(client: apiClient, config: config)
|
||||
super.init()
|
||||
|
||||
Task {
|
||||
await setupSync()
|
||||
}
|
||||
Task {
|
||||
await refreshRecentUploads()
|
||||
}
|
||||
}
|
||||
|
||||
public func updateConfig(_ newConfig: SyncConfig) {
|
||||
config = newConfig
|
||||
|
||||
Task {
|
||||
await configStore.save(newConfig)
|
||||
|
||||
if let url = newConfig.serverBaseURL {
|
||||
await apiClient.setAPIToken(newConfig.apiToken)
|
||||
apiClient = APIClient(baseURL: url, apiToken: newConfig.apiToken)
|
||||
uploadManager = UploadManager(client: apiClient, config: newConfig)
|
||||
syncEngine = SyncEngine(client: apiClient, config: newConfig)
|
||||
await refreshRecentUploads()
|
||||
await setupSync()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func setupSync() async {
|
||||
// Stop existing watcher
|
||||
folderWatcher?.stop()
|
||||
syncTimer?.invalidate()
|
||||
|
||||
guard config.autoSync, let syncFolder = config.syncFolder else {
|
||||
syncStatus = "Auto-sync disabled"
|
||||
return
|
||||
}
|
||||
|
||||
// Setup folder watcher
|
||||
let watcher = FolderWatcher(folderPath: syncFolder)
|
||||
watcher.delegate = self
|
||||
watcher.start()
|
||||
self.folderWatcher = watcher
|
||||
|
||||
// Setup periodic sync
|
||||
syncTimer = Timer.scheduledTimer(withTimeInterval: TimeInterval(config.syncInterval), repeats: true) { [weak self] _ in
|
||||
Task { [weak self] in
|
||||
await self?.performSync()
|
||||
}
|
||||
}
|
||||
|
||||
// Initial sync
|
||||
await performSync()
|
||||
}
|
||||
|
||||
public func performSync() async {
|
||||
guard !isSyncing else { return }
|
||||
guard config.syncFolder != nil else {
|
||||
syncStatus = "No sync folder configured"
|
||||
return
|
||||
}
|
||||
|
||||
isSyncing = true
|
||||
syncStatus = "Syncing..."
|
||||
|
||||
do {
|
||||
let engine = syncEngine ?? SyncEngine(client: apiClient, config: config)
|
||||
syncEngine = engine
|
||||
try await engine.performSync()
|
||||
|
||||
lastSyncTime = Date()
|
||||
syncStatus = "Synced"
|
||||
} catch {
|
||||
syncStatus = "Sync failed: \(error.localizedDescription)"
|
||||
}
|
||||
|
||||
isSyncing = false
|
||||
}
|
||||
|
||||
// MARK: - Window Management
|
||||
|
||||
public func showSettings() {
|
||||
if let window = settingsWindow {
|
||||
present(window)
|
||||
return
|
||||
}
|
||||
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 520, height: 640),
|
||||
styleMask: [.titled, .closable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
window.title = "Cairnquire Settings"
|
||||
window.titlebarAppearsTransparent = true
|
||||
window.isReleasedWhenClosed = false
|
||||
window.delegate = self
|
||||
window.contentView = NSHostingView(rootView: SettingsView(config: config, onSave: { [weak self, weak window] newConfig in
|
||||
self?.updateConfig(newConfig)
|
||||
window?.close()
|
||||
}))
|
||||
window.center()
|
||||
|
||||
settingsWindow = window
|
||||
present(window)
|
||||
}
|
||||
|
||||
public func showUploads() {
|
||||
if let window = uploadsWindow {
|
||||
present(window)
|
||||
return
|
||||
}
|
||||
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 400, height: 500),
|
||||
styleMask: [.titled, .closable, .miniaturizable, .resizable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
window.title = "Uploaded Files"
|
||||
window.isReleasedWhenClosed = false
|
||||
window.delegate = self
|
||||
window.contentView = NSHostingView(rootView: UploadsListView(client: apiClient))
|
||||
window.center()
|
||||
|
||||
uploadsWindow = window
|
||||
present(window)
|
||||
}
|
||||
|
||||
public func showDropZone() {
|
||||
if let window = dropZoneWindow {
|
||||
present(window)
|
||||
return
|
||||
}
|
||||
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 300, height: 250),
|
||||
styleMask: [.titled, .closable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
window.title = "Upload File"
|
||||
window.isReleasedWhenClosed = false
|
||||
window.delegate = self
|
||||
window.contentView = NSHostingView(rootView: DropZoneView(uploadManager: uploadManager))
|
||||
window.center()
|
||||
|
||||
dropZoneWindow = window
|
||||
present(window)
|
||||
}
|
||||
|
||||
public func windowWillClose(_ notification: Notification) {
|
||||
guard let window = notification.object as? NSWindow else { return }
|
||||
|
||||
if window === settingsWindow {
|
||||
settingsWindow = nil
|
||||
} else if window === uploadsWindow {
|
||||
uploadsWindow = nil
|
||||
} else if window === dropZoneWindow {
|
||||
dropZoneWindow = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func present(_ window: NSWindow) {
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
}
|
||||
|
||||
public func quickUpload() {
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseFiles = true
|
||||
panel.canChooseDirectories = false
|
||||
panel.allowsMultipleSelection = true
|
||||
|
||||
guard panel.runModal() == .OK else { return }
|
||||
|
||||
Task {
|
||||
await uploadURLList(panel.urls)
|
||||
}
|
||||
}
|
||||
|
||||
public func uploadFiles(_ urls: [URL]) {
|
||||
Task {
|
||||
await uploadURLList(urls)
|
||||
}
|
||||
}
|
||||
|
||||
private func uploadURLList(_ urls: [URL]) async {
|
||||
guard !urls.isEmpty else { return }
|
||||
|
||||
let total = Double(urls.count)
|
||||
for (index, url) in urls.enumerated() {
|
||||
let baseProgress = Double(index) / total
|
||||
let fileWeight = 1 / total
|
||||
uploadProgress = baseProgress
|
||||
syncStatus = "Uploading \(url.lastPathComponent)"
|
||||
|
||||
let onProgress: UploadProgressHandler = { [weak self] fileProgress in
|
||||
let combinedProgress = baseProgress + min(max(fileProgress, 0), 1) * fileWeight
|
||||
Task { @MainActor in
|
||||
self?.uploadProgress = combinedProgress
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
let result = try await uploadFileResolvingDuplicates(url, onProgress: onProgress)
|
||||
uploadProgress = Double(index + 1) / total
|
||||
syncStatus = "Uploaded \(result.filename)"
|
||||
clipboardNotice = ClipboardNotice(filename: result.filename)
|
||||
await refreshRecentUploads()
|
||||
} catch {
|
||||
syncStatus = "Upload failed: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
uploadProgress = nil
|
||||
}
|
||||
|
||||
private func uploadFileResolvingDuplicates(
|
||||
_ url: URL,
|
||||
onProgress: UploadProgressHandler?
|
||||
) async throws -> UploadResult {
|
||||
var duplicateAction: UploadDuplicateAction?
|
||||
|
||||
while true {
|
||||
do {
|
||||
return try await uploadManager.uploadFile(
|
||||
url,
|
||||
duplicateAction: duplicateAction,
|
||||
onProgress: onProgress
|
||||
)
|
||||
} catch let error as APIError where error.isUploadConflict {
|
||||
guard let action = promptForUploadDuplicate(error, filename: url.lastPathComponent) else {
|
||||
throw UploadCancelledError()
|
||||
}
|
||||
duplicateAction = action
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func refreshRecentUploads() async {
|
||||
guard !isLoadingRecentUploads else { return }
|
||||
|
||||
isLoadingRecentUploads = true
|
||||
defer { isLoadingRecentUploads = false }
|
||||
|
||||
do {
|
||||
recentUploads = Array(try await apiClient.listAttachments().prefix(3))
|
||||
recentUploadsError = nil
|
||||
} catch {
|
||||
recentUploadsError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
public func createNoteFromClipboard() {
|
||||
guard let text = NSPasteboard.general.string(forType: .string) else {
|
||||
syncStatus = "Clipboard is empty"
|
||||
return
|
||||
}
|
||||
|
||||
Task {
|
||||
do {
|
||||
let result = try await uploadManager.createNote(from: text)
|
||||
syncStatus = "Created note: \(result.title)"
|
||||
} catch {
|
||||
syncStatus = "Failed to create note: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension MenuBarController: FolderWatcherDelegate {
|
||||
public nonisolated func folderWatcher(_ watcher: FolderWatcher, didDetectChanges paths: [String]) {
|
||||
Task { @MainActor in
|
||||
pendingChanges += paths.count
|
||||
syncStatus = "\(pendingChanges) pending changes"
|
||||
await performSync()
|
||||
pendingChanges = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
245
apps/macos-sync/Sources/CairnquireSync/UI/SettingsView.swift
Normal file
245
apps/macos-sync/Sources/CairnquireSync/UI/SettingsView.swift
Normal file
@@ -0,0 +1,245 @@
|
||||
import SwiftUI
|
||||
|
||||
public struct SettingsView: View {
|
||||
@State public var config: SyncConfig
|
||||
public var onSave: (SyncConfig) -> Void
|
||||
|
||||
@State private var serverURL: String = ""
|
||||
@State private var apiToken: String = ""
|
||||
@State private var syncFolder: String = ""
|
||||
@State private var defaultUploadFolder: String = ""
|
||||
@State private var autoSync: Bool = true
|
||||
@State private var syncInterval: Double = 30
|
||||
@State private var selectedIcon: String = "tray.circle.fill"
|
||||
|
||||
private let labelWidth: CGFloat = 120
|
||||
|
||||
public init(config: SyncConfig, onSave: @escaping (SyncConfig) -> Void) {
|
||||
self.config = config
|
||||
self.onSave = onSave
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
serverSection
|
||||
Divider().padding(.vertical, 16)
|
||||
syncSection
|
||||
Divider().padding(.vertical, 16)
|
||||
uploadsSection
|
||||
Divider().padding(.vertical, 16)
|
||||
appearanceSection
|
||||
|
||||
Spacer(minLength: 32)
|
||||
|
||||
saveButton
|
||||
}
|
||||
.padding(24)
|
||||
}
|
||||
.frame(width: 520, height: 600)
|
||||
.onAppear {
|
||||
serverURL = config.serverURL
|
||||
apiToken = config.apiToken ?? ""
|
||||
syncFolder = config.syncFolder ?? ""
|
||||
defaultUploadFolder = config.defaultUploadFolder ?? ""
|
||||
autoSync = config.autoSync
|
||||
syncInterval = Double(config.syncInterval)
|
||||
selectedIcon = config.iconName
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sections
|
||||
|
||||
private var serverSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
sectionHeader("Server", icon: "server.rack")
|
||||
|
||||
formRow(label: "Server URL") {
|
||||
TextField("", text: $serverURL)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 280)
|
||||
}
|
||||
|
||||
formRow(label: "API Token") {
|
||||
SecureField("", text: $apiToken)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 280)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
.frame(width: labelWidth)
|
||||
Text("Create an API token from the web app at /account")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(nil)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.frame(width: 280, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var syncSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
sectionHeader("Sync", icon: "arrow.triangle.2.circlepath")
|
||||
|
||||
formRow(label: "Sync Folder") {
|
||||
HStack(spacing: 8) {
|
||||
TextField("", text: $syncFolder)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 200)
|
||||
|
||||
Button("Choose...") {
|
||||
chooseFolder()
|
||||
}
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
|
||||
formRow(label: "") {
|
||||
Toggle("Auto Sync", isOn: $autoSync)
|
||||
}
|
||||
|
||||
if autoSync {
|
||||
formRow(label: "Interval") {
|
||||
HStack(spacing: 12) {
|
||||
Slider(value: $syncInterval, in: 5...300, step: 5)
|
||||
.frame(width: 160)
|
||||
Text("\(Int(syncInterval))s")
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
.frame(width: 40, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var uploadsSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
sectionHeader("Uploads", icon: "arrow.up.doc")
|
||||
|
||||
formRow(label: "Upload Folder") {
|
||||
TextField("e.g. inbox", text: $defaultUploadFolder)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 280)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
.frame(width: labelWidth)
|
||||
Text("Notes and uploads will be saved to this folder")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.frame(width: 280, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var appearanceSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
sectionHeader("Appearance", icon: "paintbrush")
|
||||
|
||||
formRow(label: "Menu Icon") {
|
||||
Picker("", selection: $selectedIcon) {
|
||||
ForEach(SyncConfig.iconOptions, id: \.self) { icon in
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: icon)
|
||||
.frame(width: 20, alignment: .center)
|
||||
Text(iconNameToLabel(icon))
|
||||
}
|
||||
.tag(icon)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.frame(width: 180)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var saveButton: some View {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button {
|
||||
let newConfig = SyncConfig(
|
||||
serverURL: serverURL,
|
||||
apiToken: apiToken.isEmpty ? nil : apiToken,
|
||||
syncFolder: syncFolder.isEmpty ? nil : syncFolder,
|
||||
defaultUploadFolder: defaultUploadFolder.isEmpty ? nil : defaultUploadFolder,
|
||||
autoSync: autoSync,
|
||||
syncInterval: Int(syncInterval),
|
||||
deviceId: config.deviceId,
|
||||
iconName: selectedIcon
|
||||
)
|
||||
onSave(newConfig)
|
||||
} label: {
|
||||
Text("Save")
|
||||
.frame(width: 80)
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.controlSize(.large)
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func sectionHeader(_ title: String, icon: String) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: icon)
|
||||
.foregroundColor(.accentColor)
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
Text(title)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
Spacer()
|
||||
}
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
|
||||
private func formRow<Content: View>(label: String, @ViewBuilder content: () -> Content) -> some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 16) {
|
||||
Text(label)
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(.primary)
|
||||
.frame(width: labelWidth, alignment: .trailing)
|
||||
|
||||
content()
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
private func chooseFolder() {
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseFiles = false
|
||||
panel.canChooseDirectories = true
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.prompt = "Select Folder"
|
||||
|
||||
if panel.runModal() == .OK {
|
||||
syncFolder = panel.url?.path ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
private func iconNameToLabel(_ name: String) -> String {
|
||||
let labels: [String: String] = [
|
||||
"tray.circle.fill": "Tray (Default)",
|
||||
"arrow.triangle.2.circlepath": "Sync",
|
||||
"arrow.up.doc": "Upload",
|
||||
"doc.badge.arrow.up": "Doc Upload",
|
||||
"arrow.up.and.down.and.arrow.left.and.right": "Transfer",
|
||||
"cloud": "Cloud",
|
||||
"externaldrive.connected.to.line.below": "Server",
|
||||
"note.text": "Notes",
|
||||
"doc.text": "Documents",
|
||||
"arrow.clockwise.circle": "Refresh",
|
||||
"rectangle.stack.badge.plus": "Stack"
|
||||
]
|
||||
return labels[name] ?? name
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
SettingsView(config: SyncConfig()) { _ in }
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
import SwiftUI
|
||||
|
||||
public struct StatusPopoverView: View {
|
||||
@ObservedObject public var controller: MenuBarController
|
||||
|
||||
public init(controller: MenuBarController) {
|
||||
self.controller = controller
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Header with status
|
||||
headerSection
|
||||
|
||||
Divider()
|
||||
|
||||
// Quick actions
|
||||
actionsSection
|
||||
|
||||
Divider()
|
||||
|
||||
// Recent uploads preview (last 3)
|
||||
recentUploadsSection
|
||||
|
||||
Divider()
|
||||
|
||||
// Footer
|
||||
footerSection
|
||||
}
|
||||
.frame(width: 320)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
|
||||
// MARK: - Sections
|
||||
|
||||
private var headerSection: some View {
|
||||
VStack(spacing: 8) {
|
||||
HStack {
|
||||
statusIndicator
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(controller.syncStatus)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
|
||||
if let lastSync = controller.lastSyncTime {
|
||||
Text("Last sync: \(timeAgo(lastSync))")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
Text("Never synced")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if controller.isSyncing {
|
||||
ProgressView()
|
||||
.scaleEffect(0.6)
|
||||
}
|
||||
}
|
||||
|
||||
if controller.pendingChanges > 0 {
|
||||
Text("\(controller.pendingChanges) pending changes")
|
||||
.font(.caption)
|
||||
.foregroundColor(.orange)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
|
||||
private var statusIndicator: some View {
|
||||
Circle()
|
||||
.fill(statusColor)
|
||||
.frame(width: 8, height: 8)
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(statusColor.opacity(0.3), lineWidth: 2)
|
||||
.frame(width: 14, height: 14)
|
||||
)
|
||||
}
|
||||
|
||||
private var statusColor: Color {
|
||||
if controller.isSyncing { return .blue }
|
||||
if controller.syncStatus.contains("fail") || controller.syncStatus.contains("error") {
|
||||
return .red
|
||||
}
|
||||
if controller.pendingChanges > 0 { return .orange }
|
||||
return .green
|
||||
}
|
||||
|
||||
private var actionsSection: some View {
|
||||
HStack(spacing: 16) {
|
||||
ActionButton(
|
||||
icon: "arrow.clockwise",
|
||||
label: "Sync",
|
||||
color: .blue
|
||||
) {
|
||||
Task {
|
||||
await controller.performSync()
|
||||
}
|
||||
}
|
||||
|
||||
ActionButton(
|
||||
icon: "arrow.up.doc",
|
||||
label: "Upload",
|
||||
color: .purple
|
||||
) {
|
||||
controller.quickUpload()
|
||||
}
|
||||
|
||||
ActionButton(
|
||||
icon: "doc.badge.plus",
|
||||
label: "Note",
|
||||
color: .green
|
||||
) {
|
||||
controller.createNoteFromClipboard()
|
||||
}
|
||||
|
||||
ActionButton(
|
||||
icon: "square.and.arrow.down",
|
||||
label: "Drop",
|
||||
color: .orange
|
||||
) {
|
||||
controller.showDropZone()
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var recentUploadsSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text("Recent Uploads")
|
||||
.font(.caption)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("View All") {
|
||||
controller.showUploads()
|
||||
}
|
||||
.font(.caption)
|
||||
.buttonStyle(.plain)
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
|
||||
if controller.isLoadingRecentUploads && controller.recentUploads.isEmpty {
|
||||
ProgressView()
|
||||
.scaleEffect(0.6)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.vertical, 8)
|
||||
} else if controller.recentUploads.isEmpty {
|
||||
Text(controller.recentUploadsError == nil ? "No recent uploads" : "Unable to load recent uploads")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.vertical, 8)
|
||||
} else {
|
||||
ForEach(controller.recentUploads) { upload in
|
||||
RecentUploadRow(upload: upload, baseURL: controller.config.serverBaseURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
private var footerSection: some View {
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
controller.showSettings()
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "gear")
|
||||
Text("Settings")
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Button {
|
||||
NSApp.terminate(nil)
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "power")
|
||||
.font(.caption2)
|
||||
Text("Quit")
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Spacer()
|
||||
|
||||
if let url = controller.config.serverBaseURL {
|
||||
Button {
|
||||
NSWorkspace.shared.open(url)
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Text("Open Server")
|
||||
Image(systemName: "arrow.up.right.square")
|
||||
.font(.caption2)
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func timeAgo(_ date: Date) -> String {
|
||||
let formatter = RelativeDateTimeFormatter()
|
||||
formatter.unitsStyle = .abbreviated
|
||||
return formatter.localizedString(for: date, relativeTo: Date())
|
||||
}
|
||||
}
|
||||
|
||||
private struct RecentUploadRow: View {
|
||||
let upload: AttachmentRecord
|
||||
let baseURL: URL?
|
||||
|
||||
var body: some View {
|
||||
Button(action: openUpload) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: upload.kind == "document" ? "doc.text" : "paperclip")
|
||||
.foregroundColor(.accentColor)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(upload.originalName)
|
||||
.font(.caption)
|
||||
.lineLimit(1)
|
||||
Text(timeAgo(upload.createdAt))
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "arrow.up.right.square")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private func openUpload() {
|
||||
guard let baseURL,
|
||||
let url = URL(string: upload.url, relativeTo: baseURL)?.absoluteURL else {
|
||||
return
|
||||
}
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
|
||||
private func timeAgo(_ date: Date) -> String {
|
||||
let formatter = RelativeDateTimeFormatter()
|
||||
formatter.unitsStyle = .abbreviated
|
||||
return formatter.localizedString(for: date, relativeTo: Date())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Action Button
|
||||
|
||||
struct ActionButton: View {
|
||||
let icon: String
|
||||
let label: String
|
||||
let color: Color
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
VStack(spacing: 6) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 20, weight: .medium))
|
||||
.foregroundColor(color)
|
||||
.frame(width: 40, height: 40)
|
||||
.background(color.opacity(0.1))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
|
||||
Text(label)
|
||||
.font(.caption2)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
// Preview won't work without a real controller
|
||||
Text("StatusPopoverView Preview")
|
||||
}
|
||||
244
apps/macos-sync/Sources/CairnquireSync/UI/UploadsListView.swift
Normal file
244
apps/macos-sync/Sources/CairnquireSync/UI/UploadsListView.swift
Normal file
@@ -0,0 +1,244 @@
|
||||
import SwiftUI
|
||||
|
||||
public struct UploadsListView: View {
|
||||
@State private var attachments: [AttachmentRecord] = []
|
||||
@State private var isLoading = false
|
||||
@State private var error: String?
|
||||
|
||||
public let client: APIClient
|
||||
|
||||
public init(client: APIClient) {
|
||||
self.client = client
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Header
|
||||
HStack {
|
||||
Text("Uploaded Files")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: loadAttachments) {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
.font(.system(size: 13))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isLoading)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 16)
|
||||
|
||||
Divider()
|
||||
|
||||
// Content
|
||||
if isLoading {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
.scaleEffect(0.8)
|
||||
Spacer()
|
||||
} else if let error = error {
|
||||
Spacer()
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.font(.system(size: 32))
|
||||
.foregroundColor(.orange)
|
||||
Text("Failed to load uploads")
|
||||
.font(.headline)
|
||||
Text(error)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.padding()
|
||||
Spacer()
|
||||
} else if attachments.isEmpty {
|
||||
Spacer()
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "tray")
|
||||
.font(.system(size: 40))
|
||||
.foregroundColor(.secondary.opacity(0.5))
|
||||
Text("No uploads yet")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundColor(.secondary)
|
||||
Text("Drop files on the menubar icon to upload")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary.opacity(0.7))
|
||||
}
|
||||
Spacer()
|
||||
} else {
|
||||
List(attachments) { attachment in
|
||||
AttachmentRow(attachment: attachment, client: client)
|
||||
.listRowSeparator(.visible)
|
||||
.listRowInsets(EdgeInsets(top: 8, leading: 20, bottom: 8, trailing: 20))
|
||||
}
|
||||
.listStyle(.plain)
|
||||
}
|
||||
}
|
||||
.frame(width: 400, height: 500)
|
||||
.onAppear {
|
||||
loadAttachments()
|
||||
}
|
||||
}
|
||||
|
||||
private func loadAttachments() {
|
||||
isLoading = true
|
||||
error = nil
|
||||
|
||||
Task {
|
||||
do {
|
||||
let items = try await client.listAttachments()
|
||||
await MainActor.run {
|
||||
attachments = items
|
||||
isLoading = false
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
self.error = error.localizedDescription
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AttachmentRow: View {
|
||||
let attachment: AttachmentRecord
|
||||
let client: APIClient
|
||||
|
||||
@State private var showingCopied = false
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
// File icon
|
||||
fileIcon
|
||||
|
||||
// Info
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(attachment.originalName)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.lineLimit(1)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Text(attachment.contentType)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text("·")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary.opacity(0.5))
|
||||
|
||||
Text(formatBytes(attachment.sizeBytes))
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text("·")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary.opacity(0.5))
|
||||
|
||||
Text(formatDate(attachment.createdAt))
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Copy button
|
||||
Button(action: copyURL) {
|
||||
Image(systemName: showingCopied ? "checkmark" : "doc.on.doc")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(showingCopied ? .green : .accentColor)
|
||||
.frame(width: 28, height: 28)
|
||||
.background(showingCopied ? Color.green.opacity(0.1) : Color.accentColor.opacity(0.1))
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.contextMenu {
|
||||
Button("Copy URL") {
|
||||
copyURL()
|
||||
}
|
||||
Button("Open in Browser") {
|
||||
if let url = URL(string: attachment.url, relativeTo: client.baseURL)?.absoluteURL {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var fileIcon: some View {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(iconColor.opacity(0.1))
|
||||
.frame(width: 36, height: 36)
|
||||
|
||||
Image(systemName: iconName)
|
||||
.font(.system(size: 16))
|
||||
.foregroundColor(iconColor)
|
||||
}
|
||||
}
|
||||
|
||||
private var iconName: String {
|
||||
switch attachment.contentType {
|
||||
case let type where type.hasPrefix("image/"):
|
||||
return "photo"
|
||||
case let type where type.hasPrefix("video/"):
|
||||
return "film"
|
||||
case let type where type.hasPrefix("audio/"):
|
||||
return "music.note"
|
||||
case "application/pdf":
|
||||
return "doc.text"
|
||||
case let type where type.hasPrefix("text/"):
|
||||
return "doc.plaintext"
|
||||
default:
|
||||
return "doc"
|
||||
}
|
||||
}
|
||||
|
||||
private var iconColor: Color {
|
||||
switch attachment.contentType {
|
||||
case let type where type.hasPrefix("image/"):
|
||||
return .purple
|
||||
case let type where type.hasPrefix("video/"):
|
||||
return .red
|
||||
case let type where type.hasPrefix("audio/"):
|
||||
return .pink
|
||||
case "application/pdf":
|
||||
return .red
|
||||
case let type where type.hasPrefix("text/"):
|
||||
return .blue
|
||||
default:
|
||||
return .gray
|
||||
}
|
||||
}
|
||||
|
||||
private func copyURL() {
|
||||
let url = URL(string: attachment.url, relativeTo: client.baseURL)?.absoluteURL
|
||||
?? client.baseURL.appendingPathComponent("attachments/\(attachment.hash)")
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(url.absoluteString, forType: .string)
|
||||
|
||||
showingCopied = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
||||
showingCopied = false
|
||||
}
|
||||
}
|
||||
|
||||
private func formatBytes(_ bytes: Int) -> String {
|
||||
let formatter = ByteCountFormatter()
|
||||
formatter.countStyle = .file
|
||||
return formatter.string(fromByteCount: Int64(bytes))
|
||||
}
|
||||
|
||||
private func formatDate(_ date: Date) -> String {
|
||||
let formatter = RelativeDateTimeFormatter()
|
||||
formatter.unitsStyle = .abbreviated
|
||||
return formatter.localizedString(for: date, relativeTo: Date())
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
Text("UploadsListView Preview")
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
|
||||
public actor UploadManager {
|
||||
private let client: APIClient
|
||||
private let config: SyncConfig
|
||||
|
||||
public init(client: APIClient, config: SyncConfig) {
|
||||
self.client = client
|
||||
self.config = config
|
||||
}
|
||||
|
||||
public func uploadFile(
|
||||
_ url: URL,
|
||||
duplicateAction: UploadDuplicateAction? = nil,
|
||||
onProgress: UploadProgressHandler? = nil
|
||||
) async throws -> UploadResult {
|
||||
let response = try await client.uploadFile(
|
||||
url: url,
|
||||
folder: config.defaultUploadFolder,
|
||||
duplicateAction: duplicateAction,
|
||||
onProgress: onProgress
|
||||
)
|
||||
let fullURL = resolvedURL(response.url ?? response.attachmentUrl)
|
||||
|
||||
// Copy URL to clipboard
|
||||
await MainActor.run {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(fullURL.absoluteString, forType: .string)
|
||||
}
|
||||
|
||||
return UploadResult(
|
||||
filename: url.lastPathComponent,
|
||||
hash: response.hash,
|
||||
url: fullURL,
|
||||
copiedToClipboard: true
|
||||
)
|
||||
}
|
||||
|
||||
public func uploadData(
|
||||
_ data: Data,
|
||||
filename: String,
|
||||
duplicateAction: UploadDuplicateAction? = nil,
|
||||
onProgress: UploadProgressHandler? = nil
|
||||
) async throws -> UploadResult {
|
||||
let response = try await client.uploadData(
|
||||
data,
|
||||
filename: filename,
|
||||
folder: config.defaultUploadFolder,
|
||||
duplicateAction: duplicateAction,
|
||||
onProgress: onProgress
|
||||
)
|
||||
let fullURL = resolvedURL(response.url ?? response.attachmentUrl)
|
||||
|
||||
// Copy URL to clipboard
|
||||
await MainActor.run {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(fullURL.absoluteString, forType: .string)
|
||||
}
|
||||
|
||||
return UploadResult(
|
||||
filename: filename,
|
||||
hash: response.hash,
|
||||
url: fullURL,
|
||||
copiedToClipboard: true
|
||||
)
|
||||
}
|
||||
|
||||
public func createNote(from text: String, title: String? = nil) async throws -> DocumentSaveResponse {
|
||||
let noteTitle = title ?? generateTitle(from: text)
|
||||
let folder = config.defaultUploadFolder ?? ""
|
||||
let path = folder.isEmpty ? "\(sanitizeFilename(noteTitle)).md" : "\(folder)/\(sanitizeFilename(noteTitle)).md"
|
||||
|
||||
let content = "# \(noteTitle)\n\n\(text)"
|
||||
|
||||
return try await client.saveDocument(path: path, content: content)
|
||||
}
|
||||
|
||||
private func generateTitle(from text: String) -> String {
|
||||
let lines = text.split(separator: "\n", omittingEmptySubsequences: true)
|
||||
if let firstLine = lines.first {
|
||||
let title = String(firstLine).trimmingCharacters(in: .whitespaces)
|
||||
if title.count > 60 {
|
||||
return String(title.prefix(60)) + "..."
|
||||
}
|
||||
return title
|
||||
}
|
||||
return "Note \(ISO8601DateFormatter().string(from: Date()))"
|
||||
}
|
||||
|
||||
private func sanitizeFilename(_ name: String) -> String {
|
||||
let invalidChars = CharacterSet(charactersIn: ":/\\?%*|\"<>").union(.controlCharacters)
|
||||
return name.components(separatedBy: invalidChars).joined(separator: "-")
|
||||
}
|
||||
|
||||
private func resolvedURL(_ path: String) -> URL {
|
||||
URL(string: path, relativeTo: client.baseURL)?.absoluteURL ?? client.baseURL.appendingPathComponent(path)
|
||||
}
|
||||
}
|
||||
|
||||
public struct UploadResult {
|
||||
public let filename: String
|
||||
public let hash: String
|
||||
public let url: URL
|
||||
public let copiedToClipboard: Bool
|
||||
}
|
||||
|
||||
public struct UploadCancelledError: LocalizedError {
|
||||
public init() {}
|
||||
|
||||
public var errorDescription: String? { "Upload cancelled" }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public func promptForUploadDuplicate(_ error: APIError, filename: String) -> UploadDuplicateAction? {
|
||||
let alert = NSAlert()
|
||||
alert.alertStyle = .warning
|
||||
|
||||
if error.conflictType == "document" {
|
||||
alert.messageText = "A document named \(filename) already exists"
|
||||
alert.informativeText = "Choose whether to replace the inbox document or keep both files."
|
||||
alert.addButton(withTitle: "Keep Both")
|
||||
alert.addButton(withTitle: "Replace")
|
||||
alert.addButton(withTitle: "Cancel")
|
||||
|
||||
switch alert.runModal() {
|
||||
case .alertFirstButtonReturn:
|
||||
return .rename
|
||||
case .alertSecondButtonReturn:
|
||||
return .overwrite
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
alert.messageText = "\(filename) was already uploaded"
|
||||
alert.informativeText = "The server already has the same file content. Use its existing URL?"
|
||||
alert.addButton(withTitle: "Copy Existing URL")
|
||||
alert.addButton(withTitle: "Cancel")
|
||||
return alert.runModal() == .alertFirstButtonReturn ? .reuse : nil
|
||||
}
|
||||
374
apps/macos-sync/Sources/CairnquireSyncApp/App.swift
Normal file
374
apps/macos-sync/Sources/CairnquireSyncApp/App.swift
Normal file
@@ -0,0 +1,374 @@
|
||||
import SwiftUI
|
||||
import Combine
|
||||
import CairnquireSync
|
||||
|
||||
@main
|
||||
struct CairnquireSyncApp: App {
|
||||
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||
|
||||
var body: some Scene {
|
||||
Settings {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
var statusItem: NSStatusItem!
|
||||
var controller: MenuBarController!
|
||||
var popover: NSPopover!
|
||||
var contextMenu: NSMenu!
|
||||
var dropButton: DropStatusButton!
|
||||
private var clipboardHUDPanel: NSPanel?
|
||||
private var clipboardHUDDismissWorkItem: DispatchWorkItem?
|
||||
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
controller = MenuBarController()
|
||||
|
||||
setupStatusItem()
|
||||
setupPopover()
|
||||
setupContextMenu()
|
||||
|
||||
// Observe config changes to update icon
|
||||
controller.$config
|
||||
.sink { [weak self] config in
|
||||
self?.updateIcon(config: config)
|
||||
}
|
||||
.store(in: &controller.cancellables)
|
||||
|
||||
// Observe upload progress to animate icon
|
||||
controller.$uploadProgress
|
||||
.sink { [weak self] progress in
|
||||
self?.updateIconForUpload(progress: progress)
|
||||
}
|
||||
.store(in: &controller.cancellables)
|
||||
|
||||
controller.$clipboardNotice
|
||||
.compactMap { $0 }
|
||||
.sink { [weak self] notice in
|
||||
self?.showClipboardHUD(notice)
|
||||
}
|
||||
.store(in: &controller.cancellables)
|
||||
|
||||
// Hide dock icon
|
||||
NSApp.setActivationPolicy(.accessory)
|
||||
}
|
||||
|
||||
private func setupStatusItem() {
|
||||
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
|
||||
|
||||
// Create custom drop-aware button
|
||||
let button = DropStatusButton()
|
||||
button.imagePosition = .imageOnly
|
||||
button.target = self
|
||||
button.action = #selector(statusItemButtonClicked(_:))
|
||||
button.sendAction(on: [.leftMouseUp, .rightMouseUp])
|
||||
button.appDelegate = self
|
||||
|
||||
// Replace the default button with our custom one
|
||||
if let oldButton = statusItem.button {
|
||||
oldButton.superview?.addSubview(button)
|
||||
button.frame = oldButton.frame
|
||||
button.autoresizingMask = [.width, .height]
|
||||
oldButton.isHidden = true
|
||||
}
|
||||
|
||||
dropButton = button
|
||||
updateIcon(config: controller.config)
|
||||
}
|
||||
|
||||
func updateIcon(config: SyncConfig) {
|
||||
guard controller.uploadProgress == nil else { return }
|
||||
let iconName = config.iconName
|
||||
let image = NSImage(systemSymbolName: iconName, accessibilityDescription: "Cairnquire")
|
||||
?? NSImage(systemSymbolName: "tray.circle.fill", accessibilityDescription: "Cairnquire")
|
||||
dropButton.image = image
|
||||
}
|
||||
|
||||
private func updateIconForUpload(progress: Double?) {
|
||||
if progress != nil {
|
||||
// During upload: show tray.circle (outline)
|
||||
let image = NSImage(systemSymbolName: "tray.circle", accessibilityDescription: "Uploading")
|
||||
dropButton.image = image
|
||||
} else {
|
||||
// Upload complete: restore normal icon
|
||||
updateIcon(config: controller.config)
|
||||
}
|
||||
}
|
||||
|
||||
func bounceIcon() {
|
||||
guard let button = dropButton else { return }
|
||||
|
||||
let originalOrigin = button.frame.origin
|
||||
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = 0.12
|
||||
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
||||
button.animator().setFrameOrigin(NSPoint(
|
||||
x: originalOrigin.x,
|
||||
y: originalOrigin.y - 6
|
||||
))
|
||||
} completionHandler: {
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = 0.15
|
||||
context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
|
||||
button.animator().setFrameOrigin(originalOrigin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func showClipboardHUD(_ notice: ClipboardNotice) {
|
||||
clipboardHUDDismissWorkItem?.cancel()
|
||||
|
||||
let panel = clipboardHUDPanel ?? makeClipboardHUDPanel()
|
||||
panel.contentView = NSHostingView(rootView: ClipboardHUDView(filename: notice.filename))
|
||||
positionClipboardHUD(panel)
|
||||
panel.alphaValue = 0
|
||||
panel.orderFrontRegardless()
|
||||
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = 0.15
|
||||
panel.animator().alphaValue = 1
|
||||
}
|
||||
|
||||
let dismissWorkItem = DispatchWorkItem { [weak self, weak panel] in
|
||||
guard let self, let panel else { return }
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = 0.2
|
||||
panel.animator().alphaValue = 0
|
||||
} completionHandler: {
|
||||
panel.orderOut(nil)
|
||||
}
|
||||
self.clipboardHUDDismissWorkItem = nil
|
||||
}
|
||||
clipboardHUDDismissWorkItem = dismissWorkItem
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: dismissWorkItem)
|
||||
}
|
||||
|
||||
private func makeClipboardHUDPanel() -> NSPanel {
|
||||
let panel = NSPanel(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 280, height: 72),
|
||||
styleMask: [.borderless, .nonactivatingPanel],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
panel.level = .floating
|
||||
panel.isOpaque = false
|
||||
panel.backgroundColor = .clear
|
||||
panel.hasShadow = true
|
||||
panel.hidesOnDeactivate = false
|
||||
panel.collectionBehavior = [.canJoinAllSpaces, .transient, .ignoresCycle]
|
||||
clipboardHUDPanel = panel
|
||||
return panel
|
||||
}
|
||||
|
||||
private func positionClipboardHUD(_ panel: NSPanel) {
|
||||
guard let buttonWindow = dropButton.window else { return }
|
||||
let buttonFrame = buttonWindow.convertToScreen(dropButton.convert(dropButton.bounds, to: nil))
|
||||
let panelSize = panel.frame.size
|
||||
let visibleFrame = buttonWindow.screen?.visibleFrame ?? NSScreen.main?.visibleFrame ?? .zero
|
||||
let centeredX = buttonFrame.midX - panelSize.width / 2
|
||||
let origin = NSPoint(
|
||||
x: min(max(centeredX, visibleFrame.minX + 8), visibleFrame.maxX - panelSize.width - 8),
|
||||
y: buttonFrame.minY - panelSize.height - 8
|
||||
)
|
||||
panel.setFrameOrigin(origin)
|
||||
}
|
||||
|
||||
private func setupPopover() {
|
||||
popover = NSPopover()
|
||||
popover.behavior = .transient
|
||||
popover.contentViewController = NSHostingController(
|
||||
rootView: StatusPopoverView(controller: controller)
|
||||
)
|
||||
}
|
||||
|
||||
private func setupContextMenu() {
|
||||
contextMenu = NSMenu()
|
||||
|
||||
contextMenu.addItem(NSMenuItem(title: "Sync Now", action: #selector(syncNow), keyEquivalent: "s"))
|
||||
contextMenu.addItem(NSMenuItem(title: "Upload File...", action: #selector(uploadFile), keyEquivalent: "u"))
|
||||
contextMenu.addItem(NSMenuItem(title: "Create Note from Clipboard", action: #selector(createNote), keyEquivalent: "n"))
|
||||
contextMenu.addItem(NSMenuItem(title: "Drop Zone", action: #selector(showDropZone), keyEquivalent: "d"))
|
||||
contextMenu.addItem(NSMenuItem(title: "View Uploads", action: #selector(showUploads), keyEquivalent: "v"))
|
||||
|
||||
contextMenu.addItem(NSMenuItem.separator())
|
||||
|
||||
contextMenu.addItem(NSMenuItem(title: "Settings...", action: #selector(showSettings), keyEquivalent: ","))
|
||||
contextMenu.addItem(NSMenuItem(title: "Quit", action: #selector(quit), keyEquivalent: "q"))
|
||||
}
|
||||
|
||||
@objc private func statusItemButtonClicked(_ sender: AnyObject?) {
|
||||
guard let event = NSApp.currentEvent else { return }
|
||||
|
||||
switch event.type {
|
||||
case .rightMouseUp:
|
||||
statusItem.menu = contextMenu
|
||||
statusItem.button?.performClick(nil)
|
||||
statusItem.menu = nil
|
||||
default:
|
||||
togglePopover()
|
||||
}
|
||||
}
|
||||
|
||||
private func togglePopover() {
|
||||
if popover.isShown {
|
||||
closePopover()
|
||||
} else {
|
||||
showPopover()
|
||||
}
|
||||
}
|
||||
|
||||
private func showPopover() {
|
||||
guard let button = dropButton else { return }
|
||||
Task { @MainActor in
|
||||
await controller.refreshRecentUploads()
|
||||
}
|
||||
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
|
||||
|
||||
NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self] event in
|
||||
guard let self = self, self.popover.isShown else { return }
|
||||
self.closePopover()
|
||||
}
|
||||
}
|
||||
|
||||
private func closePopover() {
|
||||
popover.performClose(nil)
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
@objc private func syncNow() {
|
||||
Task { @MainActor in
|
||||
await controller.performSync()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func uploadFile() {
|
||||
Task { @MainActor in
|
||||
controller.quickUpload()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func createNote() {
|
||||
Task { @MainActor in
|
||||
controller.createNoteFromClipboard()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func showDropZone() {
|
||||
Task { @MainActor in
|
||||
controller.showDropZone()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func showUploads() {
|
||||
Task { @MainActor in
|
||||
controller.showUploads()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func showSettings() {
|
||||
Task { @MainActor in
|
||||
controller.showSettings()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func quit() {
|
||||
NSApp.terminate(nil)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ClipboardHUDView: View {
|
||||
let filename: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 26))
|
||||
.foregroundColor(.green)
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("URL Copied to Clipboard")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
Text(filename)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.frame(width: 280, height: 72)
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Drop Status Button
|
||||
|
||||
class DropStatusButton: NSButton {
|
||||
weak var appDelegate: AppDelegate?
|
||||
|
||||
override init(frame frameRect: NSRect) {
|
||||
super.init(frame: frameRect)
|
||||
setup()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
setup()
|
||||
}
|
||||
|
||||
private func setup() {
|
||||
registerForDraggedTypes([.fileURL])
|
||||
}
|
||||
|
||||
override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
|
||||
// Tint icon slightly to indicate drop zone is active
|
||||
if let image = self.image {
|
||||
self.image = image.tinted(with: .systemBlue)
|
||||
}
|
||||
return .copy
|
||||
}
|
||||
|
||||
override func draggingExited(_ sender: NSDraggingInfo?) {
|
||||
// Restore original icon
|
||||
if let appDelegate = appDelegate {
|
||||
appDelegate.updateIcon(config: appDelegate.controller.config)
|
||||
}
|
||||
}
|
||||
|
||||
override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
|
||||
let pasteboard = sender.draggingPasteboard
|
||||
guard let urls = pasteboard.readObjects(forClasses: [NSURL.self], options: nil) as? [URL],
|
||||
!urls.isEmpty else {
|
||||
return false
|
||||
}
|
||||
|
||||
appDelegate?.bounceIcon()
|
||||
|
||||
Task { @MainActor in
|
||||
appDelegate?.controller.uploadFiles(urls)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
extension NSImage {
|
||||
func tinted(with color: NSColor) -> NSImage {
|
||||
let image = self.copy() as! NSImage
|
||||
image.isTemplate = false
|
||||
|
||||
let rect = NSRect(origin: .zero, size: image.size)
|
||||
image.lockFocus()
|
||||
color.set()
|
||||
rect.fill(using: .sourceAtop)
|
||||
image.unlockFocus()
|
||||
|
||||
return image
|
||||
}
|
||||
}
|
||||
398
apps/macos-sync/Sources/cairnquire-cli/main.swift
Normal file
398
apps/macos-sync/Sources/cairnquire-cli/main.swift
Normal file
@@ -0,0 +1,398 @@
|
||||
import Foundation
|
||||
import Darwin
|
||||
import CairnquireSync
|
||||
|
||||
// MARK: - CLI
|
||||
|
||||
enum CLIError: Error {
|
||||
case invalidArguments
|
||||
case missingServerURL
|
||||
case missingToken
|
||||
case fileNotFound
|
||||
}
|
||||
|
||||
final class TerminalProgress: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var lastPercent = -1
|
||||
|
||||
func update(_ progress: Double) {
|
||||
let percent = min(max(Int(progress * 100), 0), 100)
|
||||
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
|
||||
guard percent != lastPercent else { return }
|
||||
lastPercent = percent
|
||||
print("\rUploading... \(percent)%", terminator: "")
|
||||
fflush(stdout)
|
||||
}
|
||||
|
||||
func finishLine() {
|
||||
print("")
|
||||
}
|
||||
}
|
||||
|
||||
func readClipboard() -> String? {
|
||||
let task = Process()
|
||||
task.launchPath = "/usr/bin/pbpaste"
|
||||
let pipe = Pipe()
|
||||
task.standardOutput = pipe
|
||||
do {
|
||||
try task.run()
|
||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
return String(data: data, encoding: .utf8)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
struct CLI {
|
||||
static func run() async {
|
||||
let args = Array(CommandLine.arguments.dropFirst())
|
||||
|
||||
do {
|
||||
try await execute(args: args)
|
||||
} catch {
|
||||
print("Error: \(error.localizedDescription)")
|
||||
exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
static func execute(args: [String]) async throws {
|
||||
guard let command = args.first else {
|
||||
printUsage()
|
||||
return
|
||||
}
|
||||
|
||||
switch command {
|
||||
case "upload", "up":
|
||||
try await uploadCommand(args: Array(args.dropFirst()))
|
||||
case "note", "n":
|
||||
try await noteCommand(args: Array(args.dropFirst()))
|
||||
case "sync":
|
||||
try await syncCommand(args: Array(args.dropFirst()))
|
||||
case "config":
|
||||
try await configCommand(args: Array(args.dropFirst()))
|
||||
case "help", "-h", "--help":
|
||||
printHelp(for: args.dropFirst().first)
|
||||
default:
|
||||
print("Unknown command: \(command)")
|
||||
printUsage()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Commands
|
||||
|
||||
static func uploadCommand(args: [String]) async throws {
|
||||
if args.contains("--help") || args.contains("-h") {
|
||||
printUploadUsage()
|
||||
return
|
||||
}
|
||||
guard let filePath = args.first else {
|
||||
printUploadUsage()
|
||||
throw CLIError.invalidArguments
|
||||
}
|
||||
|
||||
let config = ConfigStore.shared.load()
|
||||
guard let token = config.apiToken else {
|
||||
print("No API token configured. Run: cairnquire-cli config --token <token>")
|
||||
throw CLIError.missingToken
|
||||
}
|
||||
guard let baseURL = config.serverBaseURL else {
|
||||
print("No server URL configured. Run: cairnquire-cli config --server <url>")
|
||||
throw CLIError.missingServerURL
|
||||
}
|
||||
|
||||
let fileURL = URL(fileURLWithPath: filePath)
|
||||
guard FileManager.default.fileExists(atPath: filePath) else {
|
||||
print("File not found: \(filePath)")
|
||||
throw CLIError.fileNotFound
|
||||
}
|
||||
|
||||
let client = APIClient(baseURL: baseURL, apiToken: token)
|
||||
let manager = UploadManager(client: client, config: config)
|
||||
|
||||
print("Uploading \(fileURL.lastPathComponent)...")
|
||||
let progress = TerminalProgress()
|
||||
let result = try await uploadFileResolvingDuplicates(manager: manager, fileURL: fileURL, progress: progress)
|
||||
progress.finishLine()
|
||||
|
||||
print("✓ Uploaded successfully")
|
||||
print(" URL: \(result.url)")
|
||||
print(" Hash: \(result.hash)")
|
||||
print(" Copied to clipboard")
|
||||
}
|
||||
|
||||
static func noteCommand(args: [String]) async throws {
|
||||
if args.contains("--help") || args.contains("-h") {
|
||||
printNoteUsage()
|
||||
return
|
||||
}
|
||||
let config = ConfigStore.shared.load()
|
||||
guard let token = config.apiToken else {
|
||||
print("No API token configured. Run: cairnquire-cli config --token <token>")
|
||||
throw CLIError.missingToken
|
||||
}
|
||||
guard let baseURL = config.serverBaseURL else {
|
||||
print("No server URL configured. Run: cairnquire-cli config --server <url>")
|
||||
throw CLIError.missingServerURL
|
||||
}
|
||||
|
||||
let content: String
|
||||
if args.contains("--clipboard") || args.isEmpty {
|
||||
guard let clipboard = readClipboard() else {
|
||||
print("Clipboard is empty")
|
||||
throw CLIError.invalidArguments
|
||||
}
|
||||
content = clipboard
|
||||
} else {
|
||||
// Read from stdin or args
|
||||
if args.first == "-" {
|
||||
content = String(data: FileHandle.standardInput.availableData, encoding: .utf8) ?? ""
|
||||
} else {
|
||||
content = args.joined(separator: " ")
|
||||
}
|
||||
}
|
||||
|
||||
guard !content.isEmpty else {
|
||||
print("No content provided")
|
||||
throw CLIError.invalidArguments
|
||||
}
|
||||
|
||||
let title = args.first(where: { $0.hasPrefix("--title=") })?.replacingOccurrences(of: "--title=", with: "")
|
||||
|
||||
let client = APIClient(baseURL: baseURL, apiToken: token)
|
||||
let manager = UploadManager(client: client, config: config)
|
||||
|
||||
print("Creating note...")
|
||||
let result = try await manager.createNote(from: content, title: title)
|
||||
|
||||
print("✓ Note created")
|
||||
print(" Path: \(result.path)")
|
||||
print(" Title: \(result.title)")
|
||||
print(" Hash: \(result.hash)")
|
||||
}
|
||||
|
||||
static func syncCommand(args: [String]) async throws {
|
||||
if args.contains("--help") || args.contains("-h") {
|
||||
printSyncUsage()
|
||||
return
|
||||
}
|
||||
let config = ConfigStore.shared.load()
|
||||
guard let token = config.apiToken else {
|
||||
print("No API token configured")
|
||||
throw CLIError.missingToken
|
||||
}
|
||||
guard let baseURL = config.serverBaseURL else {
|
||||
print("No server URL configured")
|
||||
throw CLIError.missingServerURL
|
||||
}
|
||||
guard let syncFolder = config.syncFolder else {
|
||||
print("No sync folder configured")
|
||||
throw CLIError.invalidArguments
|
||||
}
|
||||
|
||||
let client = APIClient(baseURL: baseURL, apiToken: token)
|
||||
let engine = SyncEngine(client: client, config: config)
|
||||
|
||||
print("Syncing \(syncFolder)...")
|
||||
try await engine.performSync()
|
||||
print("✓ Sync complete")
|
||||
}
|
||||
|
||||
static func configCommand(args: [String]) async throws {
|
||||
if args.contains("--help") || args.contains("-h") {
|
||||
printConfigUsage()
|
||||
return
|
||||
}
|
||||
|
||||
var config = ConfigStore.shared.load()
|
||||
var i = 0
|
||||
while i < args.count {
|
||||
let arg = args[i]
|
||||
guard i + 1 < args.count else {
|
||||
print("Missing value for \(arg)")
|
||||
throw CLIError.invalidArguments
|
||||
}
|
||||
let value = args[i + 1]
|
||||
switch arg {
|
||||
case "--server":
|
||||
config.serverURL = value
|
||||
case "--token":
|
||||
config.apiToken = value
|
||||
case "--folder":
|
||||
config.syncFolder = value
|
||||
case "--upload-folder":
|
||||
config.defaultUploadFolder = value
|
||||
default:
|
||||
print("Unknown config option: \(arg)")
|
||||
throw CLIError.invalidArguments
|
||||
}
|
||||
i += 2
|
||||
}
|
||||
|
||||
await ConfigStore.shared.save(config)
|
||||
|
||||
print("Configuration saved:")
|
||||
print(" Server: \(config.serverURL)")
|
||||
print(" Sync Folder: \(config.syncFolder ?? "(none)")")
|
||||
print(" Upload Folder: \(config.defaultUploadFolder ?? "(none)")")
|
||||
print(" Token: \(config.apiToken != nil ? "(set)" : "(not set)")")
|
||||
}
|
||||
|
||||
static func uploadFileResolvingDuplicates(
|
||||
manager: UploadManager,
|
||||
fileURL: URL,
|
||||
progress: TerminalProgress
|
||||
) async throws -> UploadResult {
|
||||
var duplicateAction: UploadDuplicateAction?
|
||||
|
||||
while true {
|
||||
do {
|
||||
return try await manager.uploadFile(
|
||||
fileURL,
|
||||
duplicateAction: duplicateAction,
|
||||
onProgress: { value in progress.update(value) }
|
||||
)
|
||||
} catch let error as APIError where error.isUploadConflict {
|
||||
progress.finishLine()
|
||||
duplicateAction = try promptForDuplicate(error, filename: fileURL.lastPathComponent)
|
||||
print("Uploading \(fileURL.lastPathComponent)...")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func promptForDuplicate(_ error: APIError, filename: String) throws -> UploadDuplicateAction {
|
||||
if error.conflictType == "document" {
|
||||
print("A document named \(filename) already exists in the inbox.")
|
||||
if let existingPath = error.existingPath, !existingPath.isEmpty {
|
||||
print("Existing document: \(existingPath)")
|
||||
}
|
||||
print("[k] Keep both [r] Replace existing [c] Cancel")
|
||||
print("Choice: ", terminator: "")
|
||||
fflush(stdout)
|
||||
|
||||
switch readLine()?.lowercased() {
|
||||
case "k", "keep", "keep-both":
|
||||
return .rename
|
||||
case "r", "replace", "overwrite":
|
||||
return .overwrite
|
||||
default:
|
||||
throw UploadCancelledError()
|
||||
}
|
||||
}
|
||||
|
||||
print("\(filename) was already uploaded.")
|
||||
if let existingURL = error.existingUrl, !existingURL.isEmpty {
|
||||
print("Existing URL: \(existingURL)")
|
||||
}
|
||||
print("[u] Use existing URL [c] Cancel")
|
||||
print("Choice: ", terminator: "")
|
||||
fflush(stdout)
|
||||
|
||||
switch readLine()?.lowercased() {
|
||||
case "u", "use", "reuse":
|
||||
return .reuse
|
||||
default:
|
||||
throw UploadCancelledError()
|
||||
}
|
||||
}
|
||||
|
||||
static func printUsage() {
|
||||
print("""
|
||||
Cairnquire CLI - macOS sync and upload tool
|
||||
|
||||
USAGE:
|
||||
cairnquire-cli <command> [options]
|
||||
|
||||
COMMANDS:
|
||||
upload, up <file> Upload a file and copy URL to clipboard
|
||||
note, n [text] Create a new note from clipboard or arguments
|
||||
sync Sync the configured folder once
|
||||
config [options] Configure the shared app and CLI settings
|
||||
help [command] Show general or command-specific help
|
||||
|
||||
EXAMPLES:
|
||||
cairnquire-cli upload ~/Documents/report.pdf
|
||||
cairnquire-cli note --clipboard --title="Meeting Notes"
|
||||
echo "Hello World" | cairnquire-cli note -
|
||||
cairnquire-cli config --server http://localhost:8080 --token cq_pat_xxx
|
||||
cairnquire-cli sync
|
||||
|
||||
Run 'cairnquire-cli help <command>' for details.
|
||||
""")
|
||||
}
|
||||
|
||||
static func printHelp(for command: String?) {
|
||||
switch command {
|
||||
case "upload", "up":
|
||||
printUploadUsage()
|
||||
case "note", "n":
|
||||
printNoteUsage()
|
||||
case "sync":
|
||||
printSyncUsage()
|
||||
case "config":
|
||||
printConfigUsage()
|
||||
default:
|
||||
printUsage()
|
||||
}
|
||||
}
|
||||
|
||||
static func printUploadUsage() {
|
||||
print("""
|
||||
USAGE:
|
||||
cairnquire-cli upload <file>
|
||||
|
||||
Upload one file and copy its canonical Cairnquire URL to the clipboard.
|
||||
Readable text files become rendered documents. Other files remain attachments.
|
||||
If the inbox name or attachment content already exists, the CLI asks what to do.
|
||||
""")
|
||||
}
|
||||
|
||||
static func printNoteUsage() {
|
||||
print("""
|
||||
USAGE:
|
||||
cairnquire-cli note [--clipboard] [--title="Title"]
|
||||
cairnquire-cli note <text>
|
||||
cairnquire-cli note -
|
||||
|
||||
Create a markdown note from clipboard content, arguments, or stdin.
|
||||
""")
|
||||
}
|
||||
|
||||
static func printSyncUsage() {
|
||||
print("""
|
||||
USAGE:
|
||||
cairnquire-cli sync
|
||||
|
||||
Run one bidirectional sync for the folder configured with:
|
||||
cairnquire-cli config --folder <path>
|
||||
|
||||
The menubar app also watches this folder when auto-sync is enabled in Settings.
|
||||
""")
|
||||
}
|
||||
|
||||
static func printConfigUsage() {
|
||||
print("""
|
||||
USAGE:
|
||||
cairnquire-cli config [options]
|
||||
|
||||
OPTIONS:
|
||||
--server <url> Set the Cairnquire server URL
|
||||
--token <token> Set the API token
|
||||
--folder <path> Set the local folder used by sync
|
||||
--upload-folder <path> Set the server folder for readable uploads
|
||||
|
||||
The menubar app and CLI use the same saved configuration.
|
||||
""")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Entry Point
|
||||
|
||||
@main
|
||||
struct CairnquireCLI {
|
||||
static func main() async {
|
||||
await CLI.run()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user