sync app filled in

This commit is contained in:
2026-06-01 10:53:28 -04:00
parent ebe0920f89
commit fb0673a1c5
36 changed files with 4527 additions and 98 deletions

View 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 {}

View 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
}

View 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)
}
}

View 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()
}
}

View 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")
}
}

View 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")
}

View File

@@ -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
}
}
}

View 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 }
}

View File

@@ -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")
}

View 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")
}

View File

@@ -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
}