Initial scaffold — NASBackup iOS app

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

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

BIN
.DS_Store vendored Normal file

Binary file not shown.

34
App/AppDelegate.swift Normal file
View 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
View 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
View 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
View 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
}
}

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

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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"
}
}

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

View 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 {}
}
}

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

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

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

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

View 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"
}
}
}
}
}

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

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

View 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
View 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>

View 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 */;
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

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

View File

@@ -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>

View File

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

153
Services/BackupEngine.swift Normal file
View File

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

59
Services/LANMonitor.swift Normal file
View File

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

View File

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

100
Services/SFTPService.swift Normal file
View File

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

85
Services/SMBService.swift Normal file
View File

@@ -0,0 +1,85 @@
import Foundation
import SMBClient
final class SMBService: NASTransferProtocol {
private var client: SMBClient?
private(set) var isConnected: Bool = false
// remotePath format: "/ShareName/optional/subpath"
// splitPath returns ("ShareName", "optional/subpath")
private func splitPath(_ path: String) -> (share: String, relativePath: String) {
let trimmed = path.trimmingCharacters(in: .init(charactersIn: "/"))
let parts = trimmed.split(separator: "/", maxSplits: 1, omittingEmptySubsequences: true)
let share = parts.isEmpty ? "" : String(parts[0])
let rel = parts.count > 1 ? String(parts[1]) : ""
return (share, rel)
}
func connect(to host: String, port: Int, username: String, password: String) async throws {
let c = SMBClient(host: host, port: port)
do {
try await c.login(username: username, password: password)
} catch {
throw BackupError.authenticationFailed
}
self.client = c
self.isConnected = true
}
func disconnect() {
Task { try? await client?.logoff() }
client = nil
isConnected = false
}
func listDirectory(at path: String) async throws -> [NASItem] {
guard let client else { throw BackupError.connectionFailed("Not connected") }
let (share, rel) = splitPath(path)
try await client.connectShare(share)
let files = try await client.listDirectory(path: rel.isEmpty ? "" : rel)
return files.compactMap { file -> NASItem? in
guard file.name != "." && file.name != ".." else { return nil }
let fullPath = rel.isEmpty ? "/\(share)/\(file.name)" : "/\(share)/\(rel)/\(file.name)"
return NASItem(
name: file.name,
path: fullPath,
isDirectory: file.isDirectory,
size: Int64(file.size),
modifiedDate: file.lastWriteTime
)
}
}
func createDirectory(at path: String) async throws {
guard let client else { throw BackupError.connectionFailed("Not connected") }
let (share, rel) = splitPath(path)
try await client.connectShare(share)
try await client.createDirectory(path: rel)
}
func fileExists(at remotePath: String) async throws -> Bool {
guard let client else { throw BackupError.connectionFailed("Not connected") }
let (share, rel) = splitPath(remotePath)
try await client.connectShare(share)
let dir = (rel as NSString).deletingLastPathComponent
let filename = (rel as NSString).lastPathComponent
let files = try await client.listDirectory(path: dir)
return files.contains { $0.name == filename }
}
func upload(localURL: URL, remotePath: String, progress: @escaping (Int64, Int64) -> Void) async throws {
guard let client else { throw BackupError.connectionFailed("Not connected") }
let (share, rel) = splitPath(remotePath)
try await client.connectShare(share)
let filename = localURL.lastPathComponent
do {
try await client.upload(localPath: localURL, remotePath: rel) { _, _, bytesSent in
let total = (try? localURL.resourceValues(forKeys: [.fileSizeKey]).fileSize)
.flatMap { Int64($0) } ?? 0
progress(bytesSent, total)
}
} catch {
throw BackupError.uploadFailed(filename, underlying: error)
}
}
}

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

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

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

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

View 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
View 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