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,27 @@
import Foundation
enum BackupError: LocalizedError {
case connectionFailed(String)
case authenticationFailed
case photoLibraryDenied
case uploadFailed(String, underlying: Error)
case directoryListFailed(String)
case directoryCreateFailed(String)
case cancelled
case timeout
case networkUnavailable
var errorDescription: String? {
switch self {
case .connectionFailed(let host): return "Could not connect to \(host)"
case .authenticationFailed: return "Authentication failed — check username and password"
case .photoLibraryDenied: return "Photos access denied — update in iOS Settings"
case .uploadFailed(let file, _): return "Failed to upload \(file)"
case .directoryListFailed(let path):return "Could not list \(path)"
case .directoryCreateFailed(let p): return "Could not create folder at \(p)"
case .cancelled: return "Backup was cancelled"
case .timeout: return "Operation timed out"
case .networkUnavailable: return "Network not available"
}
}
}

View File

@@ -0,0 +1,90 @@
import Foundation
import Combine
enum BackupStatus: Equatable {
case idle
case preparing
case running
case paused
case completed
case failed(String)
case cancelled
var isActive: Bool {
switch self {
case .running, .paused, .preparing: return true
default: return false
}
}
}
@MainActor
final class BackupJob: ObservableObject {
@Published private(set) var status: BackupStatus = .idle
@Published private(set) var totalFiles: Int = 0
@Published private(set) var uploadedFiles: Int = 0
@Published private(set) var skippedFiles: Int = 0
@Published private(set) var failedFiles: Int = 0
@Published private(set) var currentFileName: String = ""
@Published private(set) var currentFileSizeBytes: Int64 = 0
@Published private(set) var bytesTransferred: Int64 = 0
@Published private(set) var totalBytes: Int64 = 0
@Published private(set) var speedBytesPerSecond: Double = 0
@Published private(set) var startDate: Date?
@Published private(set) var endDate: Date?
var progress: Double {
guard totalFiles > 0 else { return 0 }
return Double(uploadedFiles + skippedFiles + failedFiles) / Double(totalFiles)
}
var eta: TimeInterval? {
guard speedBytesPerSecond > 0, totalBytes > bytesTransferred else { return nil }
return Double(totalBytes - bytesTransferred) / speedBytesPerSecond
}
func prepare() { status = .preparing }
func start(totalFiles: Int, totalBytes: Int64) {
self.totalFiles = totalFiles
self.totalBytes = totalBytes
self.uploadedFiles = 0
self.skippedFiles = 0
self.failedFiles = 0
self.bytesTransferred = 0
self.startDate = Date()
self.endDate = nil
self.status = .running
}
func updateProgress(fileName: String, fileSize: Int64, bytesTransferred: Int64, speed: Double) {
self.currentFileName = fileName
self.currentFileSizeBytes = fileSize
self.bytesTransferred = bytesTransferred
self.speedBytesPerSecond = speed
}
func fileCompleted(skipped: Bool = false) {
if skipped { skippedFiles += 1 } else { uploadedFiles += 1 }
}
func fileFailed() { failedFiles += 1 }
func finish(result: BackupResult) {
endDate = Date()
status = result.failedCount == 0 ? .completed : .completed
}
func fail(error: BackupError) {
endDate = Date()
status = .failed(error.errorDescription ?? error.localizedDescription)
}
func cancel() {
endDate = Date()
status = .cancelled
}
func pause() { if case .running = status { status = .paused } }
func resume() { if case .paused = status { status = .running } }
}

View File

@@ -0,0 +1,41 @@
import Foundation
struct BackupResult {
let uploadedCount: Int
let skippedCount: Int
let failedCount: Int
let duration: TimeInterval
let totalBytes: Int64
let date: Date
var hasErrors: Bool { failedCount > 0 }
static func empty(date: Date = Date()) -> BackupResult {
BackupResult(uploadedCount: 0, skippedCount: 0, failedCount: 0,
duration: 0, totalBytes: 0, date: date)
}
}
struct BackupHistoryEntry: Codable, Identifiable {
let id: UUID
let date: Date
let uploadedCount: Int
let skippedCount: Int
let failedCount: Int
let durationSeconds: Double
let totalBytes: Int64
let nasHost: String
let triggeredByLAN: Bool
init(result: BackupResult, nasHost: String, triggeredByLAN: Bool) {
self.id = UUID()
self.date = result.date
self.uploadedCount = result.uploadedCount
self.skippedCount = result.skippedCount
self.failedCount = result.failedCount
self.durationSeconds = result.duration
self.totalBytes = result.totalBytes
self.nasHost = nasHost
self.triggeredByLAN = triggeredByLAN
}
}

View File

@@ -0,0 +1,42 @@
import Foundation
enum NASProtocol: String, Codable, CaseIterable {
case smb = "SMB"
case sftp = "SFTP"
var defaultPort: Int {
switch self {
case .smb: return 445
case .sftp: return 22
}
}
}
struct NASConnection: Codable, Identifiable, Equatable {
var id: UUID = UUID()
var host: String
var port: Int
var nasProtocol: NASProtocol
var username: String
var password: String
var remotePath: String
var displayName: String
init(
host: String,
port: Int? = nil,
nasProtocol: NASProtocol = .smb,
username: String,
password: String,
remotePath: String = "/",
displayName: String = ""
) {
self.host = host
self.port = port ?? nasProtocol.defaultPort
self.nasProtocol = nasProtocol
self.username = username
self.password = password
self.remotePath = remotePath
self.displayName = displayName.isEmpty ? host : displayName
}
}

View File

@@ -0,0 +1,25 @@
import Foundation
struct NASItem: Identifiable {
let id = UUID()
let name: String
let path: String
let isDirectory: Bool
let size: Int64
let modifiedDate: Date?
}
protocol NASTransferProtocol: AnyObject {
var isConnected: Bool { get }
func connect(to host: String, port: Int, username: String, password: String) async throws
func disconnect()
func listDirectory(at path: String) async throws -> [NASItem]
func createDirectory(at path: String) async throws
func fileExists(at remotePath: String) async throws -> Bool
func upload(
localURL: URL,
remotePath: String,
progress: @escaping (Int64, Int64) -> Void
) async throws
}

View File

@@ -0,0 +1,29 @@
import Foundation
import Photos
struct PhotoAsset {
let localIdentifier: String
let filename: String
let creationDate: Date?
let mediaType: PHAssetMediaType
let pixelWidth: Int
let pixelHeight: Int
let duration: TimeInterval
let isScreenshot: Bool
let isRAW: Bool
}
protocol PhotoLibraryProtocol: AnyObject {
var authorizationStatus: PHAuthorizationStatus { get }
func requestAuthorization() async -> PHAuthorizationStatus
func fetchAssets(filter: BackupFilter) -> [PhotoAsset]
func exportAsset(_ asset: PhotoAsset) async throws -> URL
}
struct BackupFilter: Codable {
var includePhotos: Bool = true
var includeVideos: Bool = true
var includeScreenshots: Bool = false
var includeRAW: Bool = false
var newFilesOnly: Bool = true
}