Initial scaffold — NASBackup iOS app

Full project scaffold: XcodeGen project.yml, Core models/protocols/errors,
SMBService (kishikawakatsumi/SMBClient), SFTPService (Citadel 0.9.2),
PhotoLibraryService, LANMonitor, BackupEngine, BackgroundTaskManager,
all feature UIs (Login, Connect, Browse, Backup, History, Settings),
Shared components and theme. Builds clean with zero errors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Robin Kutesa
2026-05-15 17:05:04 +03:00
commit b96711c535
44 changed files with 3412 additions and 0 deletions

View File

@@ -0,0 +1,72 @@
import Foundation
import BackgroundTasks
final class BackgroundTaskManager {
static let backupTaskID = "com.albert.nasbackup.backup"
static let refreshTaskID = "com.albert.nasbackup.refresh"
static func registerTasks() {
BGTaskScheduler.shared.register(
forTaskWithIdentifier: backupTaskID,
using: nil
) { task in
guard let task = task as? BGProcessingTask else { return }
BackgroundTaskManager.handleBackupTask(task)
}
BGTaskScheduler.shared.register(
forTaskWithIdentifier: refreshTaskID,
using: nil
) { task in
guard let task = task as? BGAppRefreshTask else { return }
BackgroundTaskManager.handleRefreshTask(task)
}
}
static func scheduleBackup(delay: TimeInterval = 30) {
let request = BGProcessingTaskRequest(identifier: backupTaskID)
request.requiresNetworkConnectivity = true
request.requiresExternalPower = false
request.earliestBeginDate = Date(timeIntervalSinceNow: delay)
do {
try BGTaskScheduler.shared.submit(request)
} catch {
print("BGTask schedule failed: \(error)")
}
}
static func scheduleAppRefresh() {
let request = BGAppRefreshTaskRequest(identifier: refreshTaskID)
request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60)
do {
try BGTaskScheduler.shared.submit(request)
} catch {
print("BGRefresh schedule failed: \(error)")
}
}
private static func handleBackupTask(_ task: BGProcessingTask) {
scheduleBackup()
let engine = BackupEngine.shared
task.expirationHandler = { Task { await engine.cancel() } }
Task {
do {
let store = ConnectionStore.shared
guard let connection = store.savedConnection else {
task.setTaskCompleted(success: false)
return
}
let filter = store.backupFilter
_ = try await engine.run(connection: connection, filter: filter, triggeredByLAN: true)
task.setTaskCompleted(success: true)
} catch {
task.setTaskCompleted(success: false)
}
}
}
private static func handleRefreshTask(_ task: BGAppRefreshTask) {
scheduleAppRefresh()
task.setTaskCompleted(success: true)
}
}

153
Services/BackupEngine.swift Normal file
View File

@@ -0,0 +1,153 @@
import Foundation
import UserNotifications
@MainActor
final class BackupEngine: ObservableObject {
static let shared = BackupEngine()
@Published private(set) var job: BackupJob = BackupJob()
private let photoService: PhotoLibraryProtocol = PhotoLibraryService()
private var activeTransfer: (any NASTransferProtocol)?
private var isCancelled = false
private init() {}
func run(
connection: NASConnection,
filter: BackupFilter,
triggeredByLAN: Bool = false
) async throws -> BackupResult {
isCancelled = false
let startDate = Date()
// 1. Fetch assets
job.prepare()
let assets = photoService.fetchAssets(filter: filter)
// 2. Connect to NAS
let transfer: any NASTransferProtocol
switch connection.nasProtocol {
case .smb: transfer = SMBService()
case .sftp: transfer = SFTPService()
}
try await transfer.connect(
to: connection.host,
port: connection.port,
username: connection.username,
password: connection.password
)
activeTransfer = transfer
defer {
transfer.disconnect()
activeTransfer = nil
}
// 3. Start job tracking
job.start(totalFiles: assets.count, totalBytes: 0)
var uploaded = 0
var skipped = 0
var failed = 0
var totalBytes: Int64 = 0
var speedTracker = SpeedTracker()
// 4. Transfer loop
for asset in assets {
if isCancelled { break }
while case .paused = job.status {
try await Task.sleep(nanoseconds: 500_000_000)
}
let remotePath = "\(connection.remotePath)/\(asset.filename)"
// Skip if exists (newFilesOnly = true in filter)
if filter.newFilesOnly, (try? await transfer.fileExists(at: remotePath)) == true {
job.fileCompleted(skipped: true)
skipped += 1
continue
}
do {
let localURL = try await photoService.exportAsset(asset)
defer { try? FileManager.default.removeItem(at: localURL) }
let fileSize = (try? localURL.resourceValues(forKeys: [.fileSizeKey]).fileSize).flatMap { Int64($0) } ?? 0
try await transfer.upload(localURL: localURL, remotePath: remotePath) { sent, total in
let speed = speedTracker.update(bytesSent: sent)
Task { @MainActor [weak self] in
self?.job.updateProgress(
fileName: asset.filename,
fileSize: total,
bytesTransferred: totalBytes + sent,
speed: speed
)
}
}
totalBytes += fileSize
job.fileCompleted(skipped: false)
uploaded += 1
} catch {
job.fileFailed()
failed += 1
}
}
let duration = Date().timeIntervalSince(startDate)
let result = BackupResult(
uploadedCount: uploaded,
skippedCount: skipped,
failedCount: failed,
duration: duration,
totalBytes: totalBytes,
date: startDate
)
job.finish(result: result)
// 5. Persist history
let entry = BackupHistoryEntry(result: result, nasHost: connection.host, triggeredByLAN: triggeredByLAN)
ConnectionStore.shared.appendHistoryEntry(entry)
// 6. Push notification
await sendCompletionNotification(result: result)
return result
}
func cancel() {
isCancelled = true
job.cancel()
}
func pause() { job.pause() }
func resume() { job.resume() }
private func sendCompletionNotification(result: BackupResult) async {
let content = UNMutableNotificationContent()
content.title = result.hasErrors ? "Backup completed with errors" : "Backup complete"
content.body = "\(result.uploadedCount) uploaded · \(result.skippedCount) skipped · \(result.failedCount) errors"
content.sound = .default
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
try? await UNUserNotificationCenter.current().add(request)
}
}
private struct SpeedTracker {
private var lastBytes: Int64 = 0
private var lastTime: Date = Date()
mutating func update(bytesSent: Int64) -> Double {
let now = Date()
let elapsed = now.timeIntervalSince(lastTime)
guard elapsed > 0.1 else { return 0 }
let speed = Double(bytesSent - lastBytes) / elapsed
lastBytes = bytesSent
lastTime = now
return max(0, speed)
}
}

