Initial scaffold — NASBackup iOS app
Full project scaffold: XcodeGen project.yml, Core models/protocols/errors, SMBService (kishikawakatsumi/SMBClient), SFTPService (Citadel 0.9.2), PhotoLibraryService, LANMonitor, BackupEngine, BackgroundTaskManager, all feature UIs (Login, Connect, Browse, Backup, History, Settings), Shared components and theme. Builds clean with zero errors. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
34
App/AppDelegate.swift
Normal file
34
App/AppDelegate.swift
Normal file
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
72
App/Info.plist
Normal file
72
App/Info.plist
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||||
|
<array>
|
||||||
|
<string>com.albert.nasbackup.backup</string>
|
||||||
|
<string>com.albert.nasbackup.refresh</string>
|
||||||
|
</array>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>NASBackup</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>1.0</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>1</string>
|
||||||
|
<key>NSFaceIDUsageDescription</key>
|
||||||
|
<string>Sign in quickly with Face ID.</string>
|
||||||
|
<key>NSLocationWhenInUseUsageDescription</key>
|
||||||
|
<string>Used to detect your home Wi-Fi network name for automatic backup.</string>
|
||||||
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
|
<string>NASBackup needs access to your photos to back them up to your NAS.</string>
|
||||||
|
<key>UIApplicationSceneManifest</key>
|
||||||
|
<dict>
|
||||||
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
|
<true/>
|
||||||
|
<key>UISceneConfigurations</key>
|
||||||
|
<dict>
|
||||||
|
<key>UIWindowSceneSessionRoleApplication</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>UISceneConfigurationName</key>
|
||||||
|
<string>Default Configuration</string>
|
||||||
|
<key>UISceneDelegateClassName</key>
|
||||||
|
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
<key>UIBackgroundModes</key>
|
||||||
|
<array>
|
||||||
|
<string>fetch</string>
|
||||||
|
<string>processing</string>
|
||||||
|
</array>
|
||||||
|
<key>UILaunchStoryboardName</key>
|
||||||
|
<string></string>
|
||||||
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
31
App/NASBackupApp.swift
Normal file
31
App/NASBackupApp.swift
Normal file
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
14
App/SceneDelegate.swift
Normal file
14
App/SceneDelegate.swift
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
11
Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
11
Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
6
Assets.xcassets/Contents.json
Normal file
6
Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
21
Assets.xcassets/nas_logo_clean.imageset/Contents.json
vendored
Normal file
21
Assets.xcassets/nas_logo_clean.imageset/Contents.json
vendored
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
Assets.xcassets/nas_logo_clean.imageset/nas_logo_clean.png
vendored
Normal file
BIN
Assets.xcassets/nas_logo_clean.imageset/nas_logo_clean.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
27
Core/Errors/BackupError.swift
Normal file
27
Core/Errors/BackupError.swift
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum BackupError: LocalizedError {
|
||||||
|
case connectionFailed(String)
|
||||||
|
case authenticationFailed
|
||||||
|
case photoLibraryDenied
|
||||||
|
case uploadFailed(String, underlying: Error)
|
||||||
|
case directoryListFailed(String)
|
||||||
|
case directoryCreateFailed(String)
|
||||||
|
case cancelled
|
||||||
|
case timeout
|
||||||
|
case networkUnavailable
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .connectionFailed(let host): return "Could not connect to \(host)"
|
||||||
|
case .authenticationFailed: return "Authentication failed — check username and password"
|
||||||
|
case .photoLibraryDenied: return "Photos access denied — update in iOS Settings"
|
||||||
|
case .uploadFailed(let file, _): return "Failed to upload \(file)"
|
||||||
|
case .directoryListFailed(let path):return "Could not list \(path)"
|
||||||
|
case .directoryCreateFailed(let p): return "Could not create folder at \(p)"
|
||||||
|
case .cancelled: return "Backup was cancelled"
|
||||||
|
case .timeout: return "Operation timed out"
|
||||||
|
case .networkUnavailable: return "Network not available"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
90
Core/Models/BackupJob.swift
Normal file
90
Core/Models/BackupJob.swift
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
enum BackupStatus: Equatable {
|
||||||
|
case idle
|
||||||
|
case preparing
|
||||||
|
case running
|
||||||
|
case paused
|
||||||
|
case completed
|
||||||
|
case failed(String)
|
||||||
|
case cancelled
|
||||||
|
|
||||||
|
var isActive: Bool {
|
||||||
|
switch self {
|
||||||
|
case .running, .paused, .preparing: return true
|
||||||
|
default: return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class BackupJob: ObservableObject {
|
||||||
|
@Published private(set) var status: BackupStatus = .idle
|
||||||
|
@Published private(set) var totalFiles: Int = 0
|
||||||
|
@Published private(set) var uploadedFiles: Int = 0
|
||||||
|
@Published private(set) var skippedFiles: Int = 0
|
||||||
|
@Published private(set) var failedFiles: Int = 0
|
||||||
|
@Published private(set) var currentFileName: String = ""
|
||||||
|
@Published private(set) var currentFileSizeBytes: Int64 = 0
|
||||||
|
@Published private(set) var bytesTransferred: Int64 = 0
|
||||||
|
@Published private(set) var totalBytes: Int64 = 0
|
||||||
|
@Published private(set) var speedBytesPerSecond: Double = 0
|
||||||
|
@Published private(set) var startDate: Date?
|
||||||
|
@Published private(set) var endDate: Date?
|
||||||
|
|
||||||
|
var progress: Double {
|
||||||
|
guard totalFiles > 0 else { return 0 }
|
||||||
|
return Double(uploadedFiles + skippedFiles + failedFiles) / Double(totalFiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
var eta: TimeInterval? {
|
||||||
|
guard speedBytesPerSecond > 0, totalBytes > bytesTransferred else { return nil }
|
||||||
|
return Double(totalBytes - bytesTransferred) / speedBytesPerSecond
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepare() { status = .preparing }
|
||||||
|
|
||||||
|
func start(totalFiles: Int, totalBytes: Int64) {
|
||||||
|
self.totalFiles = totalFiles
|
||||||
|
self.totalBytes = totalBytes
|
||||||
|
self.uploadedFiles = 0
|
||||||
|
self.skippedFiles = 0
|
||||||
|
self.failedFiles = 0
|
||||||
|
self.bytesTransferred = 0
|
||||||
|
self.startDate = Date()
|
||||||
|
self.endDate = nil
|
||||||
|
self.status = .running
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateProgress(fileName: String, fileSize: Int64, bytesTransferred: Int64, speed: Double) {
|
||||||
|
self.currentFileName = fileName
|
||||||
|
self.currentFileSizeBytes = fileSize
|
||||||
|
self.bytesTransferred = bytesTransferred
|
||||||
|
self.speedBytesPerSecond = speed
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileCompleted(skipped: Bool = false) {
|
||||||
|
if skipped { skippedFiles += 1 } else { uploadedFiles += 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileFailed() { failedFiles += 1 }
|
||||||
|
|
||||||
|
func finish(result: BackupResult) {
|
||||||
|
endDate = Date()
|
||||||
|
status = result.failedCount == 0 ? .completed : .completed
|
||||||
|
}
|
||||||
|
|
||||||
|
func fail(error: BackupError) {
|
||||||
|
endDate = Date()
|
||||||
|
status = .failed(error.errorDescription ?? error.localizedDescription)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancel() {
|
||||||
|
endDate = Date()
|
||||||
|
status = .cancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
func pause() { if case .running = status { status = .paused } }
|
||||||
|
func resume() { if case .paused = status { status = .running } }
|
||||||
|
}
|
||||||
41
Core/Models/BackupResult.swift
Normal file
41
Core/Models/BackupResult.swift
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct BackupResult {
|
||||||
|
let uploadedCount: Int
|
||||||
|
let skippedCount: Int
|
||||||
|
let failedCount: Int
|
||||||
|
let duration: TimeInterval
|
||||||
|
let totalBytes: Int64
|
||||||
|
let date: Date
|
||||||
|
|
||||||
|
var hasErrors: Bool { failedCount > 0 }
|
||||||
|
|
||||||
|
static func empty(date: Date = Date()) -> BackupResult {
|
||||||
|
BackupResult(uploadedCount: 0, skippedCount: 0, failedCount: 0,
|
||||||
|
duration: 0, totalBytes: 0, date: date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BackupHistoryEntry: Codable, Identifiable {
|
||||||
|
let id: UUID
|
||||||
|
let date: Date
|
||||||
|
let uploadedCount: Int
|
||||||
|
let skippedCount: Int
|
||||||
|
let failedCount: Int
|
||||||
|
let durationSeconds: Double
|
||||||
|
let totalBytes: Int64
|
||||||
|
let nasHost: String
|
||||||
|
let triggeredByLAN: Bool
|
||||||
|
|
||||||
|
init(result: BackupResult, nasHost: String, triggeredByLAN: Bool) {
|
||||||
|
self.id = UUID()
|
||||||
|
self.date = result.date
|
||||||
|
self.uploadedCount = result.uploadedCount
|
||||||
|
self.skippedCount = result.skippedCount
|
||||||
|
self.failedCount = result.failedCount
|
||||||
|
self.durationSeconds = result.duration
|
||||||
|
self.totalBytes = result.totalBytes
|
||||||
|
self.nasHost = nasHost
|
||||||
|
self.triggeredByLAN = triggeredByLAN
|
||||||
|
}
|
||||||
|
}
|
||||||
42
Core/Models/NASConnection.swift
Normal file
42
Core/Models/NASConnection.swift
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum NASProtocol: String, Codable, CaseIterable {
|
||||||
|
case smb = "SMB"
|
||||||
|
case sftp = "SFTP"
|
||||||
|
|
||||||
|
var defaultPort: Int {
|
||||||
|
switch self {
|
||||||
|
case .smb: return 445
|
||||||
|
case .sftp: return 22
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct NASConnection: Codable, Identifiable, Equatable {
|
||||||
|
var id: UUID = UUID()
|
||||||
|
var host: String
|
||||||
|
var port: Int
|
||||||
|
var nasProtocol: NASProtocol
|
||||||
|
var username: String
|
||||||
|
var password: String
|
||||||
|
var remotePath: String
|
||||||
|
var displayName: String
|
||||||
|
|
||||||
|
init(
|
||||||
|
host: String,
|
||||||
|
port: Int? = nil,
|
||||||
|
nasProtocol: NASProtocol = .smb,
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
remotePath: String = "/",
|
||||||
|
displayName: String = ""
|
||||||
|
) {
|
||||||
|
self.host = host
|
||||||
|
self.port = port ?? nasProtocol.defaultPort
|
||||||
|
self.nasProtocol = nasProtocol
|
||||||
|
self.username = username
|
||||||
|
self.password = password
|
||||||
|
self.remotePath = remotePath
|
||||||
|
self.displayName = displayName.isEmpty ? host : displayName
|
||||||
|
}
|
||||||
|
}
|
||||||
25
Core/Protocols/NASTransferProtocol.swift
Normal file
25
Core/Protocols/NASTransferProtocol.swift
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct NASItem: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let name: String
|
||||||
|
let path: String
|
||||||
|
let isDirectory: Bool
|
||||||
|
let size: Int64
|
||||||
|
let modifiedDate: Date?
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol NASTransferProtocol: AnyObject {
|
||||||
|
var isConnected: Bool { get }
|
||||||
|
|
||||||
|
func connect(to host: String, port: Int, username: String, password: String) async throws
|
||||||
|
func disconnect()
|
||||||
|
func listDirectory(at path: String) async throws -> [NASItem]
|
||||||
|
func createDirectory(at path: String) async throws
|
||||||
|
func fileExists(at remotePath: String) async throws -> Bool
|
||||||
|
func upload(
|
||||||
|
localURL: URL,
|
||||||
|
remotePath: String,
|
||||||
|
progress: @escaping (Int64, Int64) -> Void
|
||||||
|
) async throws
|
||||||
|
}
|
||||||
29
Core/Protocols/PhotoLibraryProtocol.swift
Normal file
29
Core/Protocols/PhotoLibraryProtocol.swift
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import Foundation
|
||||||
|
import Photos
|
||||||
|
|
||||||
|
struct PhotoAsset {
|
||||||
|
let localIdentifier: String
|
||||||
|
let filename: String
|
||||||
|
let creationDate: Date?
|
||||||
|
let mediaType: PHAssetMediaType
|
||||||
|
let pixelWidth: Int
|
||||||
|
let pixelHeight: Int
|
||||||
|
let duration: TimeInterval
|
||||||
|
let isScreenshot: Bool
|
||||||
|
let isRAW: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol PhotoLibraryProtocol: AnyObject {
|
||||||
|
var authorizationStatus: PHAuthorizationStatus { get }
|
||||||
|
func requestAuthorization() async -> PHAuthorizationStatus
|
||||||
|
func fetchAssets(filter: BackupFilter) -> [PhotoAsset]
|
||||||
|
func exportAsset(_ asset: PhotoAsset) async throws -> URL
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BackupFilter: Codable {
|
||||||
|
var includePhotos: Bool = true
|
||||||
|
var includeVideos: Bool = true
|
||||||
|
var includeScreenshots: Bool = false
|
||||||
|
var includeRAW: Bool = false
|
||||||
|
var newFilesOnly: Bool = true
|
||||||
|
}
|
||||||
301
Features/Backup/BackupView.swift
Normal file
301
Features/Backup/BackupView.swift
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
19
Features/Backup/BackupViewModel.swift
Normal file
19
Features/Backup/BackupViewModel.swift
Normal file
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
164
Features/Browse/BrowseView.swift
Normal file
164
Features/Browse/BrowseView.swift
Normal file
@@ -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 {}
|
||||||
|
}
|
||||||
|
}
|
||||||
157
Features/Connect/ConnectView.swift
Normal file
157
Features/Connect/ConnectView.swift
Normal file
@@ -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<String>, 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<String>) -> 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
53
Features/Connect/ConnectViewModel.swift
Normal file
53
Features/Connect/ConnectViewModel.swift
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
97
Features/History/HistoryView.swift
Normal file
97
Features/History/HistoryView.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
141
Features/Login/LoginView.swift
Normal file
141
Features/Login/LoginView.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
36
Features/Login/LoginViewModel.swift
Normal file
36
Features/Login/LoginViewModel.swift
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
73
Features/MainTabView.swift
Normal file
73
Features/MainTabView.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
152
Features/Settings/LANTriggerSection.swift
Normal file
152
Features/Settings/LANTriggerSection.swift
Normal file
@@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
156
Features/Settings/SettingsView.swift
Normal file
156
Features/Settings/SettingsView.swift
Normal file
@@ -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<Int>) -> 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
8
NASBackup.entitlements
Normal file
8
NASBackup.entitlements
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.developer.networking.wifi-info</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
597
NASBackup.xcodeproj/project.pbxproj
Normal file
597
NASBackup.xcodeproj/project.pbxproj
Normal file
@@ -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 = "<group>"; };
|
||||||
|
2B33F755DA5850D1FE3F695B /* AppTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTheme.swift; sourceTree = "<group>"; };
|
||||||
|
2BE4774FFA1074FF5F1FAB2B /* NASConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NASConnection.swift; sourceTree = "<group>"; };
|
||||||
|
46B47E3C76EA718ECF97BBAD /* PhotoLibraryService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoLibraryService.swift; sourceTree = "<group>"; };
|
||||||
|
47E762898E351192399FC739 /* BackupJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupJob.swift; sourceTree = "<group>"; };
|
||||||
|
4AEFE4959C08B94AE9584A11 /* BackupResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupResult.swift; sourceTree = "<group>"; };
|
||||||
|
4B67584BB6D705BCB3D40192 /* SFTPService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SFTPService.swift; sourceTree = "<group>"; };
|
||||||
|
4CBE4F65BE653C7C72E096D1 /* LANTriggerSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LANTriggerSection.swift; sourceTree = "<group>"; };
|
||||||
|
55EA454B783C2AEDABFB00A7 /* BackupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupView.swift; sourceTree = "<group>"; };
|
||||||
|
5606A4C5894CFC72D555CAE1 /* LANMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LANMonitor.swift; sourceTree = "<group>"; };
|
||||||
|
5643379BBA1FD39841F9EA55 /* BackgroundTaskManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundTaskManager.swift; sourceTree = "<group>"; };
|
||||||
|
6EEBCD4A838173E557DC3EFC /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = "<group>"; };
|
||||||
|
74E2AF8B56C469E0D94EFE94 /* PhotoLibraryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoLibraryProtocol.swift; sourceTree = "<group>"; };
|
||||||
|
824FADB40473BFC36FE91F0A /* BackupEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupEngine.swift; sourceTree = "<group>"; };
|
||||||
|
83C13DD3EB4823AA98D51AF2 /* FolderRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderRow.swift; sourceTree = "<group>"; };
|
||||||
|
8845003C87961174302AC990 /* ToggleRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToggleRow.swift; sourceTree = "<group>"; };
|
||||||
|
88E66C22B614E89A7A9E9B35 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||||
|
91197DD51221A3277AE318E2 /* ConnectViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectViewModel.swift; sourceTree = "<group>"; };
|
||||||
|
97D17A73A05A6FDF2B89C6B2 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
||||||
|
A8F24C7DD7A503A0D561F3FB /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = "<group>"; };
|
||||||
|
AC73AD75762D4357D171CDA4 /* BackupViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupViewModel.swift; sourceTree = "<group>"; };
|
||||||
|
B36E23852EF12DC14A2DF2DC /* NASBackupApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NASBackupApp.swift; sourceTree = "<group>"; };
|
||||||
|
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 = "<group>"; };
|
||||||
|
D87319355D902007618A91AE /* ConnectionStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionStore.swift; sourceTree = "<group>"; };
|
||||||
|
D947C8F8945A2AB081BBD0EF /* BrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseView.swift; sourceTree = "<group>"; };
|
||||||
|
DEBD92D10D70B957192E271F /* SMBService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMBService.swift; sourceTree = "<group>"; };
|
||||||
|
EE318B60576B11E80862664D /* ConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectView.swift; sourceTree = "<group>"; };
|
||||||
|
F03F0ED1A914F3392BFE5E4A /* BackupError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupError.swift; sourceTree = "<group>"; };
|
||||||
|
F09160B7B7A06BD900D0D87A /* MainTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabView.swift; sourceTree = "<group>"; };
|
||||||
|
F0AABE0AA63B1ACA1DEABB65 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
||||||
|
F1A42E8CFCAECFDBF6EDDAC0 /* LoginViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginViewModel.swift; sourceTree = "<group>"; };
|
||||||
|
F5CE897758E9CD5BB8B80F63 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||||
|
/* 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 = "<group>";
|
||||||
|
};
|
||||||
|
0A81FA8FFA5273F1D6E01775 = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
957299C0794188A819B70A3D /* App */,
|
||||||
|
C607CAC118F78B0703491238 /* Core */,
|
||||||
|
0A801C860EB4C4B22FD4D073 /* Features */,
|
||||||
|
ADFBF8F90DF8352BD2833A66 /* Services */,
|
||||||
|
31C8DD8F9077E2B6C67A99BA /* Shared */,
|
||||||
|
8308FE572D56B8045F17F6A0 /* Products */,
|
||||||
|
);
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
0B27021136C1E85D5B8CFF97 /* Persistence */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
D87319355D902007618A91AE /* ConnectionStore.swift */,
|
||||||
|
);
|
||||||
|
path = Persistence;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
147B04CA2B7A7723CCD13E93 /* Settings */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
4CBE4F65BE653C7C72E096D1 /* LANTriggerSection.swift */,
|
||||||
|
97D17A73A05A6FDF2B89C6B2 /* SettingsView.swift */,
|
||||||
|
);
|
||||||
|
path = Settings;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
1AD5A741A7466C3510901C60 /* Connect */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
EE318B60576B11E80862664D /* ConnectView.swift */,
|
||||||
|
91197DD51221A3277AE318E2 /* ConnectViewModel.swift */,
|
||||||
|
);
|
||||||
|
path = Connect;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
31C8DD8F9077E2B6C67A99BA /* Shared */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
3E7F8DE77B7CDC7009B54D63 /* Components */,
|
||||||
|
0B27021136C1E85D5B8CFF97 /* Persistence */,
|
||||||
|
C874CB05A914EE933FCFD5C2 /* Theme */,
|
||||||
|
);
|
||||||
|
path = Shared;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
3E7F8DE77B7CDC7009B54D63 /* Components */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
83C13DD3EB4823AA98D51AF2 /* FolderRow.swift */,
|
||||||
|
C955FEF6710E3667C7A73A02 /* ProgressRing.swift */,
|
||||||
|
8845003C87961174302AC990 /* ToggleRow.swift */,
|
||||||
|
);
|
||||||
|
path = Components;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
803154EA96E2431367806BAF /* Backup */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
55EA454B783C2AEDABFB00A7 /* BackupView.swift */,
|
||||||
|
AC73AD75762D4357D171CDA4 /* BackupViewModel.swift */,
|
||||||
|
);
|
||||||
|
path = Backup;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
814AF232F5D5D0A188D5C304 /* Protocols */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
055D92AB46FCDDF9C34B9D62 /* NASTransferProtocol.swift */,
|
||||||
|
74E2AF8B56C469E0D94EFE94 /* PhotoLibraryProtocol.swift */,
|
||||||
|
);
|
||||||
|
path = Protocols;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
8308FE572D56B8045F17F6A0 /* Products */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
C755BFDD42D46A3FB490A4BB /* NASBackup.app */,
|
||||||
|
);
|
||||||
|
name = Products;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
88D0BD5235374F2A264A0C96 /* History */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
6EEBCD4A838173E557DC3EFC /* HistoryView.swift */,
|
||||||
|
);
|
||||||
|
path = History;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
8B6D2A627469C6FA0E1FC2C9 /* Browse */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
D947C8F8945A2AB081BBD0EF /* BrowseView.swift */,
|
||||||
|
);
|
||||||
|
path = Browse;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
957299C0794188A819B70A3D /* App */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
88E66C22B614E89A7A9E9B35 /* AppDelegate.swift */,
|
||||||
|
F5CE897758E9CD5BB8B80F63 /* Info.plist */,
|
||||||
|
B36E23852EF12DC14A2DF2DC /* NASBackupApp.swift */,
|
||||||
|
F0AABE0AA63B1ACA1DEABB65 /* SceneDelegate.swift */,
|
||||||
|
);
|
||||||
|
path = App;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
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 = "<group>";
|
||||||
|
};
|
||||||
|
C560F307C8BE37F60E1105D0 /* Errors */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
F03F0ED1A914F3392BFE5E4A /* BackupError.swift */,
|
||||||
|
);
|
||||||
|
path = Errors;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
C607CAC118F78B0703491238 /* Core */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
C560F307C8BE37F60E1105D0 /* Errors */,
|
||||||
|
F75092ACBEF36F32816506F7 /* Models */,
|
||||||
|
814AF232F5D5D0A188D5C304 /* Protocols */,
|
||||||
|
);
|
||||||
|
path = Core;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
C874CB05A914EE933FCFD5C2 /* Theme */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
2B33F755DA5850D1FE3F695B /* AppTheme.swift */,
|
||||||
|
);
|
||||||
|
path = Theme;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
F75092ACBEF36F32816506F7 /* Models */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
47E762898E351192399FC739 /* BackupJob.swift */,
|
||||||
|
4AEFE4959C08B94AE9584A11 /* BackupResult.swift */,
|
||||||
|
2BE4774FFA1074FF5F1FAB2B /* NASConnection.swift */,
|
||||||
|
);
|
||||||
|
path = Models;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
FDF5327105186A6456CF0B08 /* Login */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
A8F24C7DD7A503A0D561F3FB /* LoginView.swift */,
|
||||||
|
F1A42E8CFCAECFDBF6EDDAC0 /* LoginViewModel.swift */,
|
||||||
|
);
|
||||||
|
path = Login;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* 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 */;
|
||||||
|
}
|
||||||
7
NASBackup.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
7
NASBackup.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "self:">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
||||||
@@ -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
|
||||||
|
}
|
||||||
BIN
NASBackup.xcodeproj/project.xcworkspace/xcuserdata/kutesir.xcuserdatad/UserInterfaceState.xcuserstate
generated
Normal file
BIN
NASBackup.xcodeproj/project.xcworkspace/xcuserdata/kutesir.xcuserdatad/UserInterfaceState.xcuserstate
generated
Normal file
Binary file not shown.
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>SchemeUserState</key>
|
||||||
|
<dict>
|
||||||
|
<key>NASBackup.xcscheme_^#shared#^_</key>
|
||||||
|
<dict>
|
||||||
|
<key>orderHint</key>
|
||||||
|
<integer>0</integer>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
72
Services/BackgroundTaskManager.swift
Normal file
72
Services/BackgroundTaskManager.swift
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import Foundation
|
||||||
|
import BackgroundTasks
|
||||||
|
|
||||||
|
final class BackgroundTaskManager {
|
||||||
|
static let backupTaskID = "com.albert.nasbackup.backup"
|
||||||
|
static let refreshTaskID = "com.albert.nasbackup.refresh"
|
||||||
|
|
||||||
|
static func registerTasks() {
|
||||||
|
BGTaskScheduler.shared.register(
|
||||||
|
forTaskWithIdentifier: backupTaskID,
|
||||||
|
using: nil
|
||||||
|
) { task in
|
||||||
|
guard let task = task as? BGProcessingTask else { return }
|
||||||
|
BackgroundTaskManager.handleBackupTask(task)
|
||||||
|
}
|
||||||
|
|
||||||
|
BGTaskScheduler.shared.register(
|
||||||
|
forTaskWithIdentifier: refreshTaskID,
|
||||||
|
using: nil
|
||||||
|
) { task in
|
||||||
|
guard let task = task as? BGAppRefreshTask else { return }
|
||||||
|
BackgroundTaskManager.handleRefreshTask(task)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func scheduleBackup(delay: TimeInterval = 30) {
|
||||||
|
let request = BGProcessingTaskRequest(identifier: backupTaskID)
|
||||||
|
request.requiresNetworkConnectivity = true
|
||||||
|
request.requiresExternalPower = false
|
||||||
|
request.earliestBeginDate = Date(timeIntervalSinceNow: delay)
|
||||||
|
do {
|
||||||
|
try BGTaskScheduler.shared.submit(request)
|
||||||
|
} catch {
|
||||||
|
print("BGTask schedule failed: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func scheduleAppRefresh() {
|
||||||
|
let request = BGAppRefreshTaskRequest(identifier: refreshTaskID)
|
||||||
|
request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60)
|
||||||
|
do {
|
||||||
|
try BGTaskScheduler.shared.submit(request)
|
||||||
|
} catch {
|
||||||
|
print("BGRefresh schedule failed: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func handleBackupTask(_ task: BGProcessingTask) {
|
||||||
|
scheduleBackup()
|
||||||
|
let engine = BackupEngine.shared
|
||||||
|
task.expirationHandler = { Task { await engine.cancel() } }
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let store = ConnectionStore.shared
|
||||||
|
guard let connection = store.savedConnection else {
|
||||||
|
task.setTaskCompleted(success: false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let filter = store.backupFilter
|
||||||
|
_ = try await engine.run(connection: connection, filter: filter, triggeredByLAN: true)
|
||||||
|
task.setTaskCompleted(success: true)
|
||||||
|
} catch {
|
||||||
|
task.setTaskCompleted(success: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func handleRefreshTask(_ task: BGAppRefreshTask) {
|
||||||
|
scheduleAppRefresh()
|
||||||
|
task.setTaskCompleted(success: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
153
Services/BackupEngine.swift
Normal file
153
Services/BackupEngine.swift
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import Foundation
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class BackupEngine: ObservableObject {
|
||||||
|
static let shared = BackupEngine()
|
||||||
|
|
||||||
|
@Published private(set) var job: BackupJob = BackupJob()
|
||||||
|
|
||||||
|
private let photoService: PhotoLibraryProtocol = PhotoLibraryService()
|
||||||
|
private var activeTransfer: (any NASTransferProtocol)?
|
||||||
|
private var isCancelled = false
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
func run(
|
||||||
|
connection: NASConnection,
|
||||||
|
filter: BackupFilter,
|
||||||
|
triggeredByLAN: Bool = false
|
||||||
|
) async throws -> BackupResult {
|
||||||
|
isCancelled = false
|
||||||
|
let startDate = Date()
|
||||||
|
|
||||||
|
// 1. Fetch assets
|
||||||
|
job.prepare()
|
||||||
|
let assets = photoService.fetchAssets(filter: filter)
|
||||||
|
|
||||||
|
// 2. Connect to NAS
|
||||||
|
let transfer: any NASTransferProtocol
|
||||||
|
switch connection.nasProtocol {
|
||||||
|
case .smb: transfer = SMBService()
|
||||||
|
case .sftp: transfer = SFTPService()
|
||||||
|
}
|
||||||
|
|
||||||
|
try await transfer.connect(
|
||||||
|
to: connection.host,
|
||||||
|
port: connection.port,
|
||||||
|
username: connection.username,
|
||||||
|
password: connection.password
|
||||||
|
)
|
||||||
|
activeTransfer = transfer
|
||||||
|
|
||||||
|
defer {
|
||||||
|
transfer.disconnect()
|
||||||
|
activeTransfer = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Start job tracking
|
||||||
|
job.start(totalFiles: assets.count, totalBytes: 0)
|
||||||
|
|
||||||
|
var uploaded = 0
|
||||||
|
var skipped = 0
|
||||||
|
var failed = 0
|
||||||
|
var totalBytes: Int64 = 0
|
||||||
|
var speedTracker = SpeedTracker()
|
||||||
|
|
||||||
|
// 4. Transfer loop
|
||||||
|
for asset in assets {
|
||||||
|
if isCancelled { break }
|
||||||
|
while case .paused = job.status {
|
||||||
|
try await Task.sleep(nanoseconds: 500_000_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
let remotePath = "\(connection.remotePath)/\(asset.filename)"
|
||||||
|
|
||||||
|
// Skip if exists (newFilesOnly = true in filter)
|
||||||
|
if filter.newFilesOnly, (try? await transfer.fileExists(at: remotePath)) == true {
|
||||||
|
job.fileCompleted(skipped: true)
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let localURL = try await photoService.exportAsset(asset)
|
||||||
|
defer { try? FileManager.default.removeItem(at: localURL) }
|
||||||
|
|
||||||
|
let fileSize = (try? localURL.resourceValues(forKeys: [.fileSizeKey]).fileSize).flatMap { Int64($0) } ?? 0
|
||||||
|
|
||||||
|
try await transfer.upload(localURL: localURL, remotePath: remotePath) { sent, total in
|
||||||
|
let speed = speedTracker.update(bytesSent: sent)
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
self?.job.updateProgress(
|
||||||
|
fileName: asset.filename,
|
||||||
|
fileSize: total,
|
||||||
|
bytesTransferred: totalBytes + sent,
|
||||||
|
speed: speed
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
totalBytes += fileSize
|
||||||
|
job.fileCompleted(skipped: false)
|
||||||
|
uploaded += 1
|
||||||
|
} catch {
|
||||||
|
job.fileFailed()
|
||||||
|
failed += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let duration = Date().timeIntervalSince(startDate)
|
||||||
|
let result = BackupResult(
|
||||||
|
uploadedCount: uploaded,
|
||||||
|
skippedCount: skipped,
|
||||||
|
failedCount: failed,
|
||||||
|
duration: duration,
|
||||||
|
totalBytes: totalBytes,
|
||||||
|
date: startDate
|
||||||
|
)
|
||||||
|
|
||||||
|
job.finish(result: result)
|
||||||
|
|
||||||
|
// 5. Persist history
|
||||||
|
let entry = BackupHistoryEntry(result: result, nasHost: connection.host, triggeredByLAN: triggeredByLAN)
|
||||||
|
ConnectionStore.shared.appendHistoryEntry(entry)
|
||||||
|
|
||||||
|
// 6. Push notification
|
||||||
|
await sendCompletionNotification(result: result)
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancel() {
|
||||||
|
isCancelled = true
|
||||||
|
job.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
func pause() { job.pause() }
|
||||||
|
func resume() { job.resume() }
|
||||||
|
|
||||||
|
private func sendCompletionNotification(result: BackupResult) async {
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = result.hasErrors ? "Backup completed with errors" : "Backup complete"
|
||||||
|
content.body = "\(result.uploadedCount) uploaded · \(result.skippedCount) skipped · \(result.failedCount) errors"
|
||||||
|
content.sound = .default
|
||||||
|
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
|
||||||
|
try? await UNUserNotificationCenter.current().add(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct SpeedTracker {
|
||||||
|
private var lastBytes: Int64 = 0
|
||||||
|
private var lastTime: Date = Date()
|
||||||
|
|
||||||
|
mutating func update(bytesSent: Int64) -> Double {
|
||||||
|
let now = Date()
|
||||||
|
let elapsed = now.timeIntervalSince(lastTime)
|
||||||
|
guard elapsed > 0.1 else { return 0 }
|
||||||
|
let speed = Double(bytesSent - lastBytes) / elapsed
|
||||||
|
lastBytes = bytesSent
|
||||||
|
lastTime = now
|
||||||
|
return max(0, speed)
|
||||||
|
}
|
||||||
|
}
|
||||||
59
Services/LANMonitor.swift
Normal file
59
Services/LANMonitor.swift
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import Foundation
|
||||||
|
import Network
|
||||||
|
import NetworkExtension
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class LANMonitor: ObservableObject {
|
||||||
|
static let shared = LANMonitor()
|
||||||
|
|
||||||
|
@Published private(set) var isOnNetwork: Bool = false
|
||||||
|
@Published private(set) var currentSSID: String? = nil
|
||||||
|
|
||||||
|
private let monitor = NWPathMonitor()
|
||||||
|
private let queue = DispatchQueue(label: "com.albert.nasbackup.lan", qos: .utility)
|
||||||
|
|
||||||
|
var onTrustedNetworkJoined: ((String) -> Void)?
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
monitor.pathUpdateHandler = { [weak self] path in
|
||||||
|
guard let self else { return }
|
||||||
|
Task { @MainActor in
|
||||||
|
let satisfied = path.status == .satisfied
|
||||||
|
self.isOnNetwork = satisfied
|
||||||
|
|
||||||
|
if satisfied {
|
||||||
|
await self.checkSSID()
|
||||||
|
} else {
|
||||||
|
self.currentSSID = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
monitor.start(queue: queue)
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
monitor.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func checkSSID() async {
|
||||||
|
let ssid = await fetchCurrentSSID()
|
||||||
|
self.currentSSID = ssid
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchCurrentSSID() async -> String? {
|
||||||
|
await withCheckedContinuation { continuation in
|
||||||
|
NEHotspotNetwork.fetchCurrent { network in
|
||||||
|
continuation.resume(returning: network?.ssid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkAndTriggerIfTrusted(trustedSSIDs: [String]) async {
|
||||||
|
let ssid = await fetchCurrentSSID()
|
||||||
|
guard let ssid, trustedSSIDs.contains(ssid) else { return }
|
||||||
|
onTrustedNetworkJoined?(ssid)
|
||||||
|
}
|
||||||
|
}
|
||||||
115
Services/PhotoLibraryService.swift
Normal file
115
Services/PhotoLibraryService.swift
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import Foundation
|
||||||
|
import Photos
|
||||||
|
|
||||||
|
final class PhotoLibraryService: PhotoLibraryProtocol {
|
||||||
|
var authorizationStatus: PHAuthorizationStatus {
|
||||||
|
PHPhotoLibrary.authorizationStatus(for: .readWrite)
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestAuthorization() async -> PHAuthorizationStatus {
|
||||||
|
await PHPhotoLibrary.requestAuthorization(for: .readWrite)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchAssets(filter: BackupFilter) -> [PhotoAsset] {
|
||||||
|
let options = PHFetchOptions()
|
||||||
|
options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: true)]
|
||||||
|
|
||||||
|
var subpredicates: [NSPredicate] = []
|
||||||
|
|
||||||
|
var mediaTypes: [PHAssetMediaType] = []
|
||||||
|
if filter.includePhotos { mediaTypes.append(.image) }
|
||||||
|
if filter.includeVideos { mediaTypes.append(.video) }
|
||||||
|
|
||||||
|
if !mediaTypes.isEmpty {
|
||||||
|
let typePredicate = NSPredicate(
|
||||||
|
format: "mediaType IN %@",
|
||||||
|
mediaTypes.map(\.rawValue)
|
||||||
|
)
|
||||||
|
subpredicates.append(typePredicate)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !filter.includeScreenshots {
|
||||||
|
subpredicates.append(NSPredicate(
|
||||||
|
format: "NOT ((mediaSubtype & %d) != 0)",
|
||||||
|
PHAssetMediaSubtype.photoScreenshot.rawValue
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
if subpredicates.count > 1 {
|
||||||
|
options.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: subpredicates)
|
||||||
|
} else {
|
||||||
|
options.predicate = subpredicates.first
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = PHAsset.fetchAssets(with: options)
|
||||||
|
var assets: [PhotoAsset] = []
|
||||||
|
result.enumerateObjects { asset, _, _ in
|
||||||
|
let isScreenshot = asset.mediaSubtypes.contains(.photoScreenshot)
|
||||||
|
let resources = PHAssetResource.assetResources(for: asset)
|
||||||
|
// RAW files are identified by an alternate photo resource (RAW+JPEG pairs)
|
||||||
|
let isRAW = resources.contains { $0.type == .alternatePhoto }
|
||||||
|
if isRAW && !filter.includeRAW { return }
|
||||||
|
|
||||||
|
let filename = resources.first?.originalFilename ?? "\(asset.localIdentifier).jpg"
|
||||||
|
|
||||||
|
assets.append(PhotoAsset(
|
||||||
|
localIdentifier: asset.localIdentifier,
|
||||||
|
filename: filename,
|
||||||
|
creationDate: asset.creationDate,
|
||||||
|
mediaType: asset.mediaType,
|
||||||
|
pixelWidth: asset.pixelWidth,
|
||||||
|
pixelHeight: asset.pixelHeight,
|
||||||
|
duration: asset.duration,
|
||||||
|
isScreenshot: isScreenshot,
|
||||||
|
isRAW: isRAW
|
||||||
|
))
|
||||||
|
}
|
||||||
|
return assets
|
||||||
|
}
|
||||||
|
|
||||||
|
func exportAsset(_ asset: PhotoAsset) async throws -> URL {
|
||||||
|
let fetchResult = PHAsset.fetchAssets(
|
||||||
|
withLocalIdentifiers: [asset.localIdentifier],
|
||||||
|
options: nil
|
||||||
|
)
|
||||||
|
guard let phAsset = fetchResult.firstObject else {
|
||||||
|
throw BackupError.uploadFailed(asset.filename, underlying: NSError(domain: "PhotoExport", code: -1))
|
||||||
|
}
|
||||||
|
|
||||||
|
let tmpURL = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent(UUID().uuidString)
|
||||||
|
.appendingPathExtension((asset.filename as NSString).pathExtension)
|
||||||
|
|
||||||
|
return try await withCheckedThrowingContinuation { continuation in
|
||||||
|
if phAsset.mediaType == .video {
|
||||||
|
let options = PHVideoRequestOptions()
|
||||||
|
options.isNetworkAccessAllowed = true
|
||||||
|
PHImageManager.default().requestAVAsset(forVideo: phAsset, options: options) { avAsset, _, _ in
|
||||||
|
guard let urlAsset = avAsset as? AVURLAsset else {
|
||||||
|
continuation.resume(throwing: BackupError.uploadFailed(asset.filename, underlying: NSError(domain: "AVExport", code: -1)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
continuation.resume(returning: urlAsset.url)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let options = PHImageRequestOptions()
|
||||||
|
options.isNetworkAccessAllowed = true
|
||||||
|
options.version = .current
|
||||||
|
options.deliveryMode = .highQualityFormat
|
||||||
|
|
||||||
|
PHImageManager.default().requestImageDataAndOrientation(for: phAsset, options: options) { data, _, _, _ in
|
||||||
|
guard let data else {
|
||||||
|
continuation.resume(throwing: BackupError.uploadFailed(asset.filename, underlying: NSError(domain: "PhotoExport", code: -2)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
try data.write(to: tmpURL)
|
||||||
|
continuation.resume(returning: tmpURL)
|
||||||
|
} catch {
|
||||||
|
continuation.resume(throwing: BackupError.uploadFailed(asset.filename, underlying: error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
100
Services/SFTPService.swift
Normal file
100
Services/SFTPService.swift
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import Foundation
|
||||||
|
import NIO
|
||||||
|
import Citadel
|
||||||
|
|
||||||
|
final class SFTPService: NASTransferProtocol {
|
||||||
|
private var client: SSHClient?
|
||||||
|
private var sftp: SFTPClient?
|
||||||
|
private(set) var isConnected: Bool = false
|
||||||
|
|
||||||
|
func connect(to host: String, port: Int, username: String, password: String) async throws {
|
||||||
|
do {
|
||||||
|
let c = try await SSHClient.connect(
|
||||||
|
host: host,
|
||||||
|
port: port,
|
||||||
|
authenticationMethod: .passwordBased(username: username, password: password),
|
||||||
|
hostKeyValidator: .acceptAnything(),
|
||||||
|
reconnect: .never
|
||||||
|
)
|
||||||
|
let sftpClient = try await c.openSFTP()
|
||||||
|
self.client = c
|
||||||
|
self.sftp = sftpClient
|
||||||
|
self.isConnected = true
|
||||||
|
} catch {
|
||||||
|
throw BackupError.connectionFailed(host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func disconnect() {
|
||||||
|
Task {
|
||||||
|
try? await sftp?.close()
|
||||||
|
client = nil
|
||||||
|
}
|
||||||
|
sftp = nil
|
||||||
|
isConnected = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func listDirectory(at path: String) async throws -> [NASItem] {
|
||||||
|
guard let sftp else { throw BackupError.connectionFailed("Not connected") }
|
||||||
|
do {
|
||||||
|
let batches = try await sftp.listDirectory(atPath: path)
|
||||||
|
// listDirectory returns [SFTPMessage.Name]; each Name contains multiple SFTPPathComponent
|
||||||
|
let components = batches.flatMap { $0.components }
|
||||||
|
return components.compactMap { component -> NASItem? in
|
||||||
|
guard component.filename != "." && component.filename != ".." else { return nil }
|
||||||
|
let fullPath = path.hasSuffix("/")
|
||||||
|
? "\(path)\(component.filename)"
|
||||||
|
: "\(path)/\(component.filename)"
|
||||||
|
let isDir = component.attributes.permissions.map { ($0 & 0o170000) == 0o040000 } ?? false
|
||||||
|
return NASItem(
|
||||||
|
name: component.filename,
|
||||||
|
path: fullPath,
|
||||||
|
isDirectory: isDir,
|
||||||
|
size: Int64(component.attributes.size ?? 0),
|
||||||
|
modifiedDate: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
throw BackupError.directoryListFailed(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createDirectory(at path: String) async throws {
|
||||||
|
guard let sftp else { throw BackupError.connectionFailed("Not connected") }
|
||||||
|
do {
|
||||||
|
try await sftp.createDirectory(atPath: path)
|
||||||
|
} catch {
|
||||||
|
throw BackupError.directoryCreateFailed(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileExists(at remotePath: String) async throws -> Bool {
|
||||||
|
guard let sftp else { throw BackupError.connectionFailed("Not connected") }
|
||||||
|
do {
|
||||||
|
_ = try await sftp.getAttributes(at: remotePath)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func upload(localURL: URL, remotePath: String, progress: @escaping (Int64, Int64) -> Void) async throws {
|
||||||
|
guard let sftp else { throw BackupError.connectionFailed("Not connected") }
|
||||||
|
let filename = localURL.lastPathComponent
|
||||||
|
do {
|
||||||
|
let data = try Data(contentsOf: localURL)
|
||||||
|
let total = Int64(data.count)
|
||||||
|
progress(0, total)
|
||||||
|
try await sftp.withFile(
|
||||||
|
filePath: remotePath,
|
||||||
|
flags: [.write, .create, .truncate]
|
||||||
|
) { file in
|
||||||
|
var buffer = ByteBuffer(data: data)
|
||||||
|
try await file.write(buffer, at: 0)
|
||||||
|
}
|
||||||
|
progress(total, total)
|
||||||
|
} catch {
|
||||||
|
throw BackupError.uploadFailed(filename, underlying: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
85
Services/SMBService.swift
Normal file
85
Services/SMBService.swift
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import Foundation
|
||||||
|
import SMBClient
|
||||||
|
|
||||||
|
final class SMBService: NASTransferProtocol {
|
||||||
|
private var client: SMBClient?
|
||||||
|
private(set) var isConnected: Bool = false
|
||||||
|
|
||||||
|
// remotePath format: "/ShareName/optional/subpath"
|
||||||
|
// splitPath returns ("ShareName", "optional/subpath")
|
||||||
|
private func splitPath(_ path: String) -> (share: String, relativePath: String) {
|
||||||
|
let trimmed = path.trimmingCharacters(in: .init(charactersIn: "/"))
|
||||||
|
let parts = trimmed.split(separator: "/", maxSplits: 1, omittingEmptySubsequences: true)
|
||||||
|
let share = parts.isEmpty ? "" : String(parts[0])
|
||||||
|
let rel = parts.count > 1 ? String(parts[1]) : ""
|
||||||
|
return (share, rel)
|
||||||
|
}
|
||||||
|
|
||||||
|
func connect(to host: String, port: Int, username: String, password: String) async throws {
|
||||||
|
let c = SMBClient(host: host, port: port)
|
||||||
|
do {
|
||||||
|
try await c.login(username: username, password: password)
|
||||||
|
} catch {
|
||||||
|
throw BackupError.authenticationFailed
|
||||||
|
}
|
||||||
|
self.client = c
|
||||||
|
self.isConnected = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func disconnect() {
|
||||||
|
Task { try? await client?.logoff() }
|
||||||
|
client = nil
|
||||||
|
isConnected = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func listDirectory(at path: String) async throws -> [NASItem] {
|
||||||
|
guard let client else { throw BackupError.connectionFailed("Not connected") }
|
||||||
|
let (share, rel) = splitPath(path)
|
||||||
|
try await client.connectShare(share)
|
||||||
|
let files = try await client.listDirectory(path: rel.isEmpty ? "" : rel)
|
||||||
|
return files.compactMap { file -> NASItem? in
|
||||||
|
guard file.name != "." && file.name != ".." else { return nil }
|
||||||
|
let fullPath = rel.isEmpty ? "/\(share)/\(file.name)" : "/\(share)/\(rel)/\(file.name)"
|
||||||
|
return NASItem(
|
||||||
|
name: file.name,
|
||||||
|
path: fullPath,
|
||||||
|
isDirectory: file.isDirectory,
|
||||||
|
size: Int64(file.size),
|
||||||
|
modifiedDate: file.lastWriteTime
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createDirectory(at path: String) async throws {
|
||||||
|
guard let client else { throw BackupError.connectionFailed("Not connected") }
|
||||||
|
let (share, rel) = splitPath(path)
|
||||||
|
try await client.connectShare(share)
|
||||||
|
try await client.createDirectory(path: rel)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileExists(at remotePath: String) async throws -> Bool {
|
||||||
|
guard let client else { throw BackupError.connectionFailed("Not connected") }
|
||||||
|
let (share, rel) = splitPath(remotePath)
|
||||||
|
try await client.connectShare(share)
|
||||||
|
let dir = (rel as NSString).deletingLastPathComponent
|
||||||
|
let filename = (rel as NSString).lastPathComponent
|
||||||
|
let files = try await client.listDirectory(path: dir)
|
||||||
|
return files.contains { $0.name == filename }
|
||||||
|
}
|
||||||
|
|
||||||
|
func upload(localURL: URL, remotePath: String, progress: @escaping (Int64, Int64) -> Void) async throws {
|
||||||
|
guard let client else { throw BackupError.connectionFailed("Not connected") }
|
||||||
|
let (share, rel) = splitPath(remotePath)
|
||||||
|
try await client.connectShare(share)
|
||||||
|
let filename = localURL.lastPathComponent
|
||||||
|
do {
|
||||||
|
try await client.upload(localPath: localURL, remotePath: rel) { _, _, bytesSent in
|
||||||
|
let total = (try? localURL.resourceValues(forKeys: [.fileSizeKey]).fileSize)
|
||||||
|
.flatMap { Int64($0) } ?? 0
|
||||||
|
progress(bytesSent, total)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
throw BackupError.uploadFailed(filename, underlying: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
38
Shared/Components/FolderRow.swift
Normal file
38
Shared/Components/FolderRow.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
26
Shared/Components/ProgressRing.swift
Normal file
26
Shared/Components/ProgressRing.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
35
Shared/Components/ToggleRow.swift
Normal file
35
Shared/Components/ToggleRow.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
82
Shared/Persistence/ConnectionStore.swift
Normal file
82
Shared/Persistence/ConnectionStore.swift
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
private func ud<T: Decodable>(_ 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<T: Encodable>(_ 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
|
||||||
|
}
|
||||||
|
}
|
||||||
48
Shared/Theme/AppTheme.swift
Normal file
48
Shared/Theme/AppTheme.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
76
project.yml
Normal file
76
project.yml
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user