commit b96711c53592316120116b102d1a20a72c46f11e Author: Robin Kutesa Date: Fri May 15 17:05:04 2026 +0300 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 diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..4327103 Binary files /dev/null and b/.DS_Store differ diff --git a/App/AppDelegate.swift b/App/AppDelegate.swift new file mode 100644 index 0000000..fa6f740 --- /dev/null +++ b/App/AppDelegate.swift @@ -0,0 +1,34 @@ +import UIKit +import BackgroundTasks +import UserNotifications + +final class AppDelegate: NSObject, UIApplicationDelegate { + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + BackgroundTaskManager.registerTasks() + requestNotificationPermission() + setupLANMonitor() + return true + } + + private func requestNotificationPermission() { + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { _, _ in } + } + + private func setupLANMonitor() { + let monitor = LANMonitor.shared + monitor.onTrustedNetworkJoined = { ssid in + let store = ConnectionStore.shared + guard store.lanTriggerEnabled, !store.isInQuietHours else { return } + let delay = TimeInterval(store.startDelaySeconds) + BackgroundTaskManager.scheduleBackup(delay: delay) + } + monitor.start() + } + + func applicationDidEnterBackground(_ application: UIApplication) { + BackgroundTaskManager.scheduleAppRefresh() + } +} diff --git a/App/Info.plist b/App/Info.plist new file mode 100644 index 0000000..b13f579 --- /dev/null +++ b/App/Info.plist @@ -0,0 +1,72 @@ + + + + + BGTaskSchedulerPermittedIdentifiers + + com.albert.nasbackup.backup + com.albert.nasbackup.refresh + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + NASBackup + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + NSFaceIDUsageDescription + Sign in quickly with Face ID. + NSLocationWhenInUseUsageDescription + Used to detect your home Wi-Fi network name for automatic backup. + NSPhotoLibraryUsageDescription + NASBackup needs access to your photos to back them up to your NAS. + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + + + + + UIBackgroundModes + + fetch + processing + + UILaunchStoryboardName + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/App/NASBackupApp.swift b/App/NASBackupApp.swift new file mode 100644 index 0000000..6dec0f9 --- /dev/null +++ b/App/NASBackupApp.swift @@ -0,0 +1,31 @@ +import SwiftUI + +@main +struct NASBackupApp: App { + @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + + @StateObject private var store = ConnectionStore.shared + @StateObject private var engine = BackupEngine.shared + @StateObject private var lanMonitor = LANMonitor.shared + + var body: some Scene { + WindowGroup { + RootView() + .environmentObject(store) + .environmentObject(engine) + .environmentObject(lanMonitor) + } + } +} + +struct RootView: View { + @EnvironmentObject var store: ConnectionStore + + var body: some View { + if store.savedConnection != nil { + LoginView() + } else { + ConnectView() + } + } +} diff --git a/App/SceneDelegate.swift b/App/SceneDelegate.swift new file mode 100644 index 0000000..a4bd533 --- /dev/null +++ b/App/SceneDelegate.swift @@ -0,0 +1,14 @@ +import UIKit +import SwiftUI + +final class SceneDelegate: NSObject, UIWindowSceneDelegate { + var window: UIWindow? + + func scene( + _ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions + ) { + // SwiftUI lifecycle handles window setup via NASBackupApp.swift + } +} diff --git a/Assets.xcassets/AppIcon.appiconset/Contents.json b/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..bb659f5 --- /dev/null +++ b/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,11 @@ +{ + "images" : [ + { "idiom" : "universal", "platform" : "ios", "scale" : "1x" }, + { "idiom" : "universal", "platform" : "ios", "scale" : "2x" }, + { "idiom" : "universal", "platform" : "ios", "scale" : "3x" } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Assets.xcassets/Contents.json b/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Assets.xcassets/nas_logo_clean.imageset/Contents.json b/Assets.xcassets/nas_logo_clean.imageset/Contents.json new file mode 100644 index 0000000..f695d75 --- /dev/null +++ b/Assets.xcassets/nas_logo_clean.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "nas_logo_clean.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Assets.xcassets/nas_logo_clean.imageset/nas_logo_clean.png b/Assets.xcassets/nas_logo_clean.imageset/nas_logo_clean.png new file mode 100644 index 0000000..7e512aa Binary files /dev/null and b/Assets.xcassets/nas_logo_clean.imageset/nas_logo_clean.png differ diff --git a/Core/Errors/BackupError.swift b/Core/Errors/BackupError.swift new file mode 100644 index 0000000..0f4aeff --- /dev/null +++ b/Core/Errors/BackupError.swift @@ -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" + } + } +} diff --git a/Core/Models/BackupJob.swift b/Core/Models/BackupJob.swift new file mode 100644 index 0000000..340638d --- /dev/null +++ b/Core/Models/BackupJob.swift @@ -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 } } +} diff --git a/Core/Models/BackupResult.swift b/Core/Models/BackupResult.swift new file mode 100644 index 0000000..806ad4a --- /dev/null +++ b/Core/Models/BackupResult.swift @@ -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 + } +} diff --git a/Core/Models/NASConnection.swift b/Core/Models/NASConnection.swift new file mode 100644 index 0000000..b959b8e --- /dev/null +++ b/Core/Models/NASConnection.swift @@ -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 + } +} diff --git a/Core/Protocols/NASTransferProtocol.swift b/Core/Protocols/NASTransferProtocol.swift new file mode 100644 index 0000000..e397752 --- /dev/null +++ b/Core/Protocols/NASTransferProtocol.swift @@ -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 +} diff --git a/Core/Protocols/PhotoLibraryProtocol.swift b/Core/Protocols/PhotoLibraryProtocol.swift new file mode 100644 index 0000000..3157ab6 --- /dev/null +++ b/Core/Protocols/PhotoLibraryProtocol.swift @@ -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 +} diff --git a/Features/Backup/BackupView.swift b/Features/Backup/BackupView.swift new file mode 100644 index 0000000..4dc2a61 --- /dev/null +++ b/Features/Backup/BackupView.swift @@ -0,0 +1,301 @@ +import SwiftUI +import Photos + +struct BackupView: View { + @EnvironmentObject var engine: BackupEngine + @EnvironmentObject var store: ConnectionStore + @StateObject private var vm = BackupViewModel() + + var body: some View { + ZStack { + Color.white.ignoresSafeArea() + + ScrollView { + VStack(spacing: 24) { + // Ring + counts + ringSection + + // NAS status + nasStatusRow + + // Options card + optionsCard + + // Start / pause button + actionButton + } + .padding(.horizontal, 20) + .padding(.top, 20) + .padding(.bottom, 40) + } + } + .task { + vm.loadPhotoCount(filter: store.backupFilter) + } + } + + // MARK: Ring + + private var ringSection: some View { + VStack(spacing: 16) { + ZStack { + ProgressRing( + progress: engine.job.progress, + lineWidth: 14, + size: 200, + color: ringColor + ) + + VStack(spacing: 4) { + Text(percentText) + .font(.system(size: 36, weight: .bold)) + .foregroundColor(AppTheme.textPrimary) + + if case .running = engine.job.status { + Text("\(engine.job.uploadedFiles + engine.job.skippedFiles) / \(engine.job.totalFiles)") + .font(AppTheme.bodyFont) + .foregroundColor(AppTheme.textSecondary) + if let eta = engine.job.eta { + Text("~\(etaString(eta))") + .font(AppTheme.captionFont) + .foregroundColor(AppTheme.textTertiary) + } + } else { + Text("\(vm.totalPhotoCount) photos") + .font(AppTheme.bodyFont) + .foregroundColor(AppTheme.textSecondary) + Text("0 backed up") + .font(AppTheme.captionFont) + .foregroundColor(AppTheme.textTertiary) + } + } + } + + // Completion summary + if case .completed = engine.job.status { + completionSummary + } + + // Progress detail row + if case .running = engine.job.status { + progressDetailRow + } + } + } + + private var ringColor: Color { + switch engine.job.status { + case .completed: return AppTheme.green + case .failed: return AppTheme.red + default: return AppTheme.blue + } + } + + private var percentText: String { + "\(Int(engine.job.progress * 100))%" + } + + // MARK: Progress detail + + private var progressDetailRow: some View { + VStack(spacing: 8) { + ProgressView(value: engine.job.progress) + .tint(AppTheme.blue) + + HStack { + Text(engine.job.currentFileName) + .font(AppTheme.captionFont) + .foregroundColor(AppTheme.textSecondary) + .lineLimit(1) + .truncationMode(.middle) + Spacer() + Text(formatSpeed(engine.job.speedBytesPerSecond)) + .font(AppTheme.captionFont) + .foregroundColor(AppTheme.textTertiary) + } + } + } + + // MARK: Completion + + private var completionSummary: some View { + HStack(spacing: 24) { + statPill(count: engine.job.uploadedFiles, label: "uploaded", color: AppTheme.green) + statPill(count: engine.job.skippedFiles, label: "skipped", color: AppTheme.textSecondary) + statPill(count: engine.job.failedFiles, label: "errors", color: engine.job.failedFiles > 0 ? AppTheme.red : AppTheme.textTertiary) + } + } + + private func statPill(count: Int, label: String, color: Color) -> some View { + VStack(spacing: 2) { + Text("\(count)") + .font(.system(size: 20, weight: .bold)) + .foregroundColor(color) + Text(label) + .font(AppTheme.captionFont) + .foregroundColor(AppTheme.textSecondary) + } + } + + // MARK: NAS status row + + private var nasStatusRow: some View { + HStack { + Image(systemName: "externaldrive.fill") + .foregroundColor(AppTheme.blue) + if let conn = store.savedConnection { + Text("\(conn.host) — \(conn.username)") + .font(AppTheme.bodyFont) + .foregroundColor(AppTheme.textPrimary) + } + Spacer() + HStack(spacing: 4) { + Circle().fill(AppTheme.green).frame(width: 8, height: 8) + Text("Live") + .font(AppTheme.captionFont) + .foregroundColor(AppTheme.green) + } + } + .padding(14) + .background(Color(hex: "#F9FAFB")) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(AppTheme.cardBorder)) + } + + // MARK: Options card + + private var optionsCard: some View { + VStack(spacing: 0) { + optionRow(icon: "camera", title: "What to back up") {} + Divider().padding(.leading, 44) + optionRow(icon: "doc.badge.clock", title: "New files only") {} + Divider().padding(.leading, 44) + + // Photos access row + Button(action: { Task { await vm.requestPhotosAccess() } }) { + HStack { + Image(systemName: "photo.on.rectangle") + .foregroundColor(vm.photosAuthStatus == .authorized ? AppTheme.blue : AppTheme.red) + .frame(width: 28) + Text("Photos access") + .font(AppTheme.bodyFont) + .foregroundColor(AppTheme.textPrimary) + Spacer() + Text(photosAccessLabel) + .font(AppTheme.captionFont) + .foregroundColor(vm.photosAuthStatus == .authorized ? AppTheme.green : AppTheme.red) + Image(systemName: "chevron.right") + .font(.system(size: 13)) + .foregroundColor(AppTheme.textTertiary) + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + } + } + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius)) + .overlay(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius).stroke(AppTheme.cardBorder)) + } + + private var photosAccessLabel: String { + switch vm.photosAuthStatus { + case .authorized: return "Full access" + case .limited: return "Limited" + case .denied: return "Denied" + default: return "Not set" + } + } + + private func optionRow(icon: String, title: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + HStack { + Image(systemName: icon) + .foregroundColor(AppTheme.blue) + .frame(width: 28) + Text(title) + .font(AppTheme.bodyFont) + .foregroundColor(AppTheme.textPrimary) + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 13)) + .foregroundColor(AppTheme.textTertiary) + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + } + } + + // MARK: Action button + + private var actionButton: some View { + Group { + switch engine.job.status { + case .idle, .completed, .failed, .cancelled: + Button(action: startBackup) { + Text(engine.job.status == .completed ? "Back up again" : "Start backup") + .font(.system(size: 17, weight: .semibold)) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .frame(height: 52) + .background(engine.job.status == .completed ? AppTheme.green : AppTheme.blue) + .clipShape(RoundedRectangle(cornerRadius: 14)) + } + case .running: + Button(action: { engine.pause() }) { + Text("Pause") + .font(.system(size: 17, weight: .semibold)) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .frame(height: 52) + .background(Color(hex: "#1E3A5F")) + .clipShape(RoundedRectangle(cornerRadius: 14)) + } + case .paused: + HStack(spacing: 12) { + Button(action: { engine.resume() }) { + Text("Resume") + .font(.system(size: 17, weight: .semibold)) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .frame(height: 52) + .background(AppTheme.blue) + .clipShape(RoundedRectangle(cornerRadius: 14)) + } + Button(action: { engine.cancel() }) { + Text("Cancel") + .font(.system(size: 17, weight: .semibold)) + .foregroundColor(AppTheme.red) + .frame(height: 52) + .frame(width: 90) + .overlay(RoundedRectangle(cornerRadius: 14).stroke(AppTheme.red)) + } + } + case .preparing: + HStack { + ProgressView() + Text("Preparing…") + .foregroundColor(AppTheme.textSecondary) + } + .frame(height: 52) + } + } + } + + private func startBackup() { + guard let conn = store.savedConnection else { return } + Task { + _ = try? await engine.run(connection: conn, filter: store.backupFilter) + } + } + + private func formatSpeed(_ bps: Double) -> String { + let mbps = bps / 1_000_000 + return mbps >= 1 ? String(format: "%.1f MB/s", mbps) : String(format: "%.0f KB/s", bps / 1000) + } + + private func etaString(_ seconds: TimeInterval) -> String { + let m = Int(seconds) / 60 + let s = Int(seconds) % 60 + return m > 0 ? "\(m) min" : "\(s)s" + } +} diff --git a/Features/Backup/BackupViewModel.swift b/Features/Backup/BackupViewModel.swift new file mode 100644 index 0000000..0219353 --- /dev/null +++ b/Features/Backup/BackupViewModel.swift @@ -0,0 +1,19 @@ +import Foundation +import Photos + +@MainActor +final class BackupViewModel: ObservableObject { + @Published var totalPhotoCount: Int = 0 + @Published var photosAuthStatus: PHAuthorizationStatus = PHPhotoLibrary.authorizationStatus(for: .readWrite) + + private let photoService = PhotoLibraryService() + + func loadPhotoCount(filter: BackupFilter) { + let assets = photoService.fetchAssets(filter: filter) + totalPhotoCount = assets.count + } + + func requestPhotosAccess() async { + photosAuthStatus = await photoService.requestAuthorization() + } +} diff --git a/Features/Browse/BrowseView.swift b/Features/Browse/BrowseView.swift new file mode 100644 index 0000000..5948076 --- /dev/null +++ b/Features/Browse/BrowseView.swift @@ -0,0 +1,164 @@ +import SwiftUI + +struct BrowseView: View { + let connection: NASConnection + @Binding var selectedPath: String + @Environment(\.dismiss) private var dismiss + + @State private var currentPath: String = "/" + @State private var items: [NASItem] = [] + @State private var isLoading = false + @State private var error: String? = nil + @State private var showCreateFolder = false + @State private var newFolderName = "" + + private var service: any NASTransferProtocol { + connection.nasProtocol == .smb ? SMBService() : SFTPService() + } + + var body: some View { + NavigationStack { + ZStack { + Color.white.ignoresSafeArea() + + VStack(spacing: 0) { + // Path bar + HStack { + Image(systemName: "externaldrive.fill") + .foregroundColor(AppTheme.textSecondary) + .font(.system(size: 13)) + Text(currentPath) + .font(AppTheme.captionFont) + .foregroundColor(AppTheme.textSecondary) + .lineLimit(1) + .truncationMode(.middle) + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(Color(hex: "#F9FAFB")) + + Divider() + + if isLoading { + Spacer() + ProgressView() + Spacer() + } else if let error { + Spacer() + VStack(spacing: 12) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 32)) + .foregroundColor(AppTheme.textTertiary) + Text(error) + .font(AppTheme.bodyFont) + .foregroundColor(AppTheme.textSecondary) + .multilineTextAlignment(.center) + } + .padding() + Spacer() + } else { + List { + if currentPath != "/" { + Button(action: navigateUp) { + HStack { + Image(systemName: "arrow.up") + .foregroundColor(AppTheme.textSecondary) + Text("Parent folder") + .font(AppTheme.bodyFont) + .foregroundColor(AppTheme.textSecondary) + } + } + } + + ForEach(items) { item in + FolderRow( + item: item, + isSelected: selectedPath == item.path + ) { + if item.isDirectory { + navigate(to: item.path) + } else { + selectedPath = item.path + } + } + .listRowInsets(EdgeInsets()) + .listRowSeparator(.visible) + } + } + .listStyle(.plain) + } + } + } + .navigationTitle("Choose folder") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .navigationBarTrailing) { + Button { + newFolderName = "" + showCreateFolder = true + } label: { + Image(systemName: "folder.badge.plus") + } + } + ToolbarItem(placement: .bottomBar) { + Button("Select this folder") { + selectedPath = currentPath + dismiss() + } + .font(.system(size: 17, weight: .semibold)) + .foregroundColor(AppTheme.blue) + } + } + .alert("New folder", isPresented: $showCreateFolder) { + TextField("Folder name", text: $newFolderName) + Button("Create") { Task { await createFolder() } } + Button("Cancel", role: .cancel) {} + } + } + .task { await loadDirectory(path: currentPath) } + } + + private func loadDirectory(path: String) async { + isLoading = true + error = nil + do { + let s: any NASTransferProtocol = connection.nasProtocol == .smb ? SMBService() : SFTPService() + try await s.connect(to: connection.host, port: connection.port, + username: connection.username, password: connection.password) + items = try await s.listDirectory(at: path).filter { $0.isDirectory } + s.disconnect() + } catch let e as BackupError { + self.error = e.errorDescription + } catch { + self.error = error.localizedDescription + } + isLoading = false + } + + private func navigate(to path: String) { + currentPath = path + Task { await loadDirectory(path: path) } + } + + private func navigateUp() { + let parent = (currentPath as NSString).deletingLastPathComponent + navigate(to: parent.isEmpty ? "/" : parent) + } + + private func createFolder() async { + guard !newFolderName.isEmpty else { return } + let newPath = "\(currentPath)/\(newFolderName)" + do { + let s: any NASTransferProtocol = connection.nasProtocol == .smb ? SMBService() : SFTPService() + try await s.connect(to: connection.host, port: connection.port, + username: connection.username, password: connection.password) + try await s.createDirectory(at: newPath) + s.disconnect() + await loadDirectory(path: currentPath) + } catch {} + } +} diff --git a/Features/Connect/ConnectView.swift b/Features/Connect/ConnectView.swift new file mode 100644 index 0000000..44545d0 --- /dev/null +++ b/Features/Connect/ConnectView.swift @@ -0,0 +1,157 @@ +import SwiftUI + +struct ConnectView: View { + @StateObject private var vm = ConnectViewModel() + @State private var navigateToDashboard = false + + var body: some View { + NavigationStack { + ZStack { + Color.white.ignoresSafeArea() + + ScrollView { + VStack(spacing: 0) { + // Logo + Image("nas_logo_clean") + .resizable() + .scaledToFit() + .frame(width: 56, height: 56) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .padding(.top, 48) + .padding(.bottom, 28) + + // Protocol picker + Picker("Protocol", selection: $vm.selectedProtocol) { + ForEach(NASProtocol.allCases, id: \.self) { Text($0.rawValue).tag($0) } + } + .pickerStyle(.segmented) + .padding(.horizontal, 20) + .padding(.bottom, 20) + + // Form card + VStack(spacing: 0) { + fieldRow(icon: "network", placeholder: "IP Address or hostname", + text: $vm.host, keyboardType: .URL) + Divider().padding(.leading, 52) + fieldRow(icon: "person", placeholder: "Username", + text: $vm.username, keyboardType: .emailAddress) + Divider().padding(.leading, 52) + secureFieldRow(icon: "lock", placeholder: "Password", text: $vm.password) + Divider().padding(.leading, 52) + + // Folder picker row + Button(action: { if vm.isConnected { vm.showFolderBrowser = true } }) { + HStack(spacing: 12) { + Image(systemName: "folder") + .font(.system(size: 16)) + .foregroundColor(vm.isConnected ? AppTheme.blue : AppTheme.textTertiary) + .frame(width: 28) + Text(vm.remotePath == "/" ? "Destination folder" : vm.remotePath) + .font(AppTheme.bodyFont) + .foregroundColor(vm.remotePath == "/" ? AppTheme.textTertiary : AppTheme.textPrimary) + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 13)) + .foregroundColor(AppTheme.textTertiary) + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + } + } + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius)) + .overlay( + RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius) + .stroke(vm.isConnected ? AppTheme.connectedBorder : + (vm.verifyError != nil ? AppTheme.red : AppTheme.cardBorder), + lineWidth: vm.isConnected ? 1.5 : 1) + ) + .padding(.horizontal, 20) + + if let error = vm.verifyError { + Text(error) + .font(AppTheme.captionFont) + .foregroundColor(AppTheme.red) + .padding(.top, 10) + .padding(.horizontal, 24) + } + + if vm.isConnected { + Text("Connected successfully") + .font(AppTheme.captionFont) + .foregroundColor(AppTheme.green) + .padding(.top, 10) + } + + // Connect button — sits BELOW the card with explicit gap + Spacer().frame(height: 24) + + Button(action: { + if vm.isConnected { + navigateToDashboard = true + } else { + Task { await vm.verify() } + } + }) { + Group { + if vm.isVerifying { + ProgressView().tint(.white) + } else { + Text(vm.isConnected ? "Connected — continue" : "Connect") + .font(.system(size: 17, weight: .semibold)) + } + } + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .frame(height: 52) + .background(vm.isConnected ? AppTheme.green : AppTheme.blue) + .clipShape(RoundedRectangle(cornerRadius: 14)) + } + .disabled(!vm.canConnect) + .padding(.horizontal, 20) + .padding(.bottom, 40) + } + } + } + .navigationTitle("Connect to NAS") + .navigationBarTitleDisplayMode(.inline) + .navigationDestination(isPresented: $navigateToDashboard) { + MainTabView() + } + .sheet(isPresented: $vm.showFolderBrowser) { + if let conn = ConnectionStore.shared.savedConnection { + BrowseView(connection: conn, selectedPath: $vm.remotePath) + } + } + } + } + + private func fieldRow(icon: String, placeholder: String, text: Binding, keyboardType: UIKeyboardType = .default) -> some View { + HStack(spacing: 12) { + Image(systemName: icon) + .font(.system(size: 16)) + .foregroundColor(AppTheme.textSecondary) + .frame(width: 28) + TextField(placeholder, text: text) + .font(AppTheme.bodyFont) + .keyboardType(keyboardType) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + } + + private func secureFieldRow(icon: String, placeholder: String, text: Binding) -> some View { + HStack(spacing: 12) { + Image(systemName: icon) + .font(.system(size: 16)) + .foregroundColor(AppTheme.textSecondary) + .frame(width: 28) + SecureField(placeholder, text: text) + .font(AppTheme.bodyFont) + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + } +} diff --git a/Features/Connect/ConnectViewModel.swift b/Features/Connect/ConnectViewModel.swift new file mode 100644 index 0000000..11b9412 --- /dev/null +++ b/Features/Connect/ConnectViewModel.swift @@ -0,0 +1,53 @@ +import Foundation + +enum ConnectField { case host, username, password, folder } +enum FieldState { case idle, valid, invalid } + +@MainActor +final class ConnectViewModel: ObservableObject { + @Published var host = "" + @Published var username = "" + @Published var password = "" + @Published var remotePath = "/" + @Published var selectedProtocol: NASProtocol = .smb + @Published var isVerifying = false + @Published var isConnected = false + @Published var verifyError: String? = nil + @Published var showFolderBrowser = false + + var fieldState: FieldState { + isConnected ? .valid : (verifyError != nil ? .invalid : .idle) + } + + var canConnect: Bool { + !host.isEmpty && !username.isEmpty && !password.isEmpty && !isVerifying + } + + func verify() async { + isVerifying = true + isConnected = false + verifyError = nil + + let connection = NASConnection( + host: host, + nasProtocol: selectedProtocol, + username: username, + password: password, + remotePath: remotePath + ) + + let service: any NASTransferProtocol = selectedProtocol == .smb ? SMBService() : SFTPService() + do { + try await service.connect(to: connection.host, port: connection.port, + username: connection.username, password: connection.password) + service.disconnect() + isConnected = true + ConnectionStore.shared.savedConnection = connection + } catch let e as BackupError { + verifyError = e.errorDescription + } catch { + verifyError = error.localizedDescription + } + isVerifying = false + } +} diff --git a/Features/History/HistoryView.swift b/Features/History/HistoryView.swift new file mode 100644 index 0000000..1c0ef2c --- /dev/null +++ b/Features/History/HistoryView.swift @@ -0,0 +1,97 @@ +import SwiftUI + +struct HistoryView: View { + @EnvironmentObject var store: ConnectionStore + + var body: some View { + ZStack { + Color.white.ignoresSafeArea() + + if store.historyEntries.isEmpty { + VStack(spacing: 16) { + Image(systemName: "clock.arrow.circlepath") + .font(.system(size: 48)) + .foregroundColor(AppTheme.textTertiary) + Text("No backup history yet") + .font(AppTheme.bodyFont) + .foregroundColor(AppTheme.textSecondary) + } + } else { + List { + ForEach(store.historyEntries) { entry in + HistoryRowView(entry: entry) + .listRowSeparator(.visible) + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + } + .onDelete { offsets in + store.removeHistoryEntries(at: offsets) + } + } + .listStyle(.plain) + } + } + .navigationTitle("History") + .toolbar { + if !store.historyEntries.isEmpty { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Clear") { store.clearHistory() } + .foregroundColor(AppTheme.red) + } + } + } + } +} + +struct HistoryRowView: View { + let entry: BackupHistoryEntry + + var body: some View { + HStack(spacing: 14) { + Circle() + .fill(entry.failedCount == 0 ? AppTheme.green : AppTheme.orange) + .frame(width: 10, height: 10) + + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(entry.date, style: .date) + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(AppTheme.textPrimary) + if entry.triggeredByLAN { + Text("AUTO") + .font(.system(size: 10, weight: .bold)) + .foregroundColor(AppTheme.blue) + .padding(.horizontal, 5) + .padding(.vertical, 2) + .overlay(RoundedRectangle(cornerRadius: 4).stroke(AppTheme.blue, lineWidth: 1)) + } + Spacer() + Text(entry.date, style: .time) + .font(AppTheme.captionFont) + .foregroundColor(AppTheme.textTertiary) + } + + HStack(spacing: 12) { + statChip("\(entry.uploadedCount) uploaded", color: AppTheme.textSecondary) + if entry.skippedCount > 0 { + statChip("\(entry.skippedCount) skipped", color: AppTheme.textTertiary) + } + if entry.failedCount > 0 { + statChip("\(entry.failedCount) errors", color: AppTheme.red) + } + } + + Text(entry.nasHost) + .font(AppTheme.captionFont) + .foregroundColor(AppTheme.textTertiary) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + + private func statChip(_ text: String, color: Color) -> some View { + Text(text) + .font(AppTheme.captionFont) + .foregroundColor(color) + } +} diff --git a/Features/Login/LoginView.swift b/Features/Login/LoginView.swift new file mode 100644 index 0000000..912201a --- /dev/null +++ b/Features/Login/LoginView.swift @@ -0,0 +1,141 @@ +import SwiftUI + +struct LoginView: View { + @StateObject private var vm = LoginViewModel() + @State private var navigateToDashboard = false + + var body: some View { + NavigationStack { + ZStack { + Color.white.ignoresSafeArea() + + VStack(spacing: 0) { + Spacer() + + // Logo + device + VStack(spacing: 16) { + Image("nas_logo_clean") + .resizable() + .scaledToFit() + .frame(width: 100, height: 100) + .clipShape(RoundedRectangle(cornerRadius: 22)) + + Text("Forge") + .font(.system(size: 22, weight: .bold)) + .foregroundColor(AppTheme.textPrimary) + } + + Spacer().frame(height: 40) + + // Account avatar + name + HStack(spacing: 12) { + ZStack { + Circle() + .fill(AppTheme.blue) + .frame(width: 48, height: 48) + Text("AB") + .font(.system(size: 16, weight: .bold)) + .foregroundColor(.white) + } + VStack(alignment: .leading, spacing: 2) { + Text("Albert") + .font(.system(size: 17, weight: .semibold)) + .foregroundColor(AppTheme.textPrimary) + Text("NAS Account") + .font(AppTheme.captionFont) + .foregroundColor(AppTheme.textSecondary) + } + } + .padding(.bottom, 32) + + // Face ID button + Button(action: { vm.authenticateWithFaceID() }) { + HStack(spacing: 10) { + Image(systemName: "faceid") + .font(.system(size: 20, weight: .medium)) + Text("Sign in with Face ID") + .font(.system(size: 17, weight: .semibold)) + } + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .frame(height: 52) + .background(AppTheme.blue) + .clipShape(RoundedRectangle(cornerRadius: 14)) + } + .padding(.horizontal, 32) + .disabled(vm.isAuthenticating) + + if let error = vm.authError { + Text(error) + .font(AppTheme.captionFont) + .foregroundColor(AppTheme.red) + .padding(.top, 12) + } + + Spacer() + + // More link + Button("More") { vm.showMoreSheet = true } + .font(.system(size: 15, weight: .regular)) + .foregroundColor(AppTheme.textSecondary) + .padding(.bottom, 40) + } + } + .navigationDestination(isPresented: $navigateToDashboard) { + MainTabView() + } + .sheet(isPresented: $vm.showMoreSheet) { + MoreOptionsSheet(isPresented: $vm.showMoreSheet, onAuthenticated: { + vm.showMoreSheet = false + navigateToDashboard = true + }) + .presentationDetents([.height(220)]) + } + } + .onAppear { + vm.onAuthenticated = { navigateToDashboard = true } + } + } +} + +struct MoreOptionsSheet: View { + @Binding var isPresented: Bool + var onAuthenticated: (() -> Void)? + @State private var showPasswordEntry = false + + var body: some View { + VStack(spacing: 0) { + Capsule() + .fill(Color(hex: "#D1D5DB")) + .frame(width: 40, height: 4) + .padding(.top, 12) + .padding(.bottom, 20) + + Button("Password") { showPasswordEntry = true } + .font(.system(size: 17, weight: .semibold)) + .foregroundColor(AppTheme.textPrimary) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + + Divider() + + Button("Another account") { + isPresented = false + onAuthenticated?() + } + .font(.system(size: 17, weight: .regular)) + .foregroundColor(AppTheme.textPrimary) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + + Divider() + + Button("Cancel") { isPresented = false } + .font(.system(size: 17, weight: .regular)) + .foregroundColor(AppTheme.textSecondary) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + } + .background(Color.white) + } +} diff --git a/Features/Login/LoginViewModel.swift b/Features/Login/LoginViewModel.swift new file mode 100644 index 0000000..c224d19 --- /dev/null +++ b/Features/Login/LoginViewModel.swift @@ -0,0 +1,36 @@ +import Foundation +import LocalAuthentication + +@MainActor +final class LoginViewModel: ObservableObject { + @Published var showMoreSheet = false + @Published var isAuthenticating = false + @Published var authError: String? = nil + + var onAuthenticated: (() -> Void)? + + func authenticateWithFaceID() { + isAuthenticating = true + authError = nil + let context = LAContext() + var error: NSError? + guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else { + authError = "Face ID not available" + isAuthenticating = false + return + } + context.evaluatePolicy( + .deviceOwnerAuthenticationWithBiometrics, + localizedReason: "Sign in to NASBackup" + ) { [weak self] success, evalError in + Task { @MainActor in + self?.isAuthenticating = false + if success { + self?.onAuthenticated?() + } else { + self?.authError = evalError?.localizedDescription ?? "Authentication failed" + } + } + } + } +} diff --git a/Features/MainTabView.swift b/Features/MainTabView.swift new file mode 100644 index 0000000..27b1601 --- /dev/null +++ b/Features/MainTabView.swift @@ -0,0 +1,73 @@ +import SwiftUI + +struct MainTabView: View { + @EnvironmentObject var store: ConnectionStore + + var body: some View { + AdaptiveLayout() + } +} + +// iPad uses NavigationSplitView; iPhone uses TabView +struct AdaptiveLayout: View { + @Environment(\.horizontalSizeClass) private var sizeClass + + var body: some View { + if sizeClass == .regular { + iPadSidebarLayout() + } else { + iPhoneTabLayout() + } + } +} + +struct iPhoneTabLayout: View { + var body: some View { + TabView { + NavigationStack { BackupView() } + .tabItem { Label("Backup", systemImage: "arrow.up.doc.fill") } + + NavigationStack { HistoryView() } + .tabItem { Label("History", systemImage: "clock.arrow.circlepath") } + + NavigationStack { + if let conn = ConnectionStore.shared.savedConnection { + BrowseView(connection: conn, selectedPath: .constant("/")) + } + } + .tabItem { Label("Browse", systemImage: "folder.fill") } + + NavigationStack { SettingsView() } + .tabItem { Label("Settings", systemImage: "gearshape.fill") } + } + .tint(AppTheme.blue) + } +} + +struct iPadSidebarLayout: View { + @State private var selection: String? = "backup" + + var body: some View { + NavigationSplitView { + List(selection: $selection) { + Label("Backup", systemImage: "arrow.up.doc.fill").tag("backup") + Label("History", systemImage: "clock.arrow.circlepath").tag("history") + Label("Browse", systemImage: "folder.fill").tag("browse") + Label("Settings", systemImage: "gearshape.fill").tag("settings") + } + .navigationTitle("NASBackup") + } detail: { + switch selection { + case "backup": BackupView() + case "history": HistoryView() + case "browse": + if let conn = ConnectionStore.shared.savedConnection { + BrowseView(connection: conn, selectedPath: .constant("/")) + } + case "settings": SettingsView() + default: BackupView() + } + } + .tint(AppTheme.blue) + } +} diff --git a/Features/Settings/LANTriggerSection.swift b/Features/Settings/LANTriggerSection.swift new file mode 100644 index 0000000..a59d9e7 --- /dev/null +++ b/Features/Settings/LANTriggerSection.swift @@ -0,0 +1,152 @@ +import SwiftUI + +struct LANTriggerSection: View { + @EnvironmentObject var store: ConnectionStore + @EnvironmentObject var lanMonitor: LANMonitor + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text("AUTOMATION — LAN TRIGGER") + .font(AppTheme.smallFont) + .foregroundColor(AppTheme.textSecondary) + .padding(.leading, 4) + + // Hero card — blue border, FIXED 210px height, strict top-down layout + VStack(spacing: 0) { + // Header band + HStack { + Image(systemName: "wifi") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.white) + Text("Auto-backup on home network") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(.white) + Spacer() + Text(store.lanTriggerEnabled ? "On" : "Off") + .font(.system(size: 12, weight: .bold)) + .foregroundColor(.white) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(store.lanTriggerEnabled ? Color.white.opacity(0.25) : Color.white.opacity(0.15)) + .clipShape(Capsule()) + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(AppTheme.blue) + + // Toggle row + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("Start when on trusted Wi-Fi") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(AppTheme.textPrimary) + Text("Backup runs on SSID match") + .font(.system(size: 12)) + .foregroundColor(AppTheme.textSecondary) + } + Spacer() + Toggle("", isOn: $store.lanTriggerEnabled) + .labelsHidden() + .tint(AppTheme.blue) + .scaleEffect(0.85) + } + .padding(.horizontal, 14) + .padding(.top, 10) + .padding(.bottom, 6) + + Divider().padding(.horizontal, 14) + + // Trusted networks + VStack(alignment: .leading, spacing: 4) { + Text("TRUSTED NETWORKS") + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(AppTheme.textTertiary) + .padding(.horizontal, 14) + .padding(.top, 6) + + ForEach(store.trustedSSIDs, id: \.self) { ssid in + HStack(spacing: 8) { + Circle() + .fill(ssid == lanMonitor.currentSSID ? AppTheme.green : AppTheme.textTertiary) + .frame(width: 7, height: 7) + Text(ssid == lanMonitor.currentSSID ? "Connected" : "Saved") + .font(.system(size: 11)) + .foregroundColor(AppTheme.textTertiary) + .frame(width: 54, alignment: .leading) + Text(ssid) + .font(.system(size: 13, weight: .medium)) + .foregroundColor(AppTheme.textPrimary) + Spacer() + Button { + store.trustedSSIDs.removeAll { $0 == ssid } + } label: { + Image(systemName: "xmark") + .font(.system(size: 11, weight: .medium)) + .foregroundColor(AppTheme.textTertiary) + } + } + .padding(.horizontal, 14) + .padding(.vertical, 2) + } + + Button { + Task { + if let ssid = await lanMonitor.fetchCurrentSSID(), + !store.trustedSSIDs.contains(ssid) { + store.trustedSSIDs.append(ssid) + } + } + } label: { + HStack(spacing: 6) { + Image(systemName: "plus.circle.fill") + .font(.system(size: 13)) + .foregroundColor(AppTheme.blue) + Text("Add current network") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(AppTheme.blue) + } + .padding(.horizontal, 14) + .padding(.vertical, 4) + } + } + + Divider().padding(.horizontal, 14).padding(.top, 4) + + // Bottom options row + HStack { + HStack(spacing: 6) { + Image(systemName: "timer") + .font(.system(size: 12)) + .foregroundColor(AppTheme.textSecondary) + Text("Wait before starting") + .font(.system(size: 13)) + .foregroundColor(AppTheme.textPrimary) + } + Spacer() + Menu { + ForEach([0, 15, 30, 60, 120], id: \.self) { sec in + Button("\(sec)s") { store.startDelaySeconds = sec } + } + } label: { + HStack(spacing: 4) { + Text("\(store.startDelaySeconds)s") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(AppTheme.blue) + Image(systemName: "chevron.right") + .font(.system(size: 11)) + .foregroundColor(AppTheme.textTertiary) + } + } + } + .padding(.horizontal, 14) + .padding(.vertical, 8) + } + .frame(height: 210) + .clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius)) + .overlay( + RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius) + .stroke(AppTheme.lanBorder, lineWidth: 1.5) + ) + } + } +} diff --git a/Features/Settings/SettingsView.swift b/Features/Settings/SettingsView.swift new file mode 100644 index 0000000..960d7d0 --- /dev/null +++ b/Features/Settings/SettingsView.swift @@ -0,0 +1,156 @@ +import SwiftUI + +struct SettingsView: View { + @EnvironmentObject var store: ConnectionStore + + var body: some View { + ZStack { + Color.white.ignoresSafeArea() + + ScrollView { + VStack(spacing: AppTheme.sectionSpacing) { + // LAN trigger hero section + LANTriggerSection() + + // What to back up + mediaSection + + // Quiet hours + quietHoursSection + + // Notification section + notificationSection + + Spacer(minLength: 40) + } + .padding(.horizontal, 20) + .padding(.top, 20) + } + } + .navigationTitle("Settings") + .navigationBarTitleDisplayMode(.inline) + } + + // MARK: Media section + + private var mediaSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("WHAT TO BACK UP") + .font(AppTheme.smallFont) + .foregroundColor(AppTheme.textSecondary) + .padding(.leading, 4) + + VStack(spacing: 0) { + ToggleRow(icon: "photo", title: "Photos", + isOn: $store.backupFilter.includePhotos) + Divider().padding(.leading, 44) + ToggleRow(icon: "video", title: "Videos", + isOn: $store.backupFilter.includeVideos) + Divider().padding(.leading, 44) + ToggleRow(icon: "camera.viewfinder", title: "Screenshots", + isOn: $store.backupFilter.includeScreenshots) + Divider().padding(.leading, 44) + ToggleRow(icon: "rays", title: "RAW", + isOn: $store.backupFilter.includeRAW) + Divider().padding(.leading, 44) + ToggleRow(icon: "doc.badge.clock", title: "New files only", + subtitle: "Skip files already on NAS", + isOn: $store.backupFilter.newFilesOnly) + } + .padding(.horizontal, 16) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius)) + .overlay(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius).stroke(AppTheme.cardBorder)) + } + } + + // MARK: Quiet hours + + private var quietHoursSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("QUIET HOURS") + .font(AppTheme.smallFont) + .foregroundColor(AppTheme.textSecondary) + .padding(.leading, 4) + + VStack(spacing: 0) { + ToggleRow(icon: "moon.fill", title: "Quiet hours", + subtitle: "No auto-backup during this window", + isOn: $store.quietHoursEnabled) + + if store.quietHoursEnabled { + Divider().padding(.leading, 44) + timePickerRow(label: "Start", hour: $store.quietHoursStart) + Divider().padding(.leading, 44) + timePickerRow(label: "End", hour: $store.quietHoursEnd) + } + + Divider().padding(.leading, 44) + ToggleRow(icon: "bolt.fill", title: "Only while charging", + subtitle: "Preserve battery on large libraries", + isOn: $store.chargingOnlyMode) + } + .padding(.horizontal, 16) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius)) + .overlay(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius).stroke(AppTheme.cardBorder)) + } + } + + private func timePickerRow(label: String, hour: Binding) -> some View { + HStack { + Text(label) + .font(AppTheme.bodyFont) + .foregroundColor(AppTheme.textPrimary) + .frame(width: 80, alignment: .leading) + Spacer() + Picker(label, selection: hour) { + ForEach(0..<24, id: \.self) { h in + Text(String(format: "%02d:00", h)).tag(h) + } + } + .pickerStyle(.menu) + .tint(AppTheme.blue) + } + .padding(.vertical, 8) + } + + // MARK: Notifications + + private var notificationSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("NOTIFICATIONS") + .font(AppTheme.smallFont) + .foregroundColor(AppTheme.textSecondary) + .padding(.leading, 4) + + VStack(spacing: 0) { + staticRow(icon: "bell.fill", title: "On start", value: "Off") + Divider().padding(.leading, 44) + staticRow(icon: "checkmark.circle.fill", title: "On complete", value: "On") + Divider().padding(.leading, 44) + staticRow(icon: "exclamationmark.triangle.fill", title: "On errors", value: "On") + } + .padding(.horizontal, 16) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius)) + .overlay(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius).stroke(AppTheme.cardBorder)) + } + } + + private func staticRow(icon: String, title: String, value: String) -> some View { + HStack { + Image(systemName: icon) + .foregroundColor(AppTheme.blue) + .frame(width: 28) + Text(title) + .font(AppTheme.bodyFont) + .foregroundColor(AppTheme.textPrimary) + Spacer() + Text(value) + .font(AppTheme.bodyFont) + .foregroundColor(AppTheme.textSecondary) + } + .padding(.vertical, 12) + } +} diff --git a/NASBackup.entitlements b/NASBackup.entitlements new file mode 100644 index 0000000..ba21fbd --- /dev/null +++ b/NASBackup.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.developer.networking.wifi-info + + + diff --git a/NASBackup.xcodeproj/project.pbxproj b/NASBackup.xcodeproj/project.pbxproj new file mode 100644 index 0000000..db1ae0f --- /dev/null +++ b/NASBackup.xcodeproj/project.pbxproj @@ -0,0 +1,597 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 36FE83FFC24922EBC28287BB /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88E66C22B614E89A7A9E9B35 /* AppDelegate.swift */; }; + 37D808079BDEF32D04847D04 /* MainTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F09160B7B7A06BD900D0D87A /* MainTabView.swift */; }; + 45FFF2DCFE454EFDF642CEB1 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8F24C7DD7A503A0D561F3FB /* LoginView.swift */; }; + 512B235FB16DEFBBD9EA4E1C /* NASConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BE4774FFA1074FF5F1FAB2B /* NASConnection.swift */; }; + 57656BC4B5A2E81FE7BF37FD /* PhotoLibraryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46B47E3C76EA718ECF97BBAD /* PhotoLibraryService.swift */; }; + 6500E80B50EEF877C5127A2A /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EEBCD4A838173E557DC3EFC /* HistoryView.swift */; }; + 6DED667F0AA66503730640F1 /* FolderRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83C13DD3EB4823AA98D51AF2 /* FolderRow.swift */; }; + 7872072DD0E0B8D51C1A12DF /* NASBackupApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B36E23852EF12DC14A2DF2DC /* NASBackupApp.swift */; }; + 7DF923F172E6DFE3DD110DC9 /* LANTriggerSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CBE4F65BE653C7C72E096D1 /* LANTriggerSection.swift */; }; + 834A766E13EF297273FFEE21 /* LoginViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A42E8CFCAECFDBF6EDDAC0 /* LoginViewModel.swift */; }; + 84E3EE64F74D00104BF8694B /* BackgroundTaskManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5643379BBA1FD39841F9EA55 /* BackgroundTaskManager.swift */; }; + 8657A3E4C5E377FE34840E9A /* BackupEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 824FADB40473BFC36FE91F0A /* BackupEngine.swift */; }; + 88879238876B1E5A1BE15DF3 /* SMBClient in Frameworks */ = {isa = PBXBuildFile; productRef = 9CEDCE7A5B4B267C66F39ABE /* SMBClient */; }; + 8C1C09CA16A5D71408468BCB /* NASTransferProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 055D92AB46FCDDF9C34B9D62 /* NASTransferProtocol.swift */; }; + 9444B0EB5AD2936DA837320F /* BackupError.swift in Sources */ = {isa = PBXBuildFile; fileRef = F03F0ED1A914F3392BFE5E4A /* BackupError.swift */; }; + 9758372DD65F5353C72DE5C5 /* SMBService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEBD92D10D70B957192E271F /* SMBService.swift */; }; + 9A2F717F6A8F4AA85FA636FC /* Citadel in Frameworks */ = {isa = PBXBuildFile; productRef = 7BEB5BEBEB935BC8E2182A7B /* Citadel */; }; + 9CF57AC05F30A51B6B5269DC /* AppTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B33F755DA5850D1FE3F695B /* AppTheme.swift */; }; + A6276840D40211EB446293A9 /* BackupViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC73AD75762D4357D171CDA4 /* BackupViewModel.swift */; }; + A6D143C05EE16594E0D1AA01 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0AABE0AA63B1ACA1DEABB65 /* SceneDelegate.swift */; }; + B08D787E99C3B2E193A0D694 /* ToggleRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8845003C87961174302AC990 /* ToggleRow.swift */; }; + B50445A9010E20F97E9E6E48 /* ProgressRing.swift in Sources */ = {isa = PBXBuildFile; fileRef = C955FEF6710E3667C7A73A02 /* ProgressRing.swift */; }; + B7DBF1420838AD3397A280B5 /* BackupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55EA454B783C2AEDABFB00A7 /* BackupView.swift */; }; + BC4346F01DAA4DE7D8F260DB /* SFTPService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B67584BB6D705BCB3D40192 /* SFTPService.swift */; }; + C6AAC362F058E7836A91AC30 /* ConnectViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91197DD51221A3277AE318E2 /* ConnectViewModel.swift */; }; + C8E9BC6070E23992EED8D013 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97D17A73A05A6FDF2B89C6B2 /* SettingsView.swift */; }; + CE47E9F2179F86BE0A630D91 /* LANMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5606A4C5894CFC72D555CAE1 /* LANMonitor.swift */; }; + DCE9AF4F0160E323224E3553 /* PhotoLibraryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74E2AF8B56C469E0D94EFE94 /* PhotoLibraryProtocol.swift */; }; + DF719EAD5727B335001DB50A /* ConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE318B60576B11E80862664D /* ConnectView.swift */; }; + E830DB3078A8873382A77B63 /* BackupResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AEFE4959C08B94AE9584A11 /* BackupResult.swift */; }; + F9219423E73718D8AB604EB1 /* ConnectionStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D87319355D902007618A91AE /* ConnectionStore.swift */; }; + FA646815F98D36CAB180072A /* BrowseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D947C8F8945A2AB081BBD0EF /* BrowseView.swift */; }; + FDC18499CC21A58C2F6F1BDB /* BackupJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47E762898E351192399FC739 /* BackupJob.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 055D92AB46FCDDF9C34B9D62 /* NASTransferProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NASTransferProtocol.swift; sourceTree = ""; }; + 2B33F755DA5850D1FE3F695B /* AppTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTheme.swift; sourceTree = ""; }; + 2BE4774FFA1074FF5F1FAB2B /* NASConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NASConnection.swift; sourceTree = ""; }; + 46B47E3C76EA718ECF97BBAD /* PhotoLibraryService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoLibraryService.swift; sourceTree = ""; }; + 47E762898E351192399FC739 /* BackupJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupJob.swift; sourceTree = ""; }; + 4AEFE4959C08B94AE9584A11 /* BackupResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupResult.swift; sourceTree = ""; }; + 4B67584BB6D705BCB3D40192 /* SFTPService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SFTPService.swift; sourceTree = ""; }; + 4CBE4F65BE653C7C72E096D1 /* LANTriggerSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LANTriggerSection.swift; sourceTree = ""; }; + 55EA454B783C2AEDABFB00A7 /* BackupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupView.swift; sourceTree = ""; }; + 5606A4C5894CFC72D555CAE1 /* LANMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LANMonitor.swift; sourceTree = ""; }; + 5643379BBA1FD39841F9EA55 /* BackgroundTaskManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundTaskManager.swift; sourceTree = ""; }; + 6EEBCD4A838173E557DC3EFC /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = ""; }; + 74E2AF8B56C469E0D94EFE94 /* PhotoLibraryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoLibraryProtocol.swift; sourceTree = ""; }; + 824FADB40473BFC36FE91F0A /* BackupEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupEngine.swift; sourceTree = ""; }; + 83C13DD3EB4823AA98D51AF2 /* FolderRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderRow.swift; sourceTree = ""; }; + 8845003C87961174302AC990 /* ToggleRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToggleRow.swift; sourceTree = ""; }; + 88E66C22B614E89A7A9E9B35 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 91197DD51221A3277AE318E2 /* ConnectViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectViewModel.swift; sourceTree = ""; }; + 97D17A73A05A6FDF2B89C6B2 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + A8F24C7DD7A503A0D561F3FB /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = ""; }; + AC73AD75762D4357D171CDA4 /* BackupViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupViewModel.swift; sourceTree = ""; }; + B36E23852EF12DC14A2DF2DC /* NASBackupApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NASBackupApp.swift; sourceTree = ""; }; + C755BFDD42D46A3FB490A4BB /* NASBackup.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = NASBackup.app; sourceTree = BUILT_PRODUCTS_DIR; }; + C955FEF6710E3667C7A73A02 /* ProgressRing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressRing.swift; sourceTree = ""; }; + D87319355D902007618A91AE /* ConnectionStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionStore.swift; sourceTree = ""; }; + D947C8F8945A2AB081BBD0EF /* BrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseView.swift; sourceTree = ""; }; + DEBD92D10D70B957192E271F /* SMBService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMBService.swift; sourceTree = ""; }; + EE318B60576B11E80862664D /* ConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectView.swift; sourceTree = ""; }; + F03F0ED1A914F3392BFE5E4A /* BackupError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupError.swift; sourceTree = ""; }; + F09160B7B7A06BD900D0D87A /* MainTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabView.swift; sourceTree = ""; }; + F0AABE0AA63B1ACA1DEABB65 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + F1A42E8CFCAECFDBF6EDDAC0 /* LoginViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginViewModel.swift; sourceTree = ""; }; + F5CE897758E9CD5BB8B80F63 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + BC332B29F6F9E065FF85AED8 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 88879238876B1E5A1BE15DF3 /* SMBClient in Frameworks */, + 9A2F717F6A8F4AA85FA636FC /* Citadel in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 0A801C860EB4C4B22FD4D073 /* Features */ = { + isa = PBXGroup; + children = ( + F09160B7B7A06BD900D0D87A /* MainTabView.swift */, + 803154EA96E2431367806BAF /* Backup */, + 8B6D2A627469C6FA0E1FC2C9 /* Browse */, + 1AD5A741A7466C3510901C60 /* Connect */, + 88D0BD5235374F2A264A0C96 /* History */, + FDF5327105186A6456CF0B08 /* Login */, + 147B04CA2B7A7723CCD13E93 /* Settings */, + ); + path = Features; + sourceTree = ""; + }; + 0A81FA8FFA5273F1D6E01775 = { + isa = PBXGroup; + children = ( + 957299C0794188A819B70A3D /* App */, + C607CAC118F78B0703491238 /* Core */, + 0A801C860EB4C4B22FD4D073 /* Features */, + ADFBF8F90DF8352BD2833A66 /* Services */, + 31C8DD8F9077E2B6C67A99BA /* Shared */, + 8308FE572D56B8045F17F6A0 /* Products */, + ); + sourceTree = ""; + }; + 0B27021136C1E85D5B8CFF97 /* Persistence */ = { + isa = PBXGroup; + children = ( + D87319355D902007618A91AE /* ConnectionStore.swift */, + ); + path = Persistence; + sourceTree = ""; + }; + 147B04CA2B7A7723CCD13E93 /* Settings */ = { + isa = PBXGroup; + children = ( + 4CBE4F65BE653C7C72E096D1 /* LANTriggerSection.swift */, + 97D17A73A05A6FDF2B89C6B2 /* SettingsView.swift */, + ); + path = Settings; + sourceTree = ""; + }; + 1AD5A741A7466C3510901C60 /* Connect */ = { + isa = PBXGroup; + children = ( + EE318B60576B11E80862664D /* ConnectView.swift */, + 91197DD51221A3277AE318E2 /* ConnectViewModel.swift */, + ); + path = Connect; + sourceTree = ""; + }; + 31C8DD8F9077E2B6C67A99BA /* Shared */ = { + isa = PBXGroup; + children = ( + 3E7F8DE77B7CDC7009B54D63 /* Components */, + 0B27021136C1E85D5B8CFF97 /* Persistence */, + C874CB05A914EE933FCFD5C2 /* Theme */, + ); + path = Shared; + sourceTree = ""; + }; + 3E7F8DE77B7CDC7009B54D63 /* Components */ = { + isa = PBXGroup; + children = ( + 83C13DD3EB4823AA98D51AF2 /* FolderRow.swift */, + C955FEF6710E3667C7A73A02 /* ProgressRing.swift */, + 8845003C87961174302AC990 /* ToggleRow.swift */, + ); + path = Components; + sourceTree = ""; + }; + 803154EA96E2431367806BAF /* Backup */ = { + isa = PBXGroup; + children = ( + 55EA454B783C2AEDABFB00A7 /* BackupView.swift */, + AC73AD75762D4357D171CDA4 /* BackupViewModel.swift */, + ); + path = Backup; + sourceTree = ""; + }; + 814AF232F5D5D0A188D5C304 /* Protocols */ = { + isa = PBXGroup; + children = ( + 055D92AB46FCDDF9C34B9D62 /* NASTransferProtocol.swift */, + 74E2AF8B56C469E0D94EFE94 /* PhotoLibraryProtocol.swift */, + ); + path = Protocols; + sourceTree = ""; + }; + 8308FE572D56B8045F17F6A0 /* Products */ = { + isa = PBXGroup; + children = ( + C755BFDD42D46A3FB490A4BB /* NASBackup.app */, + ); + name = Products; + sourceTree = ""; + }; + 88D0BD5235374F2A264A0C96 /* History */ = { + isa = PBXGroup; + children = ( + 6EEBCD4A838173E557DC3EFC /* HistoryView.swift */, + ); + path = History; + sourceTree = ""; + }; + 8B6D2A627469C6FA0E1FC2C9 /* Browse */ = { + isa = PBXGroup; + children = ( + D947C8F8945A2AB081BBD0EF /* BrowseView.swift */, + ); + path = Browse; + sourceTree = ""; + }; + 957299C0794188A819B70A3D /* App */ = { + isa = PBXGroup; + children = ( + 88E66C22B614E89A7A9E9B35 /* AppDelegate.swift */, + F5CE897758E9CD5BB8B80F63 /* Info.plist */, + B36E23852EF12DC14A2DF2DC /* NASBackupApp.swift */, + F0AABE0AA63B1ACA1DEABB65 /* SceneDelegate.swift */, + ); + path = App; + sourceTree = ""; + }; + ADFBF8F90DF8352BD2833A66 /* Services */ = { + isa = PBXGroup; + children = ( + 5643379BBA1FD39841F9EA55 /* BackgroundTaskManager.swift */, + 824FADB40473BFC36FE91F0A /* BackupEngine.swift */, + 5606A4C5894CFC72D555CAE1 /* LANMonitor.swift */, + 46B47E3C76EA718ECF97BBAD /* PhotoLibraryService.swift */, + 4B67584BB6D705BCB3D40192 /* SFTPService.swift */, + DEBD92D10D70B957192E271F /* SMBService.swift */, + ); + path = Services; + sourceTree = ""; + }; + C560F307C8BE37F60E1105D0 /* Errors */ = { + isa = PBXGroup; + children = ( + F03F0ED1A914F3392BFE5E4A /* BackupError.swift */, + ); + path = Errors; + sourceTree = ""; + }; + C607CAC118F78B0703491238 /* Core */ = { + isa = PBXGroup; + children = ( + C560F307C8BE37F60E1105D0 /* Errors */, + F75092ACBEF36F32816506F7 /* Models */, + 814AF232F5D5D0A188D5C304 /* Protocols */, + ); + path = Core; + sourceTree = ""; + }; + C874CB05A914EE933FCFD5C2 /* Theme */ = { + isa = PBXGroup; + children = ( + 2B33F755DA5850D1FE3F695B /* AppTheme.swift */, + ); + path = Theme; + sourceTree = ""; + }; + F75092ACBEF36F32816506F7 /* Models */ = { + isa = PBXGroup; + children = ( + 47E762898E351192399FC739 /* BackupJob.swift */, + 4AEFE4959C08B94AE9584A11 /* BackupResult.swift */, + 2BE4774FFA1074FF5F1FAB2B /* NASConnection.swift */, + ); + path = Models; + sourceTree = ""; + }; + FDF5327105186A6456CF0B08 /* Login */ = { + isa = PBXGroup; + children = ( + A8F24C7DD7A503A0D561F3FB /* LoginView.swift */, + F1A42E8CFCAECFDBF6EDDAC0 /* LoginViewModel.swift */, + ); + path = Login; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 32316B985B8906FE4CB97730 /* NASBackup */ = { + isa = PBXNativeTarget; + buildConfigurationList = F29F169757D793211107ABA5 /* Build configuration list for PBXNativeTarget "NASBackup" */; + buildPhases = ( + E6D3CCCB60977A7DABFE7F19 /* Sources */, + BC332B29F6F9E065FF85AED8 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = NASBackup; + packageProductDependencies = ( + 9CEDCE7A5B4B267C66F39ABE /* SMBClient */, + 7BEB5BEBEB935BC8E2182A7B /* Citadel */, + ); + productName = NASBackup; + productReference = C755BFDD42D46A3FB490A4BB /* NASBackup.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 82D1639F1555985B1BEE4547 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1500; + TargetAttributes = { + 32316B985B8906FE4CB97730 = { + DevelopmentTeam = ""; + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = 1A69DB656166247B44D9B8F9 /* Build configuration list for PBXProject "NASBackup" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = 0A81FA8FFA5273F1D6E01775; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + F0C68F8974ED417596605807 /* XCRemoteSwiftPackageReference "Citadel" */, + 79C37A3DFFC979C89EC4893A /* XCRemoteSwiftPackageReference "SMBClient" */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = 8308FE572D56B8045F17F6A0 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 32316B985B8906FE4CB97730 /* NASBackup */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXSourcesBuildPhase section */ + E6D3CCCB60977A7DABFE7F19 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 36FE83FFC24922EBC28287BB /* AppDelegate.swift in Sources */, + 9CF57AC05F30A51B6B5269DC /* AppTheme.swift in Sources */, + 84E3EE64F74D00104BF8694B /* BackgroundTaskManager.swift in Sources */, + 8657A3E4C5E377FE34840E9A /* BackupEngine.swift in Sources */, + 9444B0EB5AD2936DA837320F /* BackupError.swift in Sources */, + FDC18499CC21A58C2F6F1BDB /* BackupJob.swift in Sources */, + E830DB3078A8873382A77B63 /* BackupResult.swift in Sources */, + B7DBF1420838AD3397A280B5 /* BackupView.swift in Sources */, + A6276840D40211EB446293A9 /* BackupViewModel.swift in Sources */, + FA646815F98D36CAB180072A /* BrowseView.swift in Sources */, + DF719EAD5727B335001DB50A /* ConnectView.swift in Sources */, + C6AAC362F058E7836A91AC30 /* ConnectViewModel.swift in Sources */, + F9219423E73718D8AB604EB1 /* ConnectionStore.swift in Sources */, + 6DED667F0AA66503730640F1 /* FolderRow.swift in Sources */, + 6500E80B50EEF877C5127A2A /* HistoryView.swift in Sources */, + CE47E9F2179F86BE0A630D91 /* LANMonitor.swift in Sources */, + 7DF923F172E6DFE3DD110DC9 /* LANTriggerSection.swift in Sources */, + 45FFF2DCFE454EFDF642CEB1 /* LoginView.swift in Sources */, + 834A766E13EF297273FFEE21 /* LoginViewModel.swift in Sources */, + 37D808079BDEF32D04847D04 /* MainTabView.swift in Sources */, + 7872072DD0E0B8D51C1A12DF /* NASBackupApp.swift in Sources */, + 512B235FB16DEFBBD9EA4E1C /* NASConnection.swift in Sources */, + 8C1C09CA16A5D71408468BCB /* NASTransferProtocol.swift in Sources */, + DCE9AF4F0160E323224E3553 /* PhotoLibraryProtocol.swift in Sources */, + 57656BC4B5A2E81FE7BF37FD /* PhotoLibraryService.swift in Sources */, + B50445A9010E20F97E9E6E48 /* ProgressRing.swift in Sources */, + BC4346F01DAA4DE7D8F260DB /* SFTPService.swift in Sources */, + 9758372DD65F5353C72DE5C5 /* SMBService.swift in Sources */, + A6D143C05EE16594E0D1AA01 /* SceneDelegate.swift in Sources */, + C8E9BC6070E23992EED8D013 /* SettingsView.swift in Sources */, + B08D787E99C3B2E193A0D694 /* ToggleRow.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 5496D0713D70962E349CB0C8 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = NASBackup.entitlements; + CODE_SIGN_IDENTITY = "iPhone Developer"; + INFOPLIST_FILE = App/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + AAEAFE599F3F20DB264D4528 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_ENTITLEMENTS = NASBackup.entitlements; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = ""; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.albert.nasbackup; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.9; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + B8D4CA897A51789EE55978D2 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = NASBackup.entitlements; + CODE_SIGN_IDENTITY = "iPhone Developer"; + INFOPLIST_FILE = App/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + E3742B60227DB65518D87A80 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_ENTITLEMENTS = NASBackup.entitlements; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = ""; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.albert.nasbackup; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.9; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 1A69DB656166247B44D9B8F9 /* Build configuration list for PBXProject "NASBackup" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AAEAFE599F3F20DB264D4528 /* Debug */, + E3742B60227DB65518D87A80 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + F29F169757D793211107ABA5 /* Build configuration list for PBXNativeTarget "NASBackup" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 5496D0713D70962E349CB0C8 /* Debug */, + B8D4CA897A51789EE55978D2 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 79C37A3DFFC979C89EC4893A /* XCRemoteSwiftPackageReference "SMBClient" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/kishikawakatsumi/SMBClient"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.3.0; + }; + }; + F0C68F8974ED417596605807 /* XCRemoteSwiftPackageReference "Citadel" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/orlandos-nl/Citadel"; + requirement = { + kind = exactVersion; + version = 0.9.2; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 7BEB5BEBEB935BC8E2182A7B /* Citadel */ = { + isa = XCSwiftPackageProductDependency; + package = F0C68F8974ED417596605807 /* XCRemoteSwiftPackageReference "Citadel" */; + productName = Citadel; + }; + 9CEDCE7A5B4B267C66F39ABE /* SMBClient */ = { + isa = XCSwiftPackageProductDependency; + package = 79C37A3DFFC979C89EC4893A /* XCRemoteSwiftPackageReference "SMBClient" */; + productName = SMBClient; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 82D1639F1555985B1BEE4547 /* Project object */; +} diff --git a/NASBackup.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/NASBackup.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/NASBackup.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/NASBackup.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/NASBackup.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..25085c2 --- /dev/null +++ b/NASBackup.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,105 @@ +{ + "originHash" : "6c3918d288e8e1069a99126e28b8b2c969cfcbb168003667ec624b08a6442e06", + "pins" : [ + { + "identity" : "bigint", + "kind" : "remoteSourceControl", + "location" : "https://github.com/attaswift/BigInt.git", + "state" : { + "revision" : "e07e00fa1fd435143a2dcf8b7eec9a7710b2fdfe", + "version" : "5.7.0" + } + }, + { + "identity" : "citadel", + "kind" : "remoteSourceControl", + "location" : "https://github.com/orlandos-nl/Citadel", + "state" : { + "revision" : "be1a5bc51b29d64e89a223a755159b8a8c678f47", + "version" : "0.9.2" + } + }, + { + "identity" : "colorizeswift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/mtynior/ColorizeSwift.git", + "state" : { + "revision" : "4e7daa138510b77a3cce9f6a31a116f8536347dd", + "version" : "1.7.0" + } + }, + { + "identity" : "smbclient", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kishikawakatsumi/SMBClient", + "state" : { + "revision" : "e636c2b2458930770932a36d311ec9d478575b90", + "version" : "0.3.1" + } + }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "03cc312c2c933ed87abace34044a5dff7a3117c1", + "version" : "1.5.0" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "067254c79435de759aeef4a6a03e43d087d61312", + "version" : "2.0.5" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "5073617dac96330a486245e4c0179cb0a6fd2256", + "version" : "1.12.0" + } + }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "f71c8d2a5e74a2c6d11a0fbe324774b5d6084237", + "version" : "2.99.0" + } + }, + { + "identity" : "swift-nio-ssh", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Joannis/swift-nio-ssh.git", + "state" : { + "revision" : "791437a67f5394b13060314c778c0f63124803b1", + "version" : "0.3.5" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df", + "version" : "1.6.4" + } + } + ], + "version" : 3 +} diff --git a/NASBackup.xcodeproj/project.xcworkspace/xcuserdata/kutesir.xcuserdatad/UserInterfaceState.xcuserstate b/NASBackup.xcodeproj/project.xcworkspace/xcuserdata/kutesir.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000..4683790 Binary files /dev/null and b/NASBackup.xcodeproj/project.xcworkspace/xcuserdata/kutesir.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/NASBackup.xcodeproj/xcuserdata/kutesir.xcuserdatad/xcschemes/xcschememanagement.plist b/NASBackup.xcodeproj/xcuserdata/kutesir.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..50dec0b --- /dev/null +++ b/NASBackup.xcodeproj/xcuserdata/kutesir.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,14 @@ + + + + + SchemeUserState + + NASBackup.xcscheme_^#shared#^_ + + orderHint + 0 + + + + diff --git a/Services/BackgroundTaskManager.swift b/Services/BackgroundTaskManager.swift new file mode 100644 index 0000000..3cabfb4 --- /dev/null +++ b/Services/BackgroundTaskManager.swift @@ -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) + } +} diff --git a/Services/BackupEngine.swift b/Services/BackupEngine.swift new file mode 100644 index 0000000..213d026 --- /dev/null +++ b/Services/BackupEngine.swift @@ -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) + } +} diff --git a/Services/LANMonitor.swift b/Services/LANMonitor.swift new file mode 100644 index 0000000..8aeb573 --- /dev/null +++ b/Services/LANMonitor.swift @@ -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) + } +} diff --git a/Services/PhotoLibraryService.swift b/Services/PhotoLibraryService.swift new file mode 100644 index 0000000..946ff1b --- /dev/null +++ b/Services/PhotoLibraryService.swift @@ -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)) + } + } + } + } + } +} diff --git a/Services/SFTPService.swift b/Services/SFTPService.swift new file mode 100644 index 0000000..4040b8d --- /dev/null +++ b/Services/SFTPService.swift @@ -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) + } + } +} diff --git a/Services/SMBService.swift b/Services/SMBService.swift new file mode 100644 index 0000000..fb80303 --- /dev/null +++ b/Services/SMBService.swift @@ -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) + } + } +} diff --git a/Shared/Components/FolderRow.swift b/Shared/Components/FolderRow.swift new file mode 100644 index 0000000..63f9498 --- /dev/null +++ b/Shared/Components/FolderRow.swift @@ -0,0 +1,38 @@ +import SwiftUI + +struct FolderRow: View { + let item: NASItem + let isSelected: Bool + let onTap: () -> Void + + var body: some View { + Button(action: onTap) { + HStack(spacing: 12) { + Image(systemName: item.isDirectory ? "folder.fill" : "doc.fill") + .font(.system(size: 18)) + .foregroundColor(isSelected ? .white : (item.isDirectory ? AppTheme.blue : AppTheme.textSecondary)) + + Text(item.name) + .font(AppTheme.bodyFont) + .fontWeight(isSelected ? .semibold : .regular) + .foregroundColor(isSelected ? .white : AppTheme.textPrimary) + + Spacer() + + if isSelected { + Image(systemName: "checkmark") + .font(.system(size: 13, weight: .bold)) + .foregroundColor(.white) + } else if item.isDirectory { + Image(systemName: "chevron.right") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(AppTheme.textTertiary) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(isSelected ? AppTheme.blue : Color.clear) + } + .buttonStyle(.plain) + } +} diff --git a/Shared/Components/ProgressRing.swift b/Shared/Components/ProgressRing.swift new file mode 100644 index 0000000..1b5b49f --- /dev/null +++ b/Shared/Components/ProgressRing.swift @@ -0,0 +1,26 @@ +import SwiftUI + +struct ProgressRing: View { + let progress: Double + var lineWidth: CGFloat = 14 + var size: CGFloat = 200 + var color: Color = AppTheme.blue + var backgroundColor: Color = Color(hex: "#E5E7EB") + + var body: some View { + ZStack { + Circle() + .stroke(backgroundColor, lineWidth: lineWidth) + + Circle() + .trim(from: 0, to: CGFloat(min(progress, 1.0))) + .stroke( + color, + style: StrokeStyle(lineWidth: lineWidth, lineCap: .round) + ) + .rotationEffect(.degrees(-90)) + .animation(.easeInOut(duration: 0.4), value: progress) + } + .frame(width: size, height: size) + } +} diff --git a/Shared/Components/ToggleRow.swift b/Shared/Components/ToggleRow.swift new file mode 100644 index 0000000..14d3ee5 --- /dev/null +++ b/Shared/Components/ToggleRow.swift @@ -0,0 +1,35 @@ +import SwiftUI + +struct ToggleRow: View { + let icon: String + let title: String + var subtitle: String? = nil + @Binding var isOn: Bool + + var body: some View { + HStack(spacing: 12) { + Image(systemName: icon) + .font(.system(size: 16, weight: .medium)) + .foregroundColor(AppTheme.blue) + .frame(width: 24) + + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(AppTheme.bodyFont) + .foregroundColor(AppTheme.textPrimary) + if let subtitle { + Text(subtitle) + .font(AppTheme.captionFont) + .foregroundColor(AppTheme.textSecondary) + } + } + + Spacer() + + Toggle("", isOn: $isOn) + .labelsHidden() + .tint(AppTheme.blue) + } + .padding(.vertical, 4) + } +} diff --git a/Shared/Persistence/ConnectionStore.swift b/Shared/Persistence/ConnectionStore.swift new file mode 100644 index 0000000..9cc8607 --- /dev/null +++ b/Shared/Persistence/ConnectionStore.swift @@ -0,0 +1,82 @@ +import Foundation +import Combine + +private func ud(_ type: T.Type, key: String) -> T? { + guard let data = UserDefaults.standard.data(forKey: key) else { return nil } + return try? JSONDecoder().decode(type, from: data) +} + +private func saveUD(_ value: T?, key: String) { + guard let value else { UserDefaults.standard.removeObject(forKey: key); return } + if let data = try? JSONEncoder().encode(value) { UserDefaults.standard.set(data, forKey: key) } +} + +final class ConnectionStore: ObservableObject { + static let shared = ConnectionStore() + + @Published var savedConnection: NASConnection? { + didSet { saveUD(savedConnection, key: "savedConnection") } + } + @Published var backupFilter: BackupFilter { + didSet { saveUD(backupFilter, key: "backupFilter") } + } + @Published var trustedSSIDs: [String] { + didSet { UserDefaults.standard.set(trustedSSIDs, forKey: "trustedSSIDs") } + } + @Published var lanTriggerEnabled: Bool { + didSet { UserDefaults.standard.set(lanTriggerEnabled, forKey: "lanTriggerEnabled") } + } + @Published var startDelaySeconds: Int { + didSet { UserDefaults.standard.set(startDelaySeconds, forKey: "startDelaySeconds") } + } + @Published var chargingOnlyMode: Bool { + didSet { UserDefaults.standard.set(chargingOnlyMode, forKey: "chargingOnlyMode") } + } + @Published var quietHoursEnabled: Bool { + didSet { UserDefaults.standard.set(quietHoursEnabled, forKey: "quietHoursEnabled") } + } + @Published var quietHoursStart: Int { + didSet { UserDefaults.standard.set(quietHoursStart, forKey: "quietHoursStart") } + } + @Published var quietHoursEnd: Int { + didSet { UserDefaults.standard.set(quietHoursEnd, forKey: "quietHoursEnd") } + } + @Published private(set) var historyEntries: [BackupHistoryEntry] = [] + + private init() { + savedConnection = ud(NASConnection.self, key: "savedConnection") + backupFilter = ud(BackupFilter.self, key: "backupFilter") ?? BackupFilter() + trustedSSIDs = UserDefaults.standard.stringArray(forKey: "trustedSSIDs") ?? [] + lanTriggerEnabled = UserDefaults.standard.object(forKey: "lanTriggerEnabled") as? Bool ?? true + startDelaySeconds = UserDefaults.standard.object(forKey: "startDelaySeconds") as? Int ?? 30 + chargingOnlyMode = UserDefaults.standard.object(forKey: "chargingOnlyMode") as? Bool ?? true + quietHoursEnabled = UserDefaults.standard.object(forKey: "quietHoursEnabled") as? Bool ?? false + quietHoursStart = UserDefaults.standard.object(forKey: "quietHoursStart") as? Int ?? 23 + quietHoursEnd = UserDefaults.standard.object(forKey: "quietHoursEnd") as? Int ?? 7 + historyEntries = ud([BackupHistoryEntry].self, key: "historyEntries") ?? [] + } + + func appendHistoryEntry(_ entry: BackupHistoryEntry) { + historyEntries.insert(entry, at: 0) + saveUD(historyEntries, key: "historyEntries") + } + + func removeHistoryEntries(at offsets: IndexSet) { + historyEntries.remove(atOffsets: offsets) + saveUD(historyEntries, key: "historyEntries") + } + + func clearHistory() { + historyEntries = [] + UserDefaults.standard.removeObject(forKey: "historyEntries") + } + + var isInQuietHours: Bool { + guard quietHoursEnabled else { return false } + let hour = Calendar.current.component(.hour, from: Date()) + if quietHoursStart > quietHoursEnd { + return hour >= quietHoursStart || hour < quietHoursEnd + } + return hour >= quietHoursStart && hour < quietHoursEnd + } +} diff --git a/Shared/Theme/AppTheme.swift b/Shared/Theme/AppTheme.swift new file mode 100644 index 0000000..4e5663e --- /dev/null +++ b/Shared/Theme/AppTheme.swift @@ -0,0 +1,48 @@ +import SwiftUI + +enum AppTheme { + // Brand + static let blue = Color(hex: "#2563EB") + static let blueLight = Color(hex: "#3B82F6") + static let green = Color(hex: "#16A34A") + static let greenLight = Color(hex: "#22C55E") + static let red = Color(hex: "#DC2626") + static let orange = Color(hex: "#EA580C") + + // Backgrounds + static let background = Color.white + static let cardBackground = Color.white + static let cardBorder = Color(hex: "#E5E7EB") + + // Text + static let textPrimary = Color(hex: "#111827") + static let textSecondary = Color(hex: "#6B7280") + static let textTertiary = Color(hex: "#9CA3AF") + + // Status + static let connectedBorder = Color(hex: "#16A34A") + static let lanBorder = Color(hex: "#2563EB") + + // Typography + static let titleFont = Font.system(size: 28, weight: .bold, design: .default) + static let headlineFont = Font.system(size: 17, weight: .semibold) + static let bodyFont = Font.system(size: 15, weight: .regular) + static let captionFont = Font.system(size: 13, weight: .regular) + static let smallFont = Font.system(size: 11, weight: .medium) + + static let cardCornerRadius: CGFloat = 12 + static let cardPadding: CGFloat = 16 + static let sectionSpacing: CGFloat = 24 +} + +extension Color { + init(hex: String) { + var h = hex.trimmingCharacters(in: .init(charactersIn: "#")) + if h.count == 3 { h = h.map { "\($0)\($0)" }.joined() } + let n = UInt64(h, radix: 16) ?? 0 + let r = Double((n >> 16) & 0xFF) / 255 + let g = Double((n >> 8) & 0xFF) / 255 + let b = Double(n & 0xFF) / 255 + self.init(red: r, green: g, blue: b) + } +} diff --git a/project.yml b/project.yml new file mode 100644 index 0000000..dc0d203 --- /dev/null +++ b/project.yml @@ -0,0 +1,76 @@ +name: NASBackup +options: + bundleIdPrefix: com.albert + deploymentTarget: + iOS: "16.0" + xcodeVersion: "15.0" + createIntermediateGroups: true + +settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.albert.nasbackup + DEVELOPMENT_TEAM: "" + SWIFT_VERSION: "5.9" + IPHONEOS_DEPLOYMENT_TARGET: "16.0" + CODE_SIGN_STYLE: Automatic + TARGETED_DEVICE_FAMILY: "1,2" + CODE_SIGN_ENTITLEMENTS: NASBackup.entitlements + +packages: + SMBClient: + url: https://github.com/kishikawakatsumi/SMBClient + from: "0.3.0" + Citadel: + url: https://github.com/orlandos-nl/Citadel + exactVersion: "0.9.2" + +targets: + NASBackup: + type: application + platform: iOS + sources: + - App + - Core + - Services + - Features + - Shared + resources: + - Assets.xcassets + info: + path: App/Info.plist + properties: + CFBundleDisplayName: NASBackup + UILaunchStoryboardName: "" + UISupportedInterfaceOrientations: + - UIInterfaceOrientationPortrait + - UIInterfaceOrientationLandscapeLeft + - UIInterfaceOrientationLandscapeRight + UISupportedInterfaceOrientations~ipad: + - UIInterfaceOrientationPortrait + - UIInterfaceOrientationPortraitUpsideDown + - UIInterfaceOrientationLandscapeLeft + - UIInterfaceOrientationLandscapeRight + UIApplicationSceneManifest: + UIApplicationSupportsMultipleScenes: true + UISceneConfigurations: + UIWindowSceneSessionRoleApplication: + - UISceneConfigurationName: Default Configuration + UISceneDelegateClassName: $(PRODUCT_MODULE_NAME).SceneDelegate + BGTaskSchedulerPermittedIdentifiers: + - com.albert.nasbackup.backup + - com.albert.nasbackup.refresh + UIBackgroundModes: + - fetch + - processing + NSPhotoLibraryUsageDescription: NASBackup needs access to your photos to back them up to your NAS. + NSLocationWhenInUseUsageDescription: Used to detect your home Wi-Fi network name for automatic backup. + NSFaceIDUsageDescription: Sign in quickly with Face ID. + entitlements: + path: NASBackup.entitlements + properties: + com.apple.developer.networking.wifi-info: true + dependencies: + - package: SMBClient + product: SMBClient + - package: Citadel + product: Citadel