import Foundation import SMBClient final class SMBService: NASTransferProtocol { private var client: SMBClient? private(set) var isConnected: Bool = false // remotePath format: "/ShareName/optional/subpath" // splitPath returns ("ShareName", "optional/subpath") private func splitPath(_ path: String) -> (share: String, relativePath: String) { let trimmed = path.trimmingCharacters(in: .init(charactersIn: "/")) let parts = trimmed.split(separator: "/", maxSplits: 1, omittingEmptySubsequences: true) let share = parts.isEmpty ? "" : String(parts[0]) let rel = parts.count > 1 ? String(parts[1]) : "" return (share, rel) } func connect(to host: String, port: Int, username: String, password: String) async throws { let c = SMBClient(host: host, port: port) do { try await c.login(username: username, password: password) } catch { throw BackupError.authenticationFailed } self.client = c self.isConnected = true } func disconnect() { Task { try? await client?.logoff() } client = nil isConnected = false } func listShares() async throws -> [String] { guard let client else { throw BackupError.connectionFailed("Not connected") } let shares = try await client.listShares() return shares .filter { share in !share.name.hasSuffix("$") && !share.type.contains(.ipc) && !share.type.contains(.special) } .map { $0.name } } func listDirectory(at path: String) async throws -> [NASItem] { guard let client else { throw BackupError.connectionFailed("Not connected") } let (share, rel) = splitPath(path) try await client.connectShare(share) let files = try await client.listDirectory(path: rel.isEmpty ? "" : rel) return files.compactMap { file -> NASItem? in guard file.name != "." && file.name != ".." else { return nil } let fullPath = rel.isEmpty ? "/\(share)/\(file.name)" : "/\(share)/\(rel)/\(file.name)" return NASItem( name: file.name, path: fullPath, isDirectory: file.isDirectory, size: Int64(file.size), modifiedDate: file.lastWriteTime ) } } func createDirectory(at path: String) async throws { guard let client else { throw BackupError.connectionFailed("Not connected") } let (share, rel) = splitPath(path) try await client.connectShare(share) try await client.createDirectory(path: rel) } func fileExists(at remotePath: String) async throws -> Bool { guard let client else { throw BackupError.connectionFailed("Not connected") } let (share, rel) = splitPath(remotePath) try await client.connectShare(share) let dir = (rel as NSString).deletingLastPathComponent let filename = (rel as NSString).lastPathComponent let files = try await client.listDirectory(path: dir) return files.contains { $0.name == filename } } func downloadData(at remotePath: String) async throws -> Data { guard let client else { throw BackupError.connectionFailed("Not connected") } let (share, rel) = splitPath(remotePath) try await client.connectShare(share) return try await client.download(path: rel) } func upload(localURL: URL, remotePath: String, progress: @escaping (Int64, Int64) -> Void) async throws { guard let client else { throw BackupError.connectionFailed("Not connected") } let (share, rel) = splitPath(remotePath) try await client.connectShare(share) let filename = localURL.lastPathComponent do { try await client.upload(localPath: localURL, remotePath: rel) { _, _, bytesSent in let total = (try? localURL.resourceValues(forKeys: [.fileSizeKey]).fileSize) .flatMap { Int64($0) } ?? 0 progress(bytesSent, total) } } catch { throw BackupError.uploadFailed(filename, underlying: error) } } }