59
Services/LANMonitor.swift Normal file
View File

@@ -0,0 +1,59 @@
import Foundation
import Network
import NetworkExtension
import Combine
@MainActor
final class LANMonitor: ObservableObject {
static let shared = LANMonitor()
@Published private(set) var isOnNetwork: Bool = false
@Published private(set) var currentSSID: String? = nil
private let monitor = NWPathMonitor()
private let queue = DispatchQueue(label: "com.albert.nasbackup.lan", qos: .utility)
var onTrustedNetworkJoined: ((String) -> Void)?
private init() {}
func start() {
monitor.pathUpdateHandler = { [weak self] path in
guard let self else { return }
Task { @MainActor in
let satisfied = path.status == .satisfied
self.isOnNetwork = satisfied
if satisfied {
await self.checkSSID()
} else {
self.currentSSID = nil
}
}
}
monitor.start(queue: queue)
}
func stop() {
monitor.cancel()
}
private func checkSSID() async {
let ssid = await fetchCurrentSSID()
self.currentSSID = ssid
}
func fetchCurrentSSID() async -> String? {
await withCheckedContinuation { continuation in
NEHotspotNetwork.fetchCurrent { network in
continuation.resume(returning: network?.ssid)
}
}
}
func checkAndTriggerIfTrusted(trustedSSIDs: [String]) async {
let ssid = await fetchCurrentSSID()
guard let ssid, trustedSSIDs.contains(ssid) else { return }
onTrustedNetworkJoined?(ssid)
}
}

View File

