Files
Kisani/Services/SMBService.swift
Robin Kutesa d2190bce8a Fix backup loop, NAS Archive regression, and manifest validity check
- AutoBackupCoordinator: add 55s debounce to onActive() so repeated .task
  and scenePhase triggers (navigation appear, rapid foreground cycles) are
  no-ops; start a 60s periodic recheck timer on first active call; stop timer
  and reset debounce on willResignActiveNotification so each new foreground
  session always gets an immediate fresh status check

- BackupView: remove the redundant lanMonitor.nasReachable onChange that was
  calling statusService.refresh() directly — coordinator already handles this
  via its Combine $nasReachable subscriber, avoiding a double reconcile that
  could bypass the autoBackupOnOpen gate with the wrong lastTriggerWasLAN value

- BackupStatusService: when NAS connect fails, restore nasArchiveTotal from
  NASManifestCache before writing the offline snapshot — prevents the count
  from showing 0 when the NAS is temporarily unreachable but the cache is warm

- BackupStatusService: manifest validity check — if the decoded manifest has
  ≤ 1 entries (corrupt or newly created), scan the NAS directory and use the
  real file count as the display floor for nasArchiveTotal without adding
  orphan entries to the manifest (that was the 7421 regression)

- BackupManifest: add ManifestIndex.init(manifest:overrideTotalCount:) for
  the validity check path — keeps localIdentifier matching intact while
  correcting the displayed archive count

- SMBService: add os.log around connect/auth/listDirectory with actual error
  reason instead of always surfacing authenticationFailed; distinguish network
  errors (timeout, host unreachable) from auth errors in thrown BackupError

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
2026-05-18 16:07:41 +03:00

138 lines
6.1 KiB
Swift

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