import Foundation import SMBClient import os.log private let log = Logger(subsystem: "com.albert.nasbackup", category: "SMBService") 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 { log.info("SMB connecting — host=\(host, privacy: .public):\(port) user=\(username, privacy: .private)") let c = SMBClient(host: host, port: port) do { try await c.login(username: username, password: password) } catch { log.error("SMB connect failed — host=\(host, privacy: .public):\(port) error=\(error.localizedDescription, privacy: .public)") // Propagate a connection error rather than always claiming auth failed — // the real cause could be host unreachable, wrong port, or network timeout. let nsErr = error as NSError if nsErr.domain == NSURLErrorDomain || nsErr.code == NSURLErrorTimedOut || nsErr.code == NSURLErrorCannotConnectToHost || nsErr.code == NSURLErrorNetworkConnectionLost { throw BackupError.connectionFailed("\(host):\(port) — \(error.localizedDescription)") } throw BackupError.authenticationFailed } log.info("SMB auth OK — \(host, privacy: .public):\(port)") 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) log.info("SMB listDirectory — share=\(share, privacy: .public) path=\(rel.isEmpty ? "/" : rel, privacy: .public)") try await client.connectShare(share) let files = try await client.listDirectory(path: rel.isEmpty ? "" : rel) let items = 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 ) } log.info("SMB listDirectory result — \(items.count) items at \(path, privacy: .public)") return items } 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 writeData(_ data: Data, to remotePath: String) async throws { guard let client else { throw BackupError.connectionFailed("Not connected") } let (share, rel) = splitPath(remotePath) try await client.connectShare(share) let tmpURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString) try data.write(to: tmpURL) defer { try? FileManager.default.removeItem(at: tmpURL) } do { try await client.upload(localPath: tmpURL, remotePath: rel) { _, _, _ in } } catch { throw BackupError.uploadFailed(BackupManifest.remoteFilename, underlying: error) } } func deleteFile(at remotePath: String) async throws { guard let client else { throw BackupError.connectionFailed("Not connected") } let (share, rel) = splitPath(remotePath) try await client.connectShare(share) try await client.deleteFile(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) } } }