@@ -0,0 +1,115 @@
import Foundation
import Photos
final class PhotoLibraryService: PhotoLibraryProtocol {
var authorizationStatus: PHAuthorizationStatus {
PHPhotoLibrary.authorizationStatus(for: .readWrite)
}
func requestAuthorization() async -> PHAuthorizationStatus {
await PHPhotoLibrary.requestAuthorization(for: .readWrite)
}
func fetchAssets(filter: BackupFilter) -> [PhotoAsset] {
let options = PHFetchOptions()
options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: true)]
var subpredicates: [NSPredicate] = []
var mediaTypes: [PHAssetMediaType] = []
if filter.includePhotos { mediaTypes.append(.image) }
if filter.includeVideos { mediaTypes.append(.video) }
if !mediaTypes.isEmpty {
let typePredicate = NSPredicate(
format: "mediaType IN %@",
mediaTypes.map(\.rawValue)
)
subpredicates.append(typePredicate)
}
if !filter.includeScreenshots {
subpredicates.append(NSPredicate(
format: "NOT ((mediaSubtype & %d) != 0)",
PHAssetMediaSubtype.photoScreenshot.rawValue
))
}
if subpredicates.count > 1 {
options.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: subpredicates)
} else {
options.predicate = subpredicates.first
}
let result = PHAsset.fetchAssets(with: options)
var assets: [PhotoAsset] = []
result.enumerateObjects { asset, _, _ in
let isScreenshot = asset.mediaSubtypes.contains(.photoScreenshot)
let resources = PHAssetResource.assetResources(for: asset)
// RAW files are identified by an alternate photo resource (RAW+JPEG pairs)
let isRAW = resources.contains { $0.type == .alternatePhoto }
if isRAW && !filter.includeRAW { return }
let filename = resources.first?.originalFilename ?? "\(asset.localIdentifier).jpg"
assets.append(PhotoAsset(
localIdentifier: asset.localIdentifier,
filename: filename,
creationDate: asset.creationDate,
mediaType: asset.mediaType,
pixelWidth: asset.pixelWidth,
pixelHeight: asset.pixelHeight,
duration: asset.duration,
isScreenshot: isScreenshot,
isRAW: isRAW
))
}
return assets
}
func exportAsset(_ asset: PhotoAsset) async throws -> URL {
let fetchResult = PHAsset.fetchAssets(
withLocalIdentifiers: [asset.localIdentifier],
options: nil
)
guard let phAsset = fetchResult.firstObject else {
throw BackupError.uploadFailed(asset.filename, underlying: NSError(domain: "PhotoExport", code: -1))
}
let tmpURL = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
.appendingPathExtension((asset.filename as NSString).pathExtension)
return try await withCheckedThrowingContinuation { continuation in
if phAsset.mediaType == .video {
let options = PHVideoRequestOptions()
options.isNetworkAccessAllowed = true
PHImageManager.default().requestAVAsset(forVideo: phAsset, options: options) { avAsset, _, _ in
guard let urlAsset = avAsset as? AVURLAsset else {
continuation.resume(throwing: BackupError.uploadFailed(asset.filename, underlying: NSError(domain: "AVExport", code: -1)))
return
}
continuation.resume(returning: urlAsset.url)
}
} else {
let options = PHImageRequestOptions()
options.isNetworkAccessAllowed = true
options.version = .current
options.deliveryMode = .highQualityFormat
PHImageManager.default().requestImageDataAndOrientation(for: phAsset, options: options) { data, _, _, _ in
guard let data else {
continuation.resume(throwing: BackupError.uploadFailed(asset.filename, underlying: NSError(domain: "PhotoExport", code: -2)))
return
}
do {
try data.write(to: tmpURL)
continuation.resume(returning: tmpURL)
} catch {
continuation.resume(throwing: BackupError.uploadFailed(asset.filename, underlying: error))
}
}
}
}
}
}

100
Services/SFTPService.swift Normal file
View File

@@ -0,0 +1,100 @@
import Foundation
import NIO
import Citadel
final class SFTPService: NASTransferProtocol {
private var client: SSHClient?
private var sftp: SFTPClient?
private(set) var isConnected: Bool = false
func connect(to host: String, port: Int, username: String, password: String) async throws {
do {
let c = try await SSHClient.connect(
host: host,
port: port,
authenticationMethod: .passwordBased(username: username, password: password),
hostKeyValidator: .acceptAnything(),
reconnect: .never
)
let sftpClient = try await c.openSFTP()
self.client = c
self.sftp = sftpClient
self.isConnected = true
} catch {
throw BackupError.connectionFailed(host)
}
}
func disconnect() {
Task {
try? await sftp?.close()
client = nil
}
sftp = nil
isConnected = false
}
func listDirectory(at path: String) async throws -> [NASItem] {
guard let sftp else { throw BackupError.connectionFailed("Not connected") }
do {
let batches = try await sftp.listDirectory(atPath: path)
// listDirectory returns [SFTPMessage.Name]; each Name contains multiple SFTPPathComponent
let components = batches.flatMap { $0.components }
return components.compactMap { component -> NASItem? in
guard component.filename != "." && component.filename != ".." else { return nil }
let fullPath = path.hasSuffix("/")
? "\(path)\(component.filename)"
: "\(path)/\(component.filename)"
let isDir = component.attributes.permissions.map { ($0 & 0o170000) == 0o040000 } ?? false
return NASItem(
name: component.filename,
path: fullPath,
isDirectory: isDir,
size: Int64(component.attributes.size ?? 0),
modifiedDate: nil
)
}
} catch {
throw BackupError.directoryListFailed(path)
}
}
func createDirectory(at path: String) async throws {
guard let sftp else { throw BackupError.connectionFailed("Not connected") }
do {
try await sftp.createDirectory(atPath: path)
} catch {
throw BackupError.directoryCreateFailed(path)
}
}
func fileExists(at remotePath: String) async throws -> Bool {
guard let sftp else { throw BackupError.connectionFailed("Not connected") }
do {
_ = try await sftp.getAttributes(at: remotePath)
return true
} catch {
return false
}
}
func upload(localURL: URL, remotePath: String, progress: @escaping (Int64, Int64) -> Void) async throws {
guard let sftp else { throw BackupError.connectionFailed("Not connected") }
let filename = localURL.lastPathComponent
do {
let data = try Data(contentsOf: localURL)
let total = Int64(data.count)
progress(0, total)
try await sftp.withFile(
filePath: remotePath,
flags: [.write, .create, .truncate]
) { file in
var buffer = ByteBuffer(data: data)
try await file.write(buffer, at: 0)
}
progress(total, total)
} catch {
throw BackupError.uploadFailed(filename, underlying: error)
}
}
}

85
Services/SMBService.swift Normal file
View File

@@ -0,0 +1,85 @@
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 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 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)
}
}
}