diff --git a/App/NASBackupApp.swift b/App/NASBackupApp.swift
index 6dec0f9..95cb105 100644
--- a/App/NASBackupApp.swift
+++ b/App/NASBackupApp.swift
@@ -1,4 +1,5 @@
import SwiftUI
+import Photos
@main
struct NASBackupApp: App {
@@ -14,6 +15,7 @@ struct NASBackupApp: App {
.environmentObject(store)
.environmentObject(engine)
.environmentObject(lanMonitor)
+ .preferredColorScheme(store.appearanceMode.resolvedScheme)
}
}
}
@@ -22,10 +24,21 @@ struct RootView: View {
@EnvironmentObject var store: ConnectionStore
var body: some View {
- if store.savedConnection != nil {
- LoginView()
- } else {
- ConnectView()
+ Group {
+ if store.savedConnection == nil {
+ ConnectView()
+ } else if !store.isSessionActive {
+ LoginView()
+ } else if store.savedConnection?.remotePath == "/" {
+ FolderSetupView()
+ } else if !store.onboardingPhotoShown {
+ PhotoPermissionView()
+ } else {
+ MainTabView()
+ }
}
+ .animation(.spring(response: 0.4, dampingFraction: 0.85), value: store.isSessionActive)
+ .animation(.spring(response: 0.4, dampingFraction: 0.85), value: store.savedConnection?.remotePath)
+ .animation(.spring(response: 0.4, dampingFraction: 0.85), value: store.onboardingPhotoShown)
}
}
diff --git a/App/PrivacyInfo.xcprivacy b/App/PrivacyInfo.xcprivacy
new file mode 100644
index 0000000..04dd96c
--- /dev/null
+++ b/App/PrivacyInfo.xcprivacy
@@ -0,0 +1,44 @@
+
+
+
+
+ NSPrivacyAccessedAPITypes
+
+
+ NSPrivacyAccessedAPIType
+ NSPrivacyAccessedAPICategoryFileTimestamp
+ NSPrivacyAccessedAPITypeReasons
+
+ C617.1
+
+
+
+ NSPrivacyAccessedAPIType
+ NSPrivacyAccessedAPICategoryUserDefaults
+ NSPrivacyAccessedAPITypeReasons
+
+ CA92.1
+
+
+
+ NSPrivacyCollectedDataTypes
+
+
+ NSPrivacyCollectedDataType
+ NSPrivacyCollectedDataTypePhotosorVideos
+ NSPrivacyCollectedDataTypeLinked
+
+ NSPrivacyCollectedDataTypeTracking
+
+ NSPrivacyCollectedDataTypePurposes
+
+ NSPrivacyCollectedDataTypePurposeAppFunctionality
+
+
+
+ NSPrivacyTracking
+
+ NSPrivacyTrackingDomains
+
+
+
diff --git a/Assets.xcassets/ugreen_logo.imageset/Contents.json b/Assets.xcassets/ugreen_logo.imageset/Contents.json
new file mode 100644
index 0000000..bbc51d1
--- /dev/null
+++ b/Assets.xcassets/ugreen_logo.imageset/Contents.json
@@ -0,0 +1,23 @@
+{
+ "images" : [
+ {
+ "filename" : "ugreen_logo.png",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "filename" : "ugreen_logo 1.png",
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "filename" : "ugreen_logo 2.png",
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Assets.xcassets/ugreen_logo.imageset/ugreen_logo 1.png b/Assets.xcassets/ugreen_logo.imageset/ugreen_logo 1.png
new file mode 100644
index 0000000..07ffced
Binary files /dev/null and b/Assets.xcassets/ugreen_logo.imageset/ugreen_logo 1.png differ
diff --git a/Assets.xcassets/ugreen_logo.imageset/ugreen_logo 2.png b/Assets.xcassets/ugreen_logo.imageset/ugreen_logo 2.png
new file mode 100644
index 0000000..07ffced
Binary files /dev/null and b/Assets.xcassets/ugreen_logo.imageset/ugreen_logo 2.png differ
diff --git a/Assets.xcassets/ugreen_logo.imageset/ugreen_logo.png b/Assets.xcassets/ugreen_logo.imageset/ugreen_logo.png
new file mode 100644
index 0000000..07ffced
Binary files /dev/null and b/Assets.xcassets/ugreen_logo.imageset/ugreen_logo.png differ
diff --git a/Assets.xcassets/ugreen_logo.png b/Assets.xcassets/ugreen_logo.png
new file mode 100644
index 0000000..07ffced
Binary files /dev/null and b/Assets.xcassets/ugreen_logo.png differ
diff --git a/Core/Models/NASConnection.swift b/Core/Models/NASConnection.swift
index b959b8e..2c277b4 100644
--- a/Core/Models/NASConnection.swift
+++ b/Core/Models/NASConnection.swift
@@ -10,6 +10,8 @@ enum NASProtocol: String, Codable, CaseIterable {
case .sftp: return 22
}
}
+
+ var localizedDescription: String { rawValue }
}
struct NASConnection: Codable, Identifiable, Equatable {
@@ -18,10 +20,15 @@ struct NASConnection: Codable, Identifiable, Equatable {
var port: Int
var nasProtocol: NASProtocol
var username: String
- var password: String
+ var password: String // Runtime only — persisted in Keychain, not UserDefaults
var remotePath: String
var displayName: String
+ // Excludes password so it is never written to UserDefaults.
+ enum CodingKeys: String, CodingKey {
+ case id, host, port, nasProtocol, username, remotePath, displayName
+ }
+
init(
host: String,
port: Int? = nil,
@@ -39,4 +46,16 @@ struct NASConnection: Codable, Identifiable, Equatable {
self.remotePath = remotePath
self.displayName = displayName.isEmpty ? host : displayName
}
+
+ init(from decoder: Decoder) throws {
+ let c = try decoder.container(keyedBy: CodingKeys.self)
+ id = try c.decode(UUID.self, forKey: .id)
+ host = try c.decode(String.self, forKey: .host)
+ port = try c.decode(Int.self, forKey: .port)
+ nasProtocol = try c.decode(NASProtocol.self, forKey: .nasProtocol)
+ username = try c.decode(String.self, forKey: .username)
+ remotePath = try c.decode(String.self, forKey: .remotePath)
+ displayName = try c.decode(String.self, forKey: .displayName)
+ password = "" // filled from Keychain by SavedConnections.load()
+ }
}
diff --git a/Features/Backup/BackupView.swift b/Features/Backup/BackupView.swift
index a5ff198..24ed423 100644
--- a/Features/Backup/BackupView.swift
+++ b/Features/Backup/BackupView.swift
@@ -8,106 +8,117 @@ struct BackupView: View {
var body: some View {
ZStack {
- Color.white.ignoresSafeArea()
+ AppTheme.background.ignoresSafeArea()
- ScrollView {
- VStack(spacing: AppTheme.sectionGap) {
- ringSection
- nasRow
- optionsCard
- actionButton
+ VStack(spacing: 0) {
+ Spacer(minLength: 0)
+
+ // ─── Ring hero ───────────────────────────────────────
+ ringHero
+ .padding(.bottom, 28)
+
+ // ─── Completion summary (only on .completed) ─────────
+ if case .completed = engine.job.status {
+ completionStrip
+ .padding(.bottom, 28)
+ .transition(.opacity.combined(with: .scale(scale: 0.97)))
}
- .padding(.horizontal, AppTheme.hPad)
- .padding(.top, 24)
- .padding(.bottom, 48)
+
+ Spacer(minLength: 0)
+
+ // ─── NAS status ───────────────────────────────────────
+ nasStatusRow
+ .padding(.horizontal, AppTheme.hPad)
+ .padding(.bottom, 16)
+
+ // ─── Photos access nudge ──────────────────────────────
+ if vm.photosAuthStatus != .authorized && vm.photosAuthStatus != .limited {
+ photosAccessRow
+ .padding(.horizontal, AppTheme.hPad)
+ .padding(.bottom, 16)
+ .transition(.opacity.combined(with: .move(edge: .bottom)))
+ }
+
+ // ─── Action ───────────────────────────────────────────
+ actionButton
+ .padding(.horizontal, AppTheme.hPad)
+ .padding(.bottom, 32)
}
}
+ .animation(.spring(response: 0.35, dampingFraction: 0.82), value: engine.job.status == .completed)
+ .animation(.spring(response: 0.35, dampingFraction: 0.82), value: vm.photosAuthStatus == .authorized)
.task { vm.loadPhotoCount(filter: store.backupFilter) }
}
- // MARK: — Ring
+ // MARK: — Ring hero
- private var ringSection: some View {
- VStack(spacing: 20) {
- ZStack {
- ProgressRing(
- progress: engine.job.progress,
- lineWidth: 8,
- size: 180,
- color: ringColor,
- backgroundColor: Color(hex: "#EBEBEB")
- )
+ private var ringHero: some View {
+ ZStack {
+ ProgressRing(
+ progress: engine.job.progress,
+ lineWidth: 7,
+ size: 220,
+ color: ringColor,
+ backgroundColor: AppTheme.inkQuaternary.opacity(0.4)
+ )
+ .accessibilityLabel("Backup progress")
+ .accessibilityValue("\(Int(engine.job.progress * 100)) percent")
- VStack(spacing: 3) {
- Text(percentText)
- .font(.system(size: 40, weight: .semibold, design: .rounded))
- .foregroundStyle(AppTheme.ink)
- .contentTransition(.numericText())
+ VStack(spacing: 4) {
+ Text(percentText)
+ .font(.system(size: 44, weight: .semibold, design: .rounded))
+ .foregroundStyle(AppTheme.ink)
+ .contentTransition(.numericText())
+ .monospacedDigit()
- if case .running = engine.job.status {
- Text("\(engine.job.uploadedFiles + engine.job.skippedFiles) of \(engine.job.totalFiles)")
- .font(AppTheme.caption())
- .foregroundStyle(AppTheme.inkSecondary)
- if let eta = engine.job.eta {
- Text("~\(etaString(eta)) remaining")
- .font(.system(size: 11, weight: .regular))
- .foregroundStyle(AppTheme.inkTertiary)
- }
- } else {
- Text("\(vm.totalPhotoCount) photos")
- .font(AppTheme.caption())
- .foregroundStyle(AppTheme.inkSecondary)
- }
- }
- }
-
- // Completion summary
- if case .completed = engine.job.status {
- HStack(spacing: 0) {
- statCell("\(engine.job.uploadedFiles)", label: "uploaded")
- dividerLine
- statCell("\(engine.job.skippedFiles)", label: "skipped")
- dividerLine
- statCell("\(engine.job.failedFiles)", label: "errors",
- color: engine.job.failedFiles > 0 ? AppTheme.destructive : AppTheme.inkTertiary)
- }
- .background(AppTheme.surfaceSunken)
- .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
- .transition(.opacity.combined(with: .scale(scale: 0.95)))
- }
-
- // Progress detail
- if case .running = engine.job.status {
- VStack(spacing: 8) {
- GeometryReader { geo in
- ZStack(alignment: .leading) {
- RoundedRectangle(cornerRadius: 2, style: .continuous)
- .fill(Color(hex: "#EBEBEB"))
- .frame(height: 3)
- RoundedRectangle(cornerRadius: 2, style: .continuous)
- .fill(AppTheme.ink)
- .frame(width: geo.size.width * engine.job.progress, height: 3)
- .animation(.spring(response: 0.4, dampingFraction: 0.8), value: engine.job.progress)
- }
- }
- .frame(height: 3)
-
- HStack {
- Text(engine.job.currentFileName)
- .font(.system(size: 12, weight: .regular))
- .foregroundStyle(AppTheme.inkSecondary)
- .lineLimit(1)
- .truncationMode(.middle)
- Spacer()
- Text(formatSpeed(engine.job.speedBytesPerSecond))
- .font(.system(size: 12, weight: .regular))
- .foregroundStyle(AppTheme.inkTertiary)
- }
- }
- .transition(.opacity)
+ ringSubtitle
+ .transition(.opacity)
}
}
- .animation(.spring(response: 0.4, dampingFraction: 0.8), value: engine.job.status == .completed)
+ .animation(.spring(response: 0.4, dampingFraction: 0.8), value: engine.job.progress)
+ }
+
+ @ViewBuilder
+ private var ringSubtitle: some View {
+ switch engine.job.status {
+ case .running:
+ VStack(spacing: 2) {
+ Text("\(engine.job.uploadedFiles + engine.job.skippedFiles) of \(engine.job.totalFiles)")
+ .font(AppTheme.caption())
+ .foregroundStyle(AppTheme.inkSecondary)
+ if let eta = engine.job.eta {
+ Text("~\(etaString(eta)) remaining")
+ .font(.system(size: 11, weight: .regular))
+ .foregroundStyle(AppTheme.inkTertiary)
+ }
+ Text(engine.job.currentFileName)
+ .font(.system(size: 11, weight: .regular))
+ .foregroundStyle(AppTheme.inkTertiary)
+ .lineLimit(1)
+ .truncationMode(.middle)
+ .frame(maxWidth: 160)
+ }
+ case .preparing:
+ Text("Preparing…")
+ .font(AppTheme.caption())
+ .foregroundStyle(AppTheme.inkTertiary)
+ case .completed:
+ Text("Done")
+ .font(.system(size: 13, weight: .medium))
+ .foregroundStyle(AppTheme.positive)
+ case .failed:
+ Text("Failed")
+ .font(.system(size: 13, weight: .medium))
+ .foregroundStyle(AppTheme.destructive)
+ case .cancelled:
+ Text("Cancelled")
+ .font(AppTheme.caption())
+ .foregroundStyle(AppTheme.inkTertiary)
+ default:
+ Text(vm.totalPhotoCount > 0 ? "\(vm.totalPhotoCount) photos ready" : "Ready")
+ .font(AppTheme.caption())
+ .foregroundStyle(AppTheme.inkTertiary)
+ }
}
private var ringColor: Color {
@@ -120,11 +131,32 @@ struct BackupView: View {
private var percentText: String { "\(Int(engine.job.progress * 100))%" }
- private func statCell(_ value: String, label: String, color: Color = AppTheme.ink) -> some View {
- VStack(spacing: 2) {
+ // MARK: — Completion strip
+
+ private var completionStrip: some View {
+ HStack(spacing: 0) {
+ statCell("\(engine.job.uploadedFiles)", label: "uploaded")
+ thinDivider
+ statCell("\(engine.job.skippedFiles)", label: "skipped")
+ thinDivider
+ statCell(
+ "\(engine.job.failedFiles)",
+ label: "errors",
+ valueColor: engine.job.failedFiles > 0 ? AppTheme.destructive : AppTheme.ink
+ )
+ }
+ .padding(.horizontal, AppTheme.hPad)
+ .background(AppTheme.surfaceSunken)
+ .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
+ .padding(.horizontal, AppTheme.hPad)
+ }
+
+ private func statCell(_ value: String, label: String, valueColor: Color = AppTheme.ink) -> some View {
+ VStack(spacing: 3) {
Text(value)
- .font(.system(size: 18, weight: .semibold))
- .foregroundStyle(color)
+ .font(.system(size: 17, weight: .semibold))
+ .foregroundStyle(valueColor)
+ .monospacedDigit()
Text(label)
.font(AppTheme.micro())
.foregroundStyle(AppTheme.inkTertiary)
@@ -133,21 +165,21 @@ struct BackupView: View {
.padding(.vertical, 14)
}
- private var dividerLine: some View {
+ private var thinDivider: some View {
Rectangle()
.fill(AppTheme.separator)
.frame(width: 0.5)
- .padding(.vertical, 12)
+ .padding(.vertical, 10)
}
- // MARK: — NAS row
+ // MARK: — NAS status row
- private var nasRow: some View {
+ private var nasStatusRow: some View {
HStack(spacing: 10) {
ZStack {
Circle()
.fill(AppTheme.surfaceSunken)
- .frame(width: 32, height: 32)
+ .frame(width: 34, height: 34)
Image(systemName: "externaldrive")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(AppTheme.inkSecondary)
@@ -158,98 +190,54 @@ struct BackupView: View {
Text(conn.host)
.font(AppTheme.headline())
.foregroundStyle(AppTheme.ink)
- Text(conn.username)
- .font(AppTheme.caption())
- .foregroundStyle(AppTheme.inkSecondary)
+ Text(conn.nasProtocol.rawValue)
+ .font(AppTheme.micro(10))
+ .foregroundStyle(AppTheme.inkTertiary)
}
+ } else {
+ Text("No NAS configured")
+ .font(AppTheme.body())
+ .foregroundStyle(AppTheme.inkTertiary)
}
Spacer()
HStack(spacing: 5) {
Circle()
- .fill(AppTheme.positive)
+ .fill(engine.job.status.isActive ? AppTheme.positive : AppTheme.inkQuaternary)
.frame(width: 6, height: 6)
- Text("Live")
+ Text(engine.job.status.isActive ? "Active" : "Ready")
.font(AppTheme.micro())
- .foregroundStyle(AppTheme.positive)
+ .foregroundStyle(engine.job.status.isActive ? AppTheme.positive : AppTheme.inkTertiary)
}
}
- .padding(14)
+ .padding(.horizontal, 14)
+ .padding(.vertical, 12)
.background(AppTheme.surfaceSunken)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
}
- // MARK: — Options
+ // MARK: — Photos access nudge
- private var optionsCard: some View {
- VStack(spacing: 0) {
- optionRow(icon: "camera", label: "What to back up") {}
- hairline
- optionRow(icon: "doc.badge.clock", label: "New files only") {}
- hairline
- Button(action: { Task { await vm.requestPhotosAccess() } }) {
- HStack {
- Image(systemName: "photo.on.rectangle")
- .font(.system(size: 14, weight: .regular))
- .foregroundStyle(vm.photosAuthStatus == .authorized
- ? AppTheme.inkSecondary : AppTheme.destructive)
- .frame(width: 20)
- Text("Photos access")
- .font(AppTheme.body())
- .foregroundStyle(AppTheme.ink)
- Spacer()
- Text(photosLabel)
- .font(AppTheme.caption())
- .foregroundStyle(vm.photosAuthStatus == .authorized
- ? AppTheme.positive : AppTheme.destructive)
- Image(systemName: "chevron.right")
- .font(.system(size: 12, weight: .medium))
- .foregroundStyle(AppTheme.inkQuaternary)
- }
- .padding(.horizontal, 16)
- .padding(.vertical, 14)
- }
- .buttonStyle(ScaleButtonStyle())
- }
- .background(Color.white)
- .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
- .shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
- }
-
- private var photosLabel: String {
- switch vm.photosAuthStatus {
- case .authorized: return "Full access"
- case .limited: return "Limited"
- case .denied: return "Denied"
- default: return "Not set"
- }
- }
-
- private var hairline: some View {
- Rectangle()
- .fill(AppTheme.separator)
- .frame(height: 0.5)
- .padding(.leading, 36)
- }
-
- private func optionRow(icon: String, label: String, action: @escaping () -> Void) -> some View {
- Button(action: action) {
- HStack {
- Image(systemName: icon)
- .font(.system(size: 14, weight: .regular))
- .foregroundStyle(AppTheme.inkSecondary)
+ private var photosAccessRow: some View {
+ Button(action: { Task { await vm.requestPhotosAccess() } }) {
+ HStack(spacing: 10) {
+ Image(systemName: "photo.on.rectangle")
+ .font(.system(size: 13, weight: .medium))
+ .foregroundStyle(AppTheme.destructive)
.frame(width: 20)
- Text(label)
- .font(AppTheme.body())
- .foregroundStyle(AppTheme.ink)
+ Text("Grant photo library access to enable backup")
+ .font(AppTheme.caption())
+ .foregroundStyle(AppTheme.inkSecondary)
Spacer()
Image(systemName: "chevron.right")
- .font(.system(size: 12, weight: .medium))
+ .font(.system(size: 11, weight: .medium))
.foregroundStyle(AppTheme.inkQuaternary)
}
- .padding(.horizontal, 16)
- .padding(.vertical, 14)
+ .padding(.horizontal, 14)
+ .padding(.vertical, 11)
+ .background(AppTheme.destructive.opacity(0.06))
+ .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
}
.buttonStyle(ScaleButtonStyle())
}
@@ -266,24 +254,27 @@ struct BackupView: View {
.buttonStyle(PrimaryButtonStyle(
color: engine.job.status == .completed ? AppTheme.positive : AppTheme.ink
))
+ .accessibilityHint("Starts backing up your photos to the NAS")
case .running:
Button { engine.pause() } label: { Text("Pause") }
.buttonStyle(GhostButtonStyle())
+ .accessibilityLabel("Pause backup")
case .paused:
HStack(spacing: 10) {
Button { engine.resume() } label: { Text("Resume") }
.buttonStyle(PrimaryButtonStyle())
+ .accessibilityLabel("Resume backup")
Button { engine.cancel() } label: { Text("Cancel") }
.buttonStyle(GhostButtonStyle())
.frame(width: 90)
+ .accessibilityLabel("Cancel backup")
}
case .preparing:
HStack(spacing: 8) {
- ProgressView()
- .scaleEffect(0.8)
+ ProgressView().scaleEffect(0.8).tint(AppTheme.inkSecondary)
Text("Preparing…")
.font(AppTheme.body())
.foregroundStyle(AppTheme.inkSecondary)
@@ -291,7 +282,7 @@ struct BackupView: View {
.frame(height: 50)
}
}
- .animation(.spring(response: 0.3, dampingFraction: 0.8), value: engine.job.status == .running)
+ .animation(.spring(response: 0.3, dampingFraction: 0.82), value: engine.job.status == .running)
}
private func startBackup() {
@@ -299,12 +290,6 @@ struct BackupView: View {
Task { _ = try? await engine.run(connection: conn, filter: store.backupFilter) }
}
- private func formatSpeed(_ bps: Double) -> String {
- bps >= 1_000_000
- ? String(format: "%.1f MB/s", bps / 1_000_000)
- : String(format: "%.0f KB/s", bps / 1_000)
- }
-
private func etaString(_ seconds: TimeInterval) -> String {
Int(seconds) / 60 > 0 ? "\(Int(seconds) / 60)m" : "\(Int(seconds))s"
}
diff --git a/Features/Browse/BrowseView.swift b/Features/Browse/BrowseView.swift
index 0827931..d8ba12d 100644
--- a/Features/Browse/BrowseView.swift
+++ b/Features/Browse/BrowseView.swift
@@ -15,7 +15,7 @@ struct BrowseView: View {
var body: some View {
NavigationStack {
ZStack {
- Color.white.ignoresSafeArea()
+ AppTheme.background.ignoresSafeArea()
VStack(spacing: 0) {
// Path bar
@@ -40,21 +40,31 @@ struct BrowseView: View {
if isLoading {
Spacer()
- ProgressView()
- .tint(AppTheme.inkTertiary)
+ VStack(spacing: 10) {
+ ProgressView()
+ .tint(AppTheme.inkTertiary)
+ Text("Loading…")
+ .font(AppTheme.caption())
+ .foregroundStyle(AppTheme.inkTertiary)
+ }
Spacer()
} else if let error {
Spacer()
VStack(spacing: 12) {
Image(systemName: "exclamationmark.triangle")
- .font(.system(size: 28, weight: .light))
+ .font(.system(size: 28, weight: .ultraLight))
.foregroundStyle(AppTheme.inkQuaternary)
- Text(error)
- .font(AppTheme.caption())
- .foregroundStyle(AppTheme.inkSecondary)
- .multilineTextAlignment(.center)
+ VStack(spacing: 4) {
+ Text("Could not load folder")
+ .font(.system(size: 14, weight: .medium))
+ .foregroundStyle(AppTheme.inkSecondary)
+ Text(error)
+ .font(AppTheme.caption())
+ .foregroundStyle(AppTheme.inkTertiary)
+ .multilineTextAlignment(.center)
+ }
}
- .padding()
+ .padding(.horizontal, 32)
Spacer()
} else {
List {
diff --git a/Features/Connect/ConnectView.swift b/Features/Connect/ConnectView.swift
index 09df8e2..98e3f0b 100644
--- a/Features/Connect/ConnectView.swift
+++ b/Features/Connect/ConnectView.swift
@@ -9,30 +9,33 @@ struct ConnectView: View {
var body: some View {
NavigationStack {
ZStack {
- AppTheme.surfaceSunken.ignoresSafeArea()
+ AppTheme.background.ignoresSafeArea()
ScrollView {
VStack(spacing: 0) {
- // Logo + title
- VStack(spacing: 10) {
+ // Header
+ VStack(spacing: 20) {
+ // App icon
Image("nas_logo_clean")
.resizable()
.scaledToFit()
- .frame(width: 44, height: 44)
- .clipShape(RoundedRectangle(cornerRadius: 11, style: .continuous))
+ .frame(width: 52, height: 52)
+ .clipShape(RoundedRectangle(cornerRadius: 13, style: .continuous))
+ .shadow(color: .black.opacity(0.08), radius: 14, x: 0, y: 4)
.padding(.top, 44)
- Text("NAS Connect")
- .font(.system(size: 26, weight: .semibold))
- .foregroundStyle(AppTheme.ink)
+ // Title
+ VStack(spacing: 6) {
+ Text("Kisani")
+ .font(.system(size: 26, weight: .semibold))
+ .foregroundStyle(AppTheme.ink)
- Text("Connect to your UGreen or any SMB / SFTP NAS")
- .font(AppTheme.caption())
- .foregroundStyle(AppTheme.inkTertiary)
- .multilineTextAlignment(.center)
+ // UGreen NAS compatibility badge
+ UGreenCompatibilityBadge()
+ }
}
- .padding(.bottom, 32)
+ .padding(.bottom, 28)
.padding(.horizontal, AppTheme.hPad)
// Protocol picker
@@ -50,7 +53,7 @@ struct ConnectView: View {
.padding(.vertical, 7)
.background(
RoundedRectangle(cornerRadius: 7, style: .continuous)
- .fill(vm.selectedProtocol == proto ? Color.white : Color.clear)
+ .fill(vm.selectedProtocol == proto ? AppTheme.surfaceRaised : Color.clear)
.shadow(
color: vm.selectedProtocol == proto ? .black.opacity(0.06) : .clear,
radius: 3, x: 0, y: 1
@@ -61,7 +64,7 @@ struct ConnectView: View {
}
}
.padding(3)
- .background(Color.black.opacity(0.06))
+ .background(AppTheme.surfaceSunken)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 20)
@@ -90,19 +93,14 @@ struct ConnectView: View {
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
- .background(Color.white)
+ .background(AppTheme.surfaceRaised)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
- HStack(spacing: 20) {
- Text("IP Address, ex: 192.168.1.10")
- .font(AppTheme.micro(11))
- .foregroundStyle(AppTheme.inkTertiary)
- Text("NAS Name, ex: MY_NAS")
- .font(AppTheme.micro(11))
- .foregroundStyle(AppTheme.inkTertiary)
- }
- .padding(.leading, 4)
+ Text("e.g. 192.168.1.10 or MY_NAS")
+ .font(AppTheme.micro(11))
+ .foregroundStyle(AppTheme.inkTertiary)
+ .padding(.leading, 4)
}
.padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 12)
@@ -163,7 +161,7 @@ struct ConnectView: View {
.padding(.horizontal, 16)
.padding(.vertical, 14)
}
- .background(Color.white)
+ .background(AppTheme.surfaceRaised)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)
@@ -199,7 +197,7 @@ struct ConnectView: View {
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
- .background(Color.white)
+ .background(AppTheme.surfaceRaised)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
}
@@ -255,25 +253,11 @@ struct ConnectView: View {
.disabled(!vm.canConnect)
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: vm.isConnected)
- // Help
- Button {
- // future: show help sheet
- } label: {
- HStack(spacing: 6) {
- Image(systemName: "questionmark.circle")
- .font(.system(size: 13))
- Text("Help")
- .font(AppTheme.caption())
- }
- .foregroundStyle(AppTheme.inkTertiary)
- }
- .padding(.top, 20)
- .padding(.bottom, 48)
+ Spacer().frame(height: 48)
}
}
}
- .navigationTitle("NAS Connect")
- .navigationBarTitleDisplayMode(.inline)
+ .navigationBarHidden(true)
.navigationDestination(isPresented: $navigateToDashboard) { MainTabView() }
.sheet(isPresented: $vm.showFolderBrowser) {
if let conn = ConnectionStore.shared.savedConnection {
diff --git a/Features/Connect/ConnectViewModel.swift b/Features/Connect/ConnectViewModel.swift
index 11b9412..7f1f0fa 100644
--- a/Features/Connect/ConnectViewModel.swift
+++ b/Features/Connect/ConnectViewModel.swift
@@ -24,6 +24,11 @@ final class ConnectViewModel: ObservableObject {
}
func verify() async {
+ let service: any NASTransferProtocol = selectedProtocol == .smb ? SMBService() : SFTPService()
+ await verifyWith(transfer: service)
+ }
+
+ func verifyWith(transfer: any NASTransferProtocol) async {
isVerifying = true
isConnected = false
verifyError = nil
@@ -36,11 +41,10 @@ final class ConnectViewModel: ObservableObject {
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()
+ try await transfer.connect(to: connection.host, port: connection.port,
+ username: connection.username, password: connection.password)
+ transfer.disconnect()
isConnected = true
ConnectionStore.shared.savedConnection = connection
} catch let e as BackupError {
diff --git a/Features/History/HistoryView.swift b/Features/History/HistoryView.swift
index 58352e5..a8621d1 100644
--- a/Features/History/HistoryView.swift
+++ b/Features/History/HistoryView.swift
@@ -5,29 +5,22 @@ struct HistoryView: View {
var body: some View {
ZStack {
- Color.white.ignoresSafeArea()
+ AppTheme.background.ignoresSafeArea()
if store.historyEntries.isEmpty {
- VStack(spacing: 12) {
- Image(systemName: "clock.arrow.circlepath")
- .font(.system(size: 36, weight: .light))
- .foregroundStyle(AppTheme.inkQuaternary)
- Text("No backup history yet")
- .font(AppTheme.body())
- .foregroundStyle(AppTheme.inkTertiary)
- }
+ emptyState
} else {
List {
ForEach(store.historyEntries) { entry in
HistoryRowView(entry: entry)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets())
+ .listRowBackground(AppTheme.background)
}
- .onDelete { offsets in
- store.removeHistoryEntries(at: offsets)
- }
+ .onDelete { store.removeHistoryEntries(at: $0) }
}
.listStyle(.plain)
+ .scrollContentBackground(.hidden)
}
}
.navigationTitle("History")
@@ -36,32 +29,60 @@ struct HistoryView: View {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Clear") { store.clearHistory() }
.font(AppTheme.caption())
- .foregroundStyle(AppTheme.destructive)
+ .foregroundStyle(AppTheme.inkTertiary)
}
}
}
}
+
+ private var emptyState: some View {
+ VStack(spacing: 14) {
+ Image(systemName: "clock.arrow.circlepath")
+ .font(.system(size: 32, weight: .light))
+ .foregroundStyle(AppTheme.inkQuaternary)
+ VStack(spacing: 5) {
+ Text("No backups yet")
+ .font(.system(size: 15, weight: .medium))
+ .foregroundStyle(AppTheme.inkSecondary)
+ Text("Your backup history will appear here")
+ .font(AppTheme.caption())
+ .foregroundStyle(AppTheme.inkTertiary)
+ }
+ }
+ }
}
+// MARK: — Row
+
struct HistoryRowView: View {
let entry: BackupHistoryEntry
private var hasErrors: Bool { entry.failedCount > 0 }
+ private var statusColor: Color { hasErrors ? AppTheme.destructive : AppTheme.positive }
var body: some View {
VStack(spacing: 0) {
HStack(alignment: .top, spacing: 14) {
+ // Status indicator
Circle()
- .fill(hasErrors ? AppTheme.destructive : AppTheme.positive)
- .frame(width: 7, height: 7)
- .padding(.top, 5)
+ .fill(statusColor)
+ .frame(width: 6, height: 6)
+ .padding(.top, 6)
+ // Content
VStack(alignment: .leading, spacing: 5) {
- HStack(alignment: .firstTextBaseline, spacing: 8) {
- Text(entry.date, style: .date)
- .font(.system(size: 15, weight: .medium))
+ // Date + badges
+ HStack(alignment: .firstTextBaseline, spacing: 6) {
+ Text(entry.date, format: .dateTime.month(.abbreviated).day().year())
+ .font(.system(size: 14, weight: .medium))
.foregroundStyle(AppTheme.ink)
+ Text(entry.date, format: .dateTime.hour().minute())
+ .font(.system(size: 13, weight: .regular))
+ .foregroundStyle(AppTheme.inkTertiary)
+
+ Spacer()
+
if entry.triggeredByLAN {
Text("AUTO")
.font(.system(size: 9, weight: .semibold))
@@ -73,37 +94,25 @@ struct HistoryRowView: View {
.stroke(AppTheme.inkQuaternary, lineWidth: 0.75)
)
}
-
- Spacer()
-
- Text(entry.date, style: .time)
- .font(AppTheme.micro(11))
- .foregroundStyle(AppTheme.inkTertiary)
}
- HStack(spacing: 8) {
- Text("\(entry.uploadedCount) uploaded")
- .font(AppTheme.caption())
- .foregroundStyle(AppTheme.inkSecondary)
+ // Stat line
+ statLine
- if entry.skippedCount > 0 {
- dot
- Text("\(entry.skippedCount) skipped")
- .font(AppTheme.caption())
- .foregroundStyle(AppTheme.inkTertiary)
- }
-
- if entry.failedCount > 0 {
- dot
- Text("\(entry.failedCount) errors")
- .font(AppTheme.caption())
- .foregroundStyle(AppTheme.destructive)
+ // Secondary info: host · duration · size
+ HStack(spacing: 6) {
+ Text(entry.nasHost)
+ .foregroundStyle(AppTheme.inkQuaternary)
+ midDot
+ Text(formatDuration(entry.durationSeconds))
+ .foregroundStyle(AppTheme.inkQuaternary)
+ if entry.totalBytes > 0 {
+ midDot
+ Text(formatBytes(entry.totalBytes))
+ .foregroundStyle(AppTheme.inkQuaternary)
}
}
-
- Text(entry.nasHost)
- .font(AppTheme.micro(11))
- .foregroundStyle(AppTheme.inkQuaternary)
+ .font(.system(size: 11, weight: .regular))
}
}
.padding(.horizontal, 16)
@@ -112,13 +121,48 @@ struct HistoryRowView: View {
Rectangle()
.fill(AppTheme.separator)
.frame(height: 0.5)
- .padding(.leading, 37)
+ .padding(.leading, 36)
}
}
- private var dot: some View {
+ @ViewBuilder
+ private var statLine: some View {
+ HStack(spacing: 6) {
+ Text("\(entry.uploadedCount) uploaded")
+ .foregroundStyle(AppTheme.inkSecondary)
+
+ if entry.skippedCount > 0 {
+ midDot
+ Text("\(entry.skippedCount) skipped")
+ .foregroundStyle(AppTheme.inkTertiary)
+ }
+
+ if hasErrors {
+ midDot
+ Text("\(entry.failedCount) errors")
+ .foregroundStyle(AppTheme.destructive)
+ }
+ }
+ .font(AppTheme.caption())
+ }
+
+ private var midDot: some View {
Circle()
.fill(AppTheme.inkQuaternary)
.frame(width: 3, height: 3)
}
+
+ private func formatDuration(_ s: Double) -> String {
+ let m = Int(s) / 60
+ let sec = Int(s) % 60
+ return m > 0 ? "\(m)m \(sec)s" : "\(sec)s"
+ }
+
+ private func formatBytes(_ bytes: Int64) -> String {
+ let gb = Double(bytes) / 1_073_741_824
+ if gb >= 1 { return String(format: "%.1f GB", gb) }
+ let mb = Double(bytes) / 1_048_576
+ if mb >= 1 { return String(format: "%.0f MB", mb) }
+ return String(format: "%.0f KB", Double(bytes) / 1024)
+ }
}
diff --git a/Features/Login/LoginView.swift b/Features/Login/LoginView.swift
index 6bca4a7..60e7d39 100644
--- a/Features/Login/LoginView.swift
+++ b/Features/Login/LoginView.swift
@@ -1,108 +1,94 @@
import SwiftUI
struct LoginView: View {
+ @EnvironmentObject var store: ConnectionStore
@StateObject private var vm = LoginViewModel()
- @State private var navigateToDashboard = false
@State private var logoAppeared = false
var body: some View {
- NavigationStack {
- ZStack {
- Color.white.ignoresSafeArea()
+ ZStack {
+ AppTheme.background.ignoresSafeArea()
- VStack(spacing: 0) {
- Spacer()
+ VStack(spacing: 0) {
+ Spacer()
- // Logo
- Image("nas_logo_clean")
- .resizable()
- .scaledToFit()
- .frame(width: 72, height: 72)
- .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
- .shadow(color: .black.opacity(0.08), radius: 16, x: 0, y: 4)
- .scaleEffect(logoAppeared ? 1 : 0.8)
- .opacity(logoAppeared ? 1 : 0)
- .animation(.spring(response: 0.5, dampingFraction: 0.7).delay(0.1), value: logoAppeared)
-
- Spacer().frame(height: 32)
-
- // Device + account
- VStack(spacing: 6) {
- Text("Forge")
- .font(.system(size: 22, weight: .semibold))
- .foregroundStyle(AppTheme.ink)
-
- HStack(spacing: 8) {
- ZStack {
- Circle()
- .fill(AppTheme.surfaceSunken)
- .frame(width: 28, height: 28)
- Text("AB")
- .font(.system(size: 10, weight: .semibold))
- .foregroundStyle(AppTheme.inkSecondary)
- }
- Text("Albert")
- .font(AppTheme.caption())
- .foregroundStyle(AppTheme.inkSecondary)
- }
- }
+ // Logo
+ Image("nas_logo_clean")
+ .resizable()
+ .scaledToFit()
+ .frame(width: 72, height: 72)
+ .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
+ .shadow(color: .black.opacity(0.08), radius: 16, x: 0, y: 4)
+ .scaleEffect(logoAppeared ? 1 : 0.8)
.opacity(logoAppeared ? 1 : 0)
- .animation(.spring(response: 0.5, dampingFraction: 0.7).delay(0.2), value: logoAppeared)
+ .animation(.spring(response: 0.5, dampingFraction: 0.7).delay(0.1), value: logoAppeared)
- Spacer().frame(height: 40)
+ Spacer().frame(height: 32)
- // Face ID button
- Button(action: { vm.authenticateWithFaceID() }) {
- HStack(spacing: 8) {
- if vm.isAuthenticating {
- ProgressView()
- .tint(.white)
- .scaleEffect(0.8)
- } else {
- Image(systemName: "faceid")
- .font(.system(size: 16, weight: .medium))
- Text("Sign in with Face ID")
- }
- }
- }
- .buttonStyle(PrimaryButtonStyle())
- .padding(.horizontal, AppTheme.hPad)
- .disabled(vm.isAuthenticating)
- .opacity(logoAppeared ? 1 : 0)
- .animation(.spring(response: 0.5, dampingFraction: 0.7).delay(0.3), value: logoAppeared)
-
- if let error = vm.authError {
- Text(error)
- .font(AppTheme.caption())
- .foregroundStyle(AppTheme.destructive)
- .padding(.top, 12)
- .transition(.opacity.combined(with: .move(edge: .top)))
- }
-
- Spacer()
-
- // More
- Button("More options") { vm.showMoreSheet = true }
- .font(.system(size: 13, weight: .regular))
+ // Title + account
+ VStack(spacing: 5) {
+ Text("Kisani")
+ .font(.system(size: 22, weight: .semibold))
+ .foregroundStyle(AppTheme.ink)
+ Text("Sign in to continue")
+ .font(AppTheme.caption())
.foregroundStyle(AppTheme.inkTertiary)
- .padding(.bottom, 44)
- .opacity(logoAppeared ? 1 : 0)
- .animation(.spring(response: 0.5, dampingFraction: 0.7).delay(0.4), value: logoAppeared)
}
- }
- .navigationDestination(isPresented: $navigateToDashboard) { MainTabView() }
- .sheet(isPresented: $vm.showMoreSheet) {
- MoreOptionsSheet(isPresented: $vm.showMoreSheet) {
- vm.showMoreSheet = false
- navigateToDashboard = true
+ .opacity(logoAppeared ? 1 : 0)
+ .animation(.spring(response: 0.5, dampingFraction: 0.7).delay(0.2), value: logoAppeared)
+
+ Spacer().frame(height: 40)
+
+ // Face ID button
+ Button(action: { vm.authenticateWithFaceID() }) {
+ HStack(spacing: 8) {
+ if vm.isAuthenticating {
+ ProgressView()
+ .tint(AppTheme.inkInverse)
+ .scaleEffect(0.8)
+ } else {
+ Image(systemName: "faceid")
+ .font(.system(size: 16, weight: .medium))
+ Text("Sign in with Face ID")
+ }
+ }
}
- .presentationDetents([.height(200)])
- .modifier(SheetCornerRadius(20))
+ .buttonStyle(PrimaryButtonStyle())
+ .padding(.horizontal, AppTheme.hPad)
+ .disabled(vm.isAuthenticating)
+ .opacity(logoAppeared ? 1 : 0)
+ .animation(.spring(response: 0.5, dampingFraction: 0.7).delay(0.3), value: logoAppeared)
+
+ if let error = vm.authError {
+ Text(error)
+ .font(AppTheme.caption())
+ .foregroundStyle(AppTheme.destructive)
+ .padding(.top, 12)
+ .transition(.opacity.combined(with: .move(edge: .top)))
+ }
+
+ Spacer()
+
+ // More
+ Button("More options") { vm.showMoreSheet = true }
+ .font(.system(size: 13, weight: .regular))
+ .foregroundStyle(AppTheme.inkTertiary)
+ .padding(.bottom, 44)
+ .opacity(logoAppeared ? 1 : 0)
+ .animation(.spring(response: 0.5, dampingFraction: 0.7).delay(0.4), value: logoAppeared)
}
- .onAppear {
- vm.onAuthenticated = { navigateToDashboard = true }
- logoAppeared = true
+ }
+ .sheet(isPresented: $vm.showMoreSheet) {
+ MoreOptionsSheet(isPresented: $vm.showMoreSheet) {
+ vm.showMoreSheet = false
+ store.isSessionActive = true
}
+ .presentationDetents([.height(200)])
+ .modifier(SheetCornerRadius(20))
+ }
+ .onAppear {
+ vm.onAuthenticated = { store.isSessionActive = true }
+ logoAppeared = true
}
}
}
@@ -138,7 +124,7 @@ struct MoreOptionsSheet: View {
.padding(.horizontal, AppTheme.hPad)
.padding(.bottom, 16)
}
- .background(Color.white)
+ .background(AppTheme.background)
}
private func sheetRow(
diff --git a/Features/Login/LoginViewModel.swift b/Features/Login/LoginViewModel.swift
index c224d19..ff645f6 100644
--- a/Features/Login/LoginViewModel.swift
+++ b/Features/Login/LoginViewModel.swift
@@ -1,5 +1,6 @@
import Foundation
import LocalAuthentication
+import os.log
@MainActor
final class LoginViewModel: ObservableObject {
@@ -9,26 +10,34 @@ final class LoginViewModel: ObservableObject {
var onAuthenticated: (() -> Void)?
+ private let logger = Logger(subsystem: "com.albert.nasbackup", category: "Login")
+
func authenticateWithFaceID() {
+ logger.info("Face ID authentication started")
isAuthenticating = true
authError = nil
let context = LAContext()
var error: NSError?
guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
+ let reason = error?.localizedDescription ?? "unknown"
+ logger.error("Face ID not available: \(reason, privacy: .public)")
authError = "Face ID not available"
isAuthenticating = false
return
}
context.evaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics,
- localizedReason: "Sign in to NASBackup"
+ localizedReason: "Sign in to Kisani"
) { [weak self] success, evalError in
Task { @MainActor in
self?.isAuthenticating = false
if success {
+ self?.logger.info("Face ID authentication succeeded — advancing session")
self?.onAuthenticated?()
} else {
- self?.authError = evalError?.localizedDescription ?? "Authentication failed"
+ let reason = evalError?.localizedDescription ?? "Authentication failed"
+ self?.logger.error("Face ID authentication failed: \(reason, privacy: .public)")
+ self?.authError = reason
}
}
}
diff --git a/Features/Onboarding/FolderSetupView.swift b/Features/Onboarding/FolderSetupView.swift
new file mode 100644
index 0000000..4ff1489
--- /dev/null
+++ b/Features/Onboarding/FolderSetupView.swift
@@ -0,0 +1,151 @@
+import SwiftUI
+
+struct FolderSetupView: View {
+ @EnvironmentObject var store: ConnectionStore
+ @State private var showBrowser = false
+ @State private var selectedPath: String = "/"
+ @State private var appeared = false
+
+ private var connection: NASConnection? { store.savedConnection }
+
+ var body: some View {
+ ZStack {
+ AppTheme.background.ignoresSafeArea()
+
+ VStack(spacing: 0) {
+ // Step indicator
+ stepIndicator
+ .padding(.top, 20)
+ .padding(.bottom, 36)
+ .opacity(appeared ? 1 : 0)
+ .animation(.spring(response: 0.4, dampingFraction: 0.8).delay(0.05), value: appeared)
+
+ // Icon
+ ZStack {
+ Circle()
+ .fill(AppTheme.surfaceSunken)
+ .frame(width: 80, height: 80)
+ Image(systemName: "folder.badge.plus")
+ .font(.system(size: 32, weight: .light))
+ .foregroundStyle(AppTheme.ink)
+ }
+ .scaleEffect(appeared ? 1 : 0.85)
+ .opacity(appeared ? 1 : 0)
+ .animation(.spring(response: 0.5, dampingFraction: 0.7).delay(0.1), value: appeared)
+
+ Spacer().frame(height: 28)
+
+ // Title + description
+ VStack(spacing: 8) {
+ Text("Choose a destination")
+ .font(.system(size: 22, weight: .semibold))
+ .foregroundStyle(AppTheme.ink)
+ Text("Select the folder on your NAS where photos will be backed up.")
+ .font(AppTheme.body())
+ .foregroundStyle(AppTheme.inkSecondary)
+ .multilineTextAlignment(.center)
+ .padding(.horizontal, 32)
+ }
+ .opacity(appeared ? 1 : 0)
+ .animation(.spring(response: 0.4, dampingFraction: 0.8).delay(0.15), value: appeared)
+
+ Spacer().frame(height: 32)
+
+ // Connection chip
+ if let conn = connection {
+ connectionChip(conn)
+ .opacity(appeared ? 1 : 0)
+ .animation(.spring(response: 0.4, dampingFraction: 0.8).delay(0.2), value: appeared)
+ }
+
+ Spacer().frame(height: 16)
+
+ // Selected path display / browse button
+ Button(action: { showBrowser = true }) {
+ HStack(spacing: 12) {
+ Image(systemName: selectedPath == "/" ? "folder" : "folder.fill")
+ .font(.system(size: 14, weight: .regular))
+ .foregroundStyle(selectedPath == "/" ? AppTheme.inkQuaternary : AppTheme.interactive)
+ .frame(width: 20)
+ Text(selectedPath == "/" ? "Tap to browse folders" : selectedPath)
+ .font(AppTheme.body())
+ .foregroundStyle(selectedPath == "/" ? AppTheme.inkTertiary : AppTheme.ink)
+ .lineLimit(2)
+ .multilineTextAlignment(.leading)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ Image(systemName: "chevron.right")
+ .font(.system(size: 12, weight: .medium))
+ .foregroundStyle(AppTheme.inkQuaternary)
+ }
+ .padding(.horizontal, 16)
+ .padding(.vertical, 14)
+ .background(AppTheme.surfaceRaised)
+ .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
+ .shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
+ }
+ .buttonStyle(ScaleButtonStyle())
+ .padding(.horizontal, AppTheme.hPad)
+ .opacity(appeared ? 1 : 0)
+ .animation(.spring(response: 0.4, dampingFraction: 0.8).delay(0.25), value: appeared)
+
+ Spacer()
+
+ // Confirm button
+ Button(action: confirmFolder) {
+ Text("Save This Folder")
+ }
+ .buttonStyle(PrimaryButtonStyle())
+ .padding(.horizontal, AppTheme.hPad)
+ .disabled(selectedPath == "/")
+ .opacity(selectedPath == "/" ? 0.4 : 1)
+ .animation(.spring(response: 0.3, dampingFraction: 0.8), value: selectedPath == "/")
+ .opacity(appeared ? 1 : 0)
+ .animation(.spring(response: 0.4, dampingFraction: 0.8).delay(0.3), value: appeared)
+
+ Spacer().frame(height: 48)
+ }
+ }
+ .sheet(isPresented: $showBrowser) {
+ if let conn = connection {
+ BrowseView(connection: conn, selectedPath: $selectedPath)
+ }
+ }
+ .onAppear { appeared = true }
+ }
+
+ private var stepIndicator: some View {
+ HStack(spacing: 6) {
+ ForEach(1...3, id: \.self) { step in
+ Capsule()
+ .fill(step == 2 ? AppTheme.ink : AppTheme.inkQuaternary)
+ .frame(width: step == 2 ? 20 : 6, height: 6)
+ }
+ }
+ }
+
+ private func connectionChip(_ conn: NASConnection) -> some View {
+ HStack(spacing: 6) {
+ Circle()
+ .fill(AppTheme.positive)
+ .frame(width: 6, height: 6)
+ Text(conn.host)
+ .font(AppTheme.micro())
+ .foregroundStyle(AppTheme.inkSecondary)
+ Text("·")
+ .foregroundStyle(AppTheme.inkQuaternary)
+ Text(conn.nasProtocol.rawValue)
+ .font(AppTheme.micro())
+ .foregroundStyle(AppTheme.inkTertiary)
+ }
+ .padding(.horizontal, 12)
+ .padding(.vertical, 6)
+ .background(AppTheme.surfaceSunken)
+ .clipShape(Capsule(style: .continuous))
+ }
+
+ private func confirmFolder() {
+ guard selectedPath != "/", var conn = store.savedConnection else { return }
+ conn.remotePath = selectedPath
+ store.savedConnection = conn
+ }
+}
diff --git a/Features/Onboarding/PhotoPermissionView.swift b/Features/Onboarding/PhotoPermissionView.swift
new file mode 100644
index 0000000..2d2aabb
--- /dev/null
+++ b/Features/Onboarding/PhotoPermissionView.swift
@@ -0,0 +1,129 @@
+import SwiftUI
+import Photos
+
+struct PhotoPermissionView: View {
+ @EnvironmentObject var store: ConnectionStore
+ @State private var appeared = false
+ @State private var isRequesting = false
+
+ var body: some View {
+ ZStack {
+ AppTheme.background.ignoresSafeArea()
+
+ VStack(spacing: 0) {
+ // Step indicator
+ stepIndicator
+ .padding(.top, 20)
+ .padding(.bottom, 36)
+ .opacity(appeared ? 1 : 0)
+ .animation(.spring(response: 0.4, dampingFraction: 0.8).delay(0.05), value: appeared)
+
+ Spacer()
+
+ // Icon
+ ZStack {
+ Circle()
+ .fill(AppTheme.surfaceSunken)
+ .frame(width: 96, height: 96)
+ Image(systemName: "photo.stack")
+ .font(.system(size: 38, weight: .light))
+ .foregroundStyle(AppTheme.ink)
+ }
+ .scaleEffect(appeared ? 1 : 0.85)
+ .opacity(appeared ? 1 : 0)
+ .animation(.spring(response: 0.5, dampingFraction: 0.7).delay(0.1), value: appeared)
+
+ Spacer().frame(height: 32)
+
+ // Title + description
+ VStack(spacing: 10) {
+ Text("Access your photos")
+ .font(.system(size: 24, weight: .semibold))
+ .foregroundStyle(AppTheme.ink)
+
+ Text("Kisani needs read access to your photo library to find and back up your photos and videos to the NAS.")
+ .font(AppTheme.body())
+ .foregroundStyle(AppTheme.inkSecondary)
+ .multilineTextAlignment(.center)
+ .padding(.horizontal, 32)
+
+ // Detail chips
+ HStack(spacing: 8) {
+ detailChip(icon: "eye.slash", label: "Read-only")
+ detailChip(icon: "lock.shield", label: "Stays on-device")
+ detailChip(icon: "icloud.slash", label: "No cloud upload")
+ }
+ .padding(.top, 8)
+ }
+ .opacity(appeared ? 1 : 0)
+ .animation(.spring(response: 0.4, dampingFraction: 0.8).delay(0.18), value: appeared)
+
+ Spacer()
+
+ // Buttons
+ VStack(spacing: 12) {
+ Button(action: requestAccess) {
+ Group {
+ if isRequesting {
+ ProgressView().tint(AppTheme.inkInverse)
+ } else {
+ Text("Allow Photo Access")
+ }
+ }
+ }
+ .buttonStyle(PrimaryButtonStyle())
+ .disabled(isRequesting)
+
+ Button(action: skipAndContinue) {
+ Text("Not Now")
+ }
+ .buttonStyle(GhostButtonStyle())
+ }
+ .padding(.horizontal, AppTheme.hPad)
+ .opacity(appeared ? 1 : 0)
+ .animation(.spring(response: 0.4, dampingFraction: 0.8).delay(0.28), value: appeared)
+
+ Spacer().frame(height: 48)
+ }
+ }
+ .onAppear { appeared = true }
+ }
+
+ private var stepIndicator: some View {
+ HStack(spacing: 6) {
+ ForEach(1...3, id: \.self) { step in
+ Capsule()
+ .fill(step == 3 ? AppTheme.ink : AppTheme.inkQuaternary)
+ .frame(width: step == 3 ? 20 : 6, height: 6)
+ }
+ }
+ }
+
+ private func detailChip(icon: String, label: String) -> some View {
+ HStack(spacing: 4) {
+ Image(systemName: icon)
+ .font(.system(size: 10, weight: .medium))
+ Text(label)
+ .font(.system(size: 11, weight: .medium))
+ }
+ .foregroundStyle(AppTheme.inkSecondary)
+ .padding(.horizontal, 10)
+ .padding(.vertical, 5)
+ .background(AppTheme.surfaceSunken)
+ .clipShape(Capsule(style: .continuous))
+ }
+
+ private func requestAccess() {
+ isRequesting = true
+ PHPhotoLibrary.requestAuthorization(for: .readWrite) { _ in
+ DispatchQueue.main.async {
+ isRequesting = false
+ store.onboardingPhotoShown = true
+ }
+ }
+ }
+
+ private func skipAndContinue() {
+ store.onboardingPhotoShown = true
+ }
+}
diff --git a/Features/Settings/LANTriggerSection.swift b/Features/Settings/LANTriggerSection.swift
index 6794eda..917927c 100644
--- a/Features/Settings/LANTriggerSection.swift
+++ b/Features/Settings/LANTriggerSection.swift
@@ -131,7 +131,7 @@ struct LANTriggerSection: View {
.padding(.horizontal, AppTheme.cardPad)
.padding(.vertical, 12)
}
- .background(Color.white)
+ .background(AppTheme.surfaceRaised)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
}
diff --git a/Features/Settings/SettingsView.swift b/Features/Settings/SettingsView.swift
index 14d69fa..ba67341 100644
--- a/Features/Settings/SettingsView.swift
+++ b/Features/Settings/SettingsView.swift
@@ -6,10 +6,11 @@ struct SettingsView: View {
var body: some View {
ZStack {
- Color.white.ignoresSafeArea()
+ AppTheme.background.ignoresSafeArea()
ScrollView {
VStack(spacing: AppTheme.sectionGap) {
+ appearanceSection
LANTriggerSection()
mediaSection
quietHoursSection
@@ -26,6 +27,45 @@ struct SettingsView: View {
.navigationBarTitleDisplayMode(.inline)
}
+ // MARK: — Appearance
+
+ private var appearanceSection: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ sectionLabel("APPEARANCE")
+
+ HStack(spacing: 0) {
+ ForEach(AppearanceMode.allCases, id: \.self) { mode in
+ Button {
+ withAnimation(.spring(response: 0.25, dampingFraction: 0.8)) {
+ store.appearanceMode = mode
+ }
+ } label: {
+ let selected = store.appearanceMode == mode
+ HStack(spacing: 5) {
+ Image(systemName: mode.icon)
+ .font(.system(size: 12, weight: selected ? .semibold : .regular))
+ Text(mode.label)
+ .font(.system(size: 12, weight: selected ? .semibold : .regular))
+ }
+ .foregroundStyle(selected ? AppTheme.ink : AppTheme.inkTertiary)
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 9)
+ .background(
+ RoundedRectangle(cornerRadius: 8, style: .continuous)
+ .fill(selected ? AppTheme.surfaceRaised : Color.clear)
+ .shadow(color: selected ? .black.opacity(0.08) : .clear,
+ radius: 4, x: 0, y: 1)
+ )
+ }
+ .buttonStyle(PlainButtonStyle())
+ }
+ }
+ .padding(3)
+ .background(AppTheme.surfaceSunken)
+ .clipShape(RoundedRectangle(cornerRadius: 11, style: .continuous))
+ }
+ }
+
// MARK: — Media
private var mediaSection: some View {
@@ -49,7 +89,7 @@ struct SettingsView: View {
subtitle: "Skip files already on NAS",
isOn: $store.backupFilter.newFilesOnly)
}
- .background(Color.white)
+ .background(AppTheme.surfaceRaised)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
}
@@ -78,7 +118,7 @@ struct SettingsView: View {
subtitle: "Preserve battery on large libraries",
isOn: $store.chargingOnlyMode)
}
- .background(Color.white)
+ .background(AppTheme.surfaceRaised)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: store.quietHoursEnabled)
@@ -135,7 +175,7 @@ struct SettingsView: View {
.padding(.vertical, 10)
}
}
- .background(Color.white)
+ .background(AppTheme.surfaceRaised)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: lanMonitor.isOnCellular)
@@ -206,13 +246,15 @@ struct SettingsView: View {
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
}
.buttonStyle(ScaleButtonStyle())
+ .accessibilityLabel("Open Tailscale app")
+ .accessibilityHint("Opens the Tailscale app to connect the VPN")
}
}
.padding(.horizontal, AppTheme.cardPad)
.padding(.vertical, 12)
}
}
- .background(Color.white)
+ .background(AppTheme.surfaceRaised)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: store.useTailscaleWhenRemote)
@@ -233,7 +275,7 @@ struct SettingsView: View {
hairline
notifRow(icon: "exclamationmark.triangle", title: "On errors", value: "On")
}
- .background(Color.white)
+ .background(AppTheme.surfaceRaised)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
}
diff --git a/NASBackup.xcodeproj/project.pbxproj b/NASBackup.xcodeproj/project.pbxproj
index c817431..5314304 100644
--- a/NASBackup.xcodeproj/project.pbxproj
+++ b/NASBackup.xcodeproj/project.pbxproj
@@ -7,26 +7,37 @@
objects = {
/* Begin PBXBuildFile section */
+ 10062AA91BC883AE597443BD /* URL+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 277DF2E5F4E566C869EC29F3 /* URL+.swift */; };
+ 13FCA7D0BB01BFC46A5B725B /* SavedConnections.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1D1C00B125D5CC000988DD0 /* SavedConnections.swift */; };
+ 30693CB8EE711577978EF88C /* Date+.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6CD83D8292EF9EFBE3A9C6C /* Date+.swift */; };
36FE83FFC24922EBC28287BB /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88E66C22B614E89A7A9E9B35 /* AppDelegate.swift */; };
37D808079BDEF32D04847D04 /* MainTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F09160B7B7A06BD900D0D87A /* MainTabView.swift */; };
+ 4148416D31C8AC65FEE6394F /* PHAsset+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86FA0D1522E2C4FF7C1CABF3 /* PHAsset+.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 */; };
+ 664E9F44D037A8B41D5A7670 /* MockNASService.swift in Sources */ = {isa = PBXBuildFile; fileRef = BACE1D31086231C97DAD884D /* MockNASService.swift */; };
+ 685E8F1B44AE5652E93C58CE /* NASProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7688CD7E9BC5475A3EB01021 /* NASProtocolTests.swift */; };
6DED667F0AA66503730640F1 /* FolderRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83C13DD3EB4823AA98D51AF2 /* FolderRow.swift */; };
+ 77A3FF7E2E4F506E7F9E5CCD /* FolderSetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C31F3F5EBCB51002188FA15 /* FolderSetupView.swift */; };
7872072DD0E0B8D51C1A12DF /* NASBackupApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B36E23852EF12DC14A2DF2DC /* NASBackupApp.swift */; };
7DF923F172E6DFE3DD110DC9 /* LANTriggerSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CBE4F65BE653C7C72E096D1 /* LANTriggerSection.swift */; };
+ 7FB20CB93FECB2F9CBE67161 /* ConnectViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50191596BF6E84CFAE7904D3 /* ConnectViewModelTests.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 */; };
+ 8EB77E535C82274276C1E328 /* KeychainStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC8C588CD91ABA1EFED2F2B /* KeychainStore.swift */; };
9444B0EB5AD2936DA837320F /* BackupError.swift in Sources */ = {isa = PBXBuildFile; fileRef = F03F0ED1A914F3392BFE5E4A /* BackupError.swift */; };
+ 96CC879835A8CFE5FDD21B85 /* UGreenCompatibilityBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = B55F95367E10145F50BF06E3 /* UGreenCompatibilityBadge.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 */; };
+ A79F538803790524284BCAD6 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 0C0E3EBE5A0DBD9D64196501 /* PrivacyInfo.xcprivacy */; };
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 */; };
@@ -34,8 +45,11 @@
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 */; };
+ CE84BC18F045AB65CCA30BB4 /* LANMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F19B5E72ADF230C0C134942B /* LANMonitorTests.swift */; };
DCE9AF4F0160E323224E3553 /* PhotoLibraryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74E2AF8B56C469E0D94EFE94 /* PhotoLibraryProtocol.swift */; };
DF719EAD5727B335001DB50A /* ConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE318B60576B11E80862664D /* ConnectView.swift */; };
+ DFD2252BDCE9EE7ABAE63BF9 /* BackupEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 336D155A4D1C895215A4FFF2 /* BackupEngineTests.swift */; };
+ E24CEE0D51131D991038205D /* PhotoPermissionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AC72ED4015671C51066AC3E /* PhotoPermissionView.swift */; };
E830DB3078A8873382A77B63 /* BackupResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AEFE4959C08B94AE9584A11 /* BackupResult.swift */; };
ED0D2A1D05B5898BCCE9EA0A /* ButtonStyles.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA0969FC7D7E0EE313E29D55 /* ButtonStyles.swift */; };
F9219423E73718D8AB604EB1 /* ConnectionStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D87319355D902007618A91AE /* ConnectionStore.swift */; };
@@ -43,22 +57,41 @@
FDC18499CC21A58C2F6F1BDB /* BackupJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47E762898E351192399FC739 /* BackupJob.swift */; };
/* End PBXBuildFile section */
+/* Begin PBXContainerItemProxy section */
+ 2F6BE82D286218B2DC8D8B98 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 82D1639F1555985B1BEE4547 /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = 32316B985B8906FE4CB97730;
+ remoteInfo = NASBackup;
+ };
+/* End PBXContainerItemProxy section */
+
/* Begin PBXFileReference section */
055D92AB46FCDDF9C34B9D62 /* NASTransferProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NASTransferProtocol.swift; sourceTree = ""; };
+ 0C0E3EBE5A0DBD9D64196501 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; path = PrivacyInfo.xcprivacy; sourceTree = ""; };
+ 277DF2E5F4E566C869EC29F3 /* URL+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+.swift"; sourceTree = ""; };
2B33F755DA5850D1FE3F695B /* AppTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTheme.swift; sourceTree = ""; };
2BE4774FFA1074FF5F1FAB2B /* NASConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NASConnection.swift; sourceTree = ""; };
+ 336D155A4D1C895215A4FFF2 /* BackupEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupEngineTests.swift; sourceTree = ""; };
+ 3C31F3F5EBCB51002188FA15 /* FolderSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderSetupView.swift; sourceTree = ""; };
46B47E3C76EA718ECF97BBAD /* PhotoLibraryService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoLibraryService.swift; sourceTree = ""; };
47E762898E351192399FC739 /* BackupJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupJob.swift; sourceTree = ""; };
4AEFE4959C08B94AE9584A11 /* BackupResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupResult.swift; sourceTree = ""; };
4B67584BB6D705BCB3D40192 /* SFTPService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SFTPService.swift; sourceTree = ""; };
4CBE4F65BE653C7C72E096D1 /* LANTriggerSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LANTriggerSection.swift; sourceTree = ""; };
+ 4EC8C588CD91ABA1EFED2F2B /* KeychainStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainStore.swift; sourceTree = ""; };
+ 50191596BF6E84CFAE7904D3 /* ConnectViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectViewModelTests.swift; sourceTree = ""; };
55EA454B783C2AEDABFB00A7 /* BackupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupView.swift; sourceTree = ""; };
5606A4C5894CFC72D555CAE1 /* LANMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LANMonitor.swift; sourceTree = ""; };
5643379BBA1FD39841F9EA55 /* BackgroundTaskManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundTaskManager.swift; sourceTree = ""; };
6EEBCD4A838173E557DC3EFC /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = ""; };
74E2AF8B56C469E0D94EFE94 /* PhotoLibraryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoLibraryProtocol.swift; sourceTree = ""; };
+ 7688CD7E9BC5475A3EB01021 /* NASProtocolTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NASProtocolTests.swift; sourceTree = ""; };
+ 7AC72ED4015671C51066AC3E /* PhotoPermissionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoPermissionView.swift; sourceTree = ""; };
824FADB40473BFC36FE91F0A /* BackupEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupEngine.swift; sourceTree = ""; };
83C13DD3EB4823AA98D51AF2 /* FolderRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderRow.swift; sourceTree = ""; };
+ 86FA0D1522E2C4FF7C1CABF3 /* PHAsset+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PHAsset+.swift"; sourceTree = ""; };
8845003C87961174302AC990 /* ToggleRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToggleRow.swift; sourceTree = ""; };
88E66C22B614E89A7A9E9B35 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; };
91197DD51221A3277AE318E2 /* ConnectViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectViewModel.swift; sourceTree = ""; };
@@ -66,18 +99,24 @@
A8F24C7DD7A503A0D561F3FB /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = ""; };
AC73AD75762D4357D171CDA4 /* BackupViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupViewModel.swift; sourceTree = ""; };
B36E23852EF12DC14A2DF2DC /* NASBackupApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NASBackupApp.swift; sourceTree = ""; };
+ B55F95367E10145F50BF06E3 /* UGreenCompatibilityBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UGreenCompatibilityBadge.swift; sourceTree = ""; };
+ BACE1D31086231C97DAD884D /* MockNASService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockNASService.swift; sourceTree = ""; };
C755BFDD42D46A3FB490A4BB /* NASBackup.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = NASBackup.app; sourceTree = BUILT_PRODUCTS_DIR; };
C955FEF6710E3667C7A73A02 /* ProgressRing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressRing.swift; sourceTree = ""; };
CA0969FC7D7E0EE313E29D55 /* ButtonStyles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonStyles.swift; sourceTree = ""; };
+ D1D1C00B125D5CC000988DD0 /* SavedConnections.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedConnections.swift; sourceTree = ""; };
D87319355D902007618A91AE /* ConnectionStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionStore.swift; sourceTree = ""; };
D947C8F8945A2AB081BBD0EF /* BrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseView.swift; sourceTree = ""; };
DEBD92D10D70B957192E271F /* SMBService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMBService.swift; sourceTree = ""; };
+ E3A1E9181F350DA9F091D019 /* NASBackupTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = NASBackupTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
EE318B60576B11E80862664D /* ConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectView.swift; sourceTree = ""; };
F03F0ED1A914F3392BFE5E4A /* BackupError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupError.swift; sourceTree = ""; };
F09160B7B7A06BD900D0D87A /* MainTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabView.swift; sourceTree = ""; };
F0AABE0AA63B1ACA1DEABB65 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; };
+ F19B5E72ADF230C0C134942B /* LANMonitorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LANMonitorTests.swift; sourceTree = ""; };
F1A42E8CFCAECFDBF6EDDAC0 /* LoginViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginViewModel.swift; sourceTree = ""; };
F5CE897758E9CD5BB8B80F63 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; };
+ F6CD83D8292EF9EFBE3A9C6C /* Date+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+.swift"; sourceTree = ""; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -102,6 +141,7 @@
1AD5A741A7466C3510901C60 /* Connect */,
88D0BD5235374F2A264A0C96 /* History */,
FDF5327105186A6456CF0B08 /* Login */,
+ 611BB6A8B01D82432C0A32EC /* Onboarding */,
147B04CA2B7A7723CCD13E93 /* Settings */,
);
path = Features;
@@ -115,6 +155,7 @@
0A801C860EB4C4B22FD4D073 /* Features */,
ADFBF8F90DF8352BD2833A66 /* Services */,
31C8DD8F9077E2B6C67A99BA /* Shared */,
+ D7CC50C0264BFFC23DA2C868 /* Tests */,
8308FE572D56B8045F17F6A0 /* Products */,
);
sourceTree = "";
@@ -123,6 +164,8 @@
isa = PBXGroup;
children = (
D87319355D902007618A91AE /* ConnectionStore.swift */,
+ 4EC8C588CD91ABA1EFED2F2B /* KeychainStore.swift */,
+ D1D1C00B125D5CC000988DD0 /* SavedConnections.swift */,
);
path = Persistence;
sourceTree = "";
@@ -136,6 +179,16 @@
path = Settings;
sourceTree = "";
};
+ 185383972A68402ADF89D64C /* Extensions */ = {
+ isa = PBXGroup;
+ children = (
+ F6CD83D8292EF9EFBE3A9C6C /* Date+.swift */,
+ 86FA0D1522E2C4FF7C1CABF3 /* PHAsset+.swift */,
+ 277DF2E5F4E566C869EC29F3 /* URL+.swift */,
+ );
+ path = Extensions;
+ sourceTree = "";
+ };
1AD5A741A7466C3510901C60 /* Connect */ = {
isa = PBXGroup;
children = (
@@ -149,6 +202,7 @@
isa = PBXGroup;
children = (
3E7F8DE77B7CDC7009B54D63 /* Components */,
+ 185383972A68402ADF89D64C /* Extensions */,
0B27021136C1E85D5B8CFF97 /* Persistence */,
C874CB05A914EE933FCFD5C2 /* Theme */,
);
@@ -162,10 +216,20 @@
83C13DD3EB4823AA98D51AF2 /* FolderRow.swift */,
C955FEF6710E3667C7A73A02 /* ProgressRing.swift */,
8845003C87961174302AC990 /* ToggleRow.swift */,
+ B55F95367E10145F50BF06E3 /* UGreenCompatibilityBadge.swift */,
);
path = Components;
sourceTree = "";
};
+ 611BB6A8B01D82432C0A32EC /* Onboarding */ = {
+ isa = PBXGroup;
+ children = (
+ 3C31F3F5EBCB51002188FA15 /* FolderSetupView.swift */,
+ 7AC72ED4015671C51066AC3E /* PhotoPermissionView.swift */,
+ );
+ path = Onboarding;
+ sourceTree = "";
+ };
803154EA96E2431367806BAF /* Backup */ = {
isa = PBXGroup;
children = (
@@ -188,6 +252,7 @@
isa = PBXGroup;
children = (
C755BFDD42D46A3FB490A4BB /* NASBackup.app */,
+ E3A1E9181F350DA9F091D019 /* NASBackupTests.xctest */,
);
name = Products;
sourceTree = "";
@@ -214,6 +279,7 @@
88E66C22B614E89A7A9E9B35 /* AppDelegate.swift */,
F5CE897758E9CD5BB8B80F63 /* Info.plist */,
B36E23852EF12DC14A2DF2DC /* NASBackupApp.swift */,
+ 0C0E3EBE5A0DBD9D64196501 /* PrivacyInfo.xcprivacy */,
F0AABE0AA63B1ACA1DEABB65 /* SceneDelegate.swift */,
);
path = App;
@@ -258,6 +324,18 @@
path = Theme;
sourceTree = "";
};
+ D7CC50C0264BFFC23DA2C868 /* Tests */ = {
+ isa = PBXGroup;
+ children = (
+ 336D155A4D1C895215A4FFF2 /* BackupEngineTests.swift */,
+ 50191596BF6E84CFAE7904D3 /* ConnectViewModelTests.swift */,
+ F19B5E72ADF230C0C134942B /* LANMonitorTests.swift */,
+ BACE1D31086231C97DAD884D /* MockNASService.swift */,
+ 7688CD7E9BC5475A3EB01021 /* NASProtocolTests.swift */,
+ );
+ path = Tests;
+ sourceTree = "";
+ };
F75092ACBEF36F32816506F7 /* Models */ = {
isa = PBXGroup;
children = (
@@ -285,6 +363,7 @@
buildConfigurationList = F29F169757D793211107ABA5 /* Build configuration list for PBXNativeTarget "NASBackup" */;
buildPhases = (
E6D3CCCB60977A7DABFE7F19 /* Sources */,
+ 6DB4B780F7FFCAAC2B48E78F /* Resources */,
BC332B29F6F9E065FF85AED8 /* Frameworks */,
);
buildRules = (
@@ -300,6 +379,24 @@
productReference = C755BFDD42D46A3FB490A4BB /* NASBackup.app */;
productType = "com.apple.product-type.application";
};
+ 88E89D71F1D8FA18A6A64BBF /* NASBackupTests */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 3B812FFDA55D20BD7BC79D06 /* Build configuration list for PBXNativeTarget "NASBackupTests" */;
+ buildPhases = (
+ E2CED6E8ED76A743BB9C913F /* Sources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ EA438E47054B868AA6F6727D /* PBXTargetDependency */,
+ );
+ name = NASBackupTests;
+ packageProductDependencies = (
+ );
+ productName = NASBackupTests;
+ productReference = E3A1E9181F350DA9F091D019 /* NASBackupTests.xctest */;
+ productType = "com.apple.product-type.bundle.unit-test";
+ };
/* End PBXNativeTarget section */
/* Begin PBXProject section */
@@ -313,6 +410,10 @@
DevelopmentTeam = "";
ProvisioningStyle = Automatic;
};
+ 88E89D71F1D8FA18A6A64BBF = {
+ DevelopmentTeam = "";
+ ProvisioningStyle = Automatic;
+ };
};
};
buildConfigurationList = 1A69DB656166247B44D9B8F9 /* Build configuration list for PBXProject "NASBackup" */;
@@ -334,11 +435,35 @@
projectRoot = "";
targets = (
32316B985B8906FE4CB97730 /* NASBackup */,
+ 88E89D71F1D8FA18A6A64BBF /* NASBackupTests */,
);
};
/* End PBXProject section */
+/* Begin PBXResourcesBuildPhase section */
+ 6DB4B780F7FFCAAC2B48E78F /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ A79F538803790524284BCAD6 /* PrivacyInfo.xcprivacy in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
/* Begin PBXSourcesBuildPhase section */
+ E2CED6E8ED76A743BB9C913F /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ DFD2252BDCE9EE7ABAE63BF9 /* BackupEngineTests.swift in Sources */,
+ 7FB20CB93FECB2F9CBE67161 /* ConnectViewModelTests.swift in Sources */,
+ CE84BC18F045AB65CCA30BB4 /* LANMonitorTests.swift in Sources */,
+ 664E9F44D037A8B41D5A7670 /* MockNASService.swift in Sources */,
+ 685E8F1B44AE5652E93C58CE /* NASProtocolTests.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
E6D3CCCB60977A7DABFE7F19 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
@@ -357,8 +482,11 @@
DF719EAD5727B335001DB50A /* ConnectView.swift in Sources */,
C6AAC362F058E7836A91AC30 /* ConnectViewModel.swift in Sources */,
F9219423E73718D8AB604EB1 /* ConnectionStore.swift in Sources */,
+ 30693CB8EE711577978EF88C /* Date+.swift in Sources */,
6DED667F0AA66503730640F1 /* FolderRow.swift in Sources */,
+ 77A3FF7E2E4F506E7F9E5CCD /* FolderSetupView.swift in Sources */,
6500E80B50EEF877C5127A2A /* HistoryView.swift in Sources */,
+ 8EB77E535C82274276C1E328 /* KeychainStore.swift in Sources */,
CE47E9F2179F86BE0A630D91 /* LANMonitor.swift in Sources */,
7DF923F172E6DFE3DD110DC9 /* LANTriggerSection.swift in Sources */,
45FFF2DCFE454EFDF642CEB1 /* LoginView.swift in Sources */,
@@ -367,20 +495,50 @@
7872072DD0E0B8D51C1A12DF /* NASBackupApp.swift in Sources */,
512B235FB16DEFBBD9EA4E1C /* NASConnection.swift in Sources */,
8C1C09CA16A5D71408468BCB /* NASTransferProtocol.swift in Sources */,
+ 4148416D31C8AC65FEE6394F /* PHAsset+.swift in Sources */,
DCE9AF4F0160E323224E3553 /* PhotoLibraryProtocol.swift in Sources */,
57656BC4B5A2E81FE7BF37FD /* PhotoLibraryService.swift in Sources */,
+ E24CEE0D51131D991038205D /* PhotoPermissionView.swift in Sources */,
B50445A9010E20F97E9E6E48 /* ProgressRing.swift in Sources */,
BC4346F01DAA4DE7D8F260DB /* SFTPService.swift in Sources */,
9758372DD65F5353C72DE5C5 /* SMBService.swift in Sources */,
+ 13FCA7D0BB01BFC46A5B725B /* SavedConnections.swift in Sources */,
A6D143C05EE16594E0D1AA01 /* SceneDelegate.swift in Sources */,
C8E9BC6070E23992EED8D013 /* SettingsView.swift in Sources */,
B08D787E99C3B2E193A0D694 /* ToggleRow.swift in Sources */,
+ 96CC879835A8CFE5FDD21B85 /* UGreenCompatibilityBadge.swift in Sources */,
+ 10062AA91BC883AE597443BD /* URL+.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
+/* Begin PBXTargetDependency section */
+ EA438E47054B868AA6F6727D /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = 32316B985B8906FE4CB97730 /* NASBackup */;
+ targetProxy = 2F6BE82D286218B2DC8D8B98 /* PBXContainerItemProxy */;
+ };
+/* End PBXTargetDependency section */
+
/* Begin XCBuildConfiguration section */
+ 53DABEDF383DBBE47A3A3FD7 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ IPHONEOS_DEPLOYMENT_TARGET = 16.0;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ "@loader_path/Frameworks",
+ );
+ SDKROOT = iphoneos;
+ SWIFT_VERSION = 5.9;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/NASBackup.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/NASBackup";
+ };
+ name = Release;
+ };
5496D0713D70962E349CB0C8 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
@@ -397,6 +555,23 @@
};
name = Debug;
};
+ 7215384C2378F778AC60259A /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ IPHONEOS_DEPLOYMENT_TARGET = 16.0;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ "@loader_path/Frameworks",
+ );
+ SDKROOT = iphoneos;
+ SWIFT_VERSION = 5.9;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/NASBackup.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/NASBackup";
+ };
+ name = Debug;
+ };
AAEAFE599F3F20DB264D4528 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
@@ -554,6 +729,15 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
+ 3B812FFDA55D20BD7BC79D06 /* Build configuration list for PBXNativeTarget "NASBackupTests" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 7215384C2378F778AC60259A /* Debug */,
+ 53DABEDF383DBBE47A3A3FD7 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Debug;
+ };
F29F169757D793211107ABA5 /* Build configuration list for PBXNativeTarget "NASBackup" */ = {
isa = XCConfigurationList;
buildConfigurations = (
diff --git a/Services/BackupEngine.swift b/Services/BackupEngine.swift
index c630daf..0d77571 100644
--- a/Services/BackupEngine.swift
+++ b/Services/BackupEngine.swift
@@ -1,5 +1,8 @@
import Foundation
import UserNotifications
+import os.log
+
+private let logger = Logger(subsystem: "com.albert.nasbackup", category: "BackupEngine")
@MainActor
final class BackupEngine: ObservableObject {
@@ -7,11 +10,30 @@ final class BackupEngine: ObservableObject {
@Published private(set) var job: BackupJob = BackupJob()
- private let photoService: PhotoLibraryProtocol = PhotoLibraryService()
+ private let photoService: PhotoLibraryProtocol
+ private let transferFactory: (NASProtocol) -> any NASTransferProtocol
private var activeTransfer: (any NASTransferProtocol)?
private var isCancelled = false
- private init() {}
+ private init(
+ photos: PhotoLibraryProtocol = PhotoLibraryService(),
+ transferFactory: @escaping (NASProtocol) -> any NASTransferProtocol = { proto in
+ switch proto {
+ case .smb: return SMBService()
+ case .sftp: return SFTPService()
+ }
+ }
+ ) {
+ self.photoService = photos
+ self.transferFactory = transferFactory
+ }
+
+ static func testInstance(
+ transfer: any NASTransferProtocol,
+ photos: PhotoLibraryProtocol
+ ) -> BackupEngine {
+ BackupEngine(photos: photos, transferFactory: { _ in transfer })
+ }
func run(
connection: NASConnection,
@@ -36,11 +58,7 @@ final class BackupEngine: ObservableObject {
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()
- }
+ let transfer = transferFactory(connection.nasProtocol)
try await transfer.connect(
to: host,
@@ -102,6 +120,7 @@ final class BackupEngine: ObservableObject {
job.fileCompleted(skipped: false)
uploaded += 1
} catch {
+ logger.error("Upload failed for \(asset.filename, privacy: .public): \(error.localizedDescription, privacy: .public)")
job.fileFailed()
failed += 1
}
diff --git a/Services/PhotoLibraryService.swift b/Services/PhotoLibraryService.swift
index 946ff1b..5b32da5 100644
--- a/Services/PhotoLibraryService.swift
+++ b/Services/PhotoLibraryService.swift
@@ -44,25 +44,26 @@ final class PhotoLibraryService: PhotoLibraryProtocol {
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 }
+ autoreleasepool {
+ let isScreenshot = asset.mediaSubtypes.contains(.photoScreenshot)
+ let resources = PHAssetResource.assetResources(for: asset)
+ let isRAW = resources.contains { $0.type == .alternatePhoto }
+ if isRAW && !filter.includeRAW { return }
- let filename = resources.first?.originalFilename ?? "\(asset.localIdentifier).jpg"
+ 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
- ))
+ 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
}
diff --git a/Shared/Components/ButtonStyles.swift b/Shared/Components/ButtonStyles.swift
index 7b4363f..7d41290 100644
--- a/Shared/Components/ButtonStyles.swift
+++ b/Shared/Components/ButtonStyles.swift
@@ -1,6 +1,6 @@
import SwiftUI
-// MARK: — Primary (black fill)
+// MARK: — Primary (ink fill)
struct PrimaryButtonStyle: ButtonStyle {
var color: Color = AppTheme.ink
var height: CGFloat = 50
@@ -8,7 +8,7 @@ struct PrimaryButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.system(size: 15, weight: .medium))
- .foregroundColor(.white)
+ .foregroundStyle(AppTheme.inkInverse)
.frame(maxWidth: .infinity)
.frame(height: height)
.background(color)
@@ -25,12 +25,12 @@ struct GhostButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.system(size: 15, weight: .medium))
- .foregroundColor(AppTheme.ink)
+ .foregroundStyle(AppTheme.ink)
.frame(maxWidth: .infinity)
.frame(height: height)
.overlay(
RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)
- .stroke(AppTheme.cardBorderColor, lineWidth: 1)
+ .stroke(AppTheme.ink.opacity(0.25), lineWidth: 1)
)
.scaleEffect(configuration.isPressed ? 0.97 : 1)
.animation(.spring(response: 0.25, dampingFraction: 0.7), value: configuration.isPressed)
@@ -52,7 +52,7 @@ struct PillButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.system(size: 13, weight: .medium))
- .foregroundColor(.white)
+ .foregroundStyle(AppTheme.inkInverse)
.padding(.horizontal, 14)
.padding(.vertical, 7)
.background(color)
diff --git a/Shared/Components/ToggleRow.swift b/Shared/Components/ToggleRow.swift
index 9abe073..895114d 100644
--- a/Shared/Components/ToggleRow.swift
+++ b/Shared/Components/ToggleRow.swift
@@ -29,6 +29,8 @@ struct ToggleRow: View {
Toggle("", isOn: $isOn)
.labelsHidden()
.tint(AppTheme.ink)
+ .accessibilityLabel(title)
+ .accessibilityValue(isOn ? "On" : "Off")
}
.padding(.horizontal, AppTheme.cardPad)
.padding(.vertical, 12)
diff --git a/Shared/Components/UGreenCompatibilityBadge.swift b/Shared/Components/UGreenCompatibilityBadge.swift
new file mode 100644
index 0000000..110e99e
--- /dev/null
+++ b/Shared/Components/UGreenCompatibilityBadge.swift
@@ -0,0 +1,46 @@
+import SwiftUI
+import UIKit
+
+// Displays the UGreen NAS compatibility indicator.
+// If you add an image named "ugreen_logo" to Assets.xcassets it will be used automatically.
+// Otherwise a brand-styled text badge is shown.
+struct UGreenCompatibilityBadge: View {
+ private static let ugreenGreen = Color(hex: "#00B388")
+ private let hasLogo = UIImage(named: "ugreen_logo") != nil
+
+ var body: some View {
+ HStack(spacing: 8) {
+ // UGreen logo or fallback wordmark
+ if hasLogo {
+ Image("ugreen_logo")
+ .resizable()
+ .scaledToFit()
+ .frame(height: 14)
+ } else {
+ HStack(spacing: 4) {
+ // Brand dot
+ Circle()
+ .fill(Self.ugreenGreen)
+ .frame(width: 6, height: 6)
+ Text("UGreen NAS")
+ .font(.system(size: 11, weight: .semibold))
+ .foregroundStyle(Self.ugreenGreen)
+ }
+ }
+
+ // Separator
+ Rectangle()
+ .fill(AppTheme.inkQuaternary)
+ .frame(width: 0.5, height: 11)
+
+ // Protocol hint
+ Text("SMB · SFTP")
+ .font(.system(size: 11, weight: .regular))
+ .foregroundStyle(AppTheme.inkTertiary)
+ }
+ .padding(.horizontal, 12)
+ .padding(.vertical, 6)
+ .background(AppTheme.surfaceSunken)
+ .clipShape(Capsule(style: .continuous))
+ }
+}
diff --git a/Shared/Extensions/Date+.swift b/Shared/Extensions/Date+.swift
new file mode 100644
index 0000000..95002a1
--- /dev/null
+++ b/Shared/Extensions/Date+.swift
@@ -0,0 +1,30 @@
+import Foundation
+
+extension Date {
+ private static let friendlyFormatter: DateFormatter = {
+ let f = DateFormatter()
+ f.dateStyle = .medium
+ f.timeStyle = .short
+ return f
+ }()
+
+ private static let relativeFormatter: RelativeDateTimeFormatter = {
+ let f = RelativeDateTimeFormatter()
+ f.unitsStyle = .abbreviated
+ return f
+ }()
+
+ var friendlyString: String {
+ Date.friendlyFormatter.string(from: self)
+ }
+
+ var relativeString: String {
+ Date.relativeFormatter.localizedString(for: self, relativeTo: Date())
+ }
+
+ var backupTimestamp: String {
+ let f = DateFormatter()
+ f.dateFormat = "yyyyMMdd_HHmmss"
+ return f.string(from: self)
+ }
+}
diff --git a/Shared/Extensions/PHAsset+.swift b/Shared/Extensions/PHAsset+.swift
new file mode 100644
index 0000000..50a4b99
--- /dev/null
+++ b/Shared/Extensions/PHAsset+.swift
@@ -0,0 +1,14 @@
+import Photos
+
+extension PHAsset {
+ var primaryFilename: String {
+ let resources = PHAssetResource.assetResources(for: self)
+ return resources.first?.originalFilename
+ ?? "\(localIdentifier.prefix(8)).jpg"
+ }
+
+ var isRAWCapture: Bool {
+ PHAssetResource.assetResources(for: self)
+ .contains { $0.type == .alternatePhoto }
+ }
+}
diff --git a/Shared/Extensions/URL+.swift b/Shared/Extensions/URL+.swift
new file mode 100644
index 0000000..5b958f6
--- /dev/null
+++ b/Shared/Extensions/URL+.swift
@@ -0,0 +1,14 @@
+import Foundation
+
+extension URL {
+ var fileSize: Int64 {
+ (try? resourceValues(forKeys: [.fileSizeKey]).fileSize)
+ .flatMap { Int64($0) } ?? 0
+ }
+
+ var sanitizedFilename: String {
+ lastPathComponent
+ .components(separatedBy: .init(charactersIn: "/\\:*?\"<>|"))
+ .joined(separator: "_")
+ }
+}
diff --git a/Shared/Persistence/ConnectionStore.swift b/Shared/Persistence/ConnectionStore.swift
index cfb69d2..89aaaf0 100644
--- a/Shared/Persistence/ConnectionStore.swift
+++ b/Shared/Persistence/ConnectionStore.swift
@@ -15,7 +15,10 @@ final class ConnectionStore: ObservableObject {
static let shared = ConnectionStore()
@Published var savedConnection: NASConnection? {
- didSet { saveUD(savedConnection, key: "savedConnection") }
+ didSet {
+ if let conn = savedConnection { SavedConnections.save(conn) }
+ else { SavedConnections.delete() }
+ }
}
@Published var backupFilter: BackupFilter {
didSet { saveUD(backupFilter, key: "backupFilter") }
@@ -42,6 +45,19 @@ final class ConnectionStore: ObservableObject {
didSet { UserDefaults.standard.set(quietHoursEnd, forKey: "quietHoursEnd") }
}
+ // Session — not persisted, resets on every app launch
+ @Published var isSessionActive = false
+
+ // Onboarding — persisted so we only show each step once
+ @Published var onboardingPhotoShown: Bool {
+ didSet { UserDefaults.standard.set(onboardingPhotoShown, forKey: "onboardingPhotoShown") }
+ }
+
+ // Appearance
+ @Published var appearanceMode: AppearanceMode {
+ didSet { UserDefaults.standard.set(appearanceMode.rawValue, forKey: "appearanceMode") }
+ }
+
// Cellular & remote access
@Published var allowCellularBackup: Bool {
didSet { UserDefaults.standard.set(allowCellularBackup, forKey: "allowCellularBackup") }
@@ -56,7 +72,7 @@ final class ConnectionStore: ObservableObject {
@Published private(set) var historyEntries: [BackupHistoryEntry] = []
private init() {
- savedConnection = ud(NASConnection.self, key: "savedConnection")
+ savedConnection = SavedConnections.load()
backupFilter = ud(BackupFilter.self, key: "backupFilter") ?? BackupFilter()
trustedSSIDs = UserDefaults.standard.stringArray(forKey: "trustedSSIDs") ?? []
lanTriggerEnabled = UserDefaults.standard.object(forKey: "lanTriggerEnabled") as? Bool ?? true
@@ -65,6 +81,8 @@ final class ConnectionStore: ObservableObject {
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
+ onboardingPhotoShown = UserDefaults.standard.bool(forKey: "onboardingPhotoShown")
+ appearanceMode = AppearanceMode(rawValue: UserDefaults.standard.string(forKey: "appearanceMode") ?? "") ?? .system
allowCellularBackup = UserDefaults.standard.object(forKey: "allowCellularBackup") as? Bool ?? false
useTailscaleWhenRemote = UserDefaults.standard.object(forKey: "useTailscaleWhenRemote") as? Bool ?? false
tailscaleHost = UserDefaults.standard.string(forKey: "tailscaleHost") ?? ""
diff --git a/Shared/Persistence/KeychainStore.swift b/Shared/Persistence/KeychainStore.swift
new file mode 100644
index 0000000..f8e99eb
--- /dev/null
+++ b/Shared/Persistence/KeychainStore.swift
@@ -0,0 +1,48 @@
+import Foundation
+import Security
+
+enum KeychainStore {
+ private static func key(for id: UUID) -> String {
+ "com.nasbackup.connection.\(id.uuidString)"
+ }
+
+ static func savePassword(_ password: String, for id: UUID) {
+ let account = key(for: id)
+ let data = Data(password.utf8)
+
+ let deleteQuery: [CFString: Any] = [
+ kSecClass: kSecClassGenericPassword,
+ kSecAttrAccount: account
+ ]
+ SecItemDelete(deleteQuery as CFDictionary)
+
+ let addQuery: [CFString: Any] = [
+ kSecClass: kSecClassGenericPassword,
+ kSecAttrAccount: account,
+ kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
+ kSecValueData: data
+ ]
+ SecItemAdd(addQuery as CFDictionary, nil)
+ }
+
+ static func loadPassword(for id: UUID) -> String? {
+ let query: [CFString: Any] = [
+ kSecClass: kSecClassGenericPassword,
+ kSecAttrAccount: key(for: id),
+ kSecReturnData: true,
+ kSecMatchLimit: kSecMatchLimitOne
+ ]
+ var result: AnyObject?
+ guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
+ let data = result as? Data else { return nil }
+ return String(data: data, encoding: .utf8)
+ }
+
+ static func deletePassword(for id: UUID) {
+ let query: [CFString: Any] = [
+ kSecClass: kSecClassGenericPassword,
+ kSecAttrAccount: key(for: id)
+ ]
+ SecItemDelete(query as CFDictionary)
+ }
+}
diff --git a/Shared/Persistence/SavedConnections.swift b/Shared/Persistence/SavedConnections.swift
new file mode 100644
index 0000000..1262a89
--- /dev/null
+++ b/Shared/Persistence/SavedConnections.swift
@@ -0,0 +1,30 @@
+import Foundation
+
+// Bridges NASConnection save/load with Keychain for password and UserDefaults for metadata.
+enum SavedConnections {
+ private static let key = "savedConnection"
+
+ static func save(_ connection: NASConnection) {
+ KeychainStore.savePassword(connection.password, for: connection.id)
+ if let data = try? JSONEncoder().encode(connection) {
+ UserDefaults.standard.set(data, forKey: key)
+ }
+ }
+
+ static func load() -> NASConnection? {
+ guard let data = UserDefaults.standard.data(forKey: key),
+ var connection = try? JSONDecoder().decode(NASConnection.self, from: data) else {
+ return nil
+ }
+ connection.password = KeychainStore.loadPassword(for: connection.id) ?? ""
+ return connection
+ }
+
+ static func delete() {
+ if let data = UserDefaults.standard.data(forKey: key),
+ let connection = try? JSONDecoder().decode(NASConnection.self, from: data) {
+ KeychainStore.deletePassword(for: connection.id)
+ }
+ UserDefaults.standard.removeObject(forKey: key)
+ }
+}
diff --git a/Shared/Theme/AppTheme.swift b/Shared/Theme/AppTheme.swift
index ab8259e..9d581cd 100644
--- a/Shared/Theme/AppTheme.swift
+++ b/Shared/Theme/AppTheme.swift
@@ -1,55 +1,107 @@
import SwiftUI
+import UIKit
+
+// MARK: — Appearance preference
+
+enum AppearanceMode: String, CaseIterable {
+ case system, light, dark
+
+ var resolvedScheme: ColorScheme? {
+ switch self {
+ case .system: return nil
+ case .light: return .light
+ case .dark: return .dark
+ }
+ }
+
+ var label: String {
+ switch self {
+ case .system: return "System"
+ case .light: return "Light"
+ case .dark: return "Dark"
+ }
+ }
+
+ var icon: String {
+ switch self {
+ case .system: return "circle.lefthalf.filled"
+ case .light: return "sun.max"
+ case .dark: return "moon"
+ }
+ }
+}
enum AppTheme {
// MARK: — Surfaces
- static let background = Color.white
- static let surfaceRaised = Color.white
- static let surfaceSunken = Color(hex: "#F5F5F5")
+ static let background = Color(UIColor.systemBackground)
+ static let surfaceRaised = Color(UIColor.systemBackground)
+ static let surfaceSunken = Color(UIColor { t in
+ t.userInterfaceStyle == .dark
+ ? UIColor(white: 0.11, alpha: 1)
+ : UIColor(white: 0.961, alpha: 1)
+ })
// MARK: — Ink
- static let ink = Color(hex: "#0F0F0F")
- static let inkSecondary = Color(hex: "#6B6B6B")
- static let inkTertiary = Color(hex: "#ABABAB")
- static let inkQuaternary = Color(hex: "#D4D4D4")
+ static let ink = Color(UIColor { t in
+ t.userInterfaceStyle == .dark
+ ? UIColor(white: 0.93, alpha: 1)
+ : UIColor(red: 0.059, green: 0.059, blue: 0.059, alpha: 1)
+ })
+ static let inkSecondary = Color(UIColor { t in
+ t.userInterfaceStyle == .dark
+ ? UIColor(white: 0.58, alpha: 1)
+ : UIColor(white: 0.42, alpha: 1)
+ })
+ static let inkTertiary = Color(UIColor { t in
+ t.userInterfaceStyle == .dark
+ ? UIColor(white: 0.40, alpha: 1)
+ : UIColor(white: 0.67, alpha: 1)
+ })
+ static let inkQuaternary = Color(UIColor { t in
+ t.userInterfaceStyle == .dark
+ ? UIColor(white: 0.22, alpha: 1)
+ : UIColor(white: 0.83, alpha: 1)
+ })
+
+ // Readable on AppTheme.ink background in both color schemes
+ static let inkInverse = Color(UIColor { t in
+ t.userInterfaceStyle == .dark
+ ? UIColor(red: 0.059, green: 0.059, blue: 0.059, alpha: 1)
+ : UIColor(white: 1.0, alpha: 1)
+ })
// MARK: — Semantic
- static let positive = Color(hex: "#16A34A")
- static let destructive = Color(hex: "#DC2626")
- static let interactive = Color(hex: "#2563EB")
+ static let positive = Color(hex: "#16A34A")
+ static let destructive = Color(hex: "#DC2626")
+ static let interactive = Color(hex: "#2563EB")
// MARK: — Chrome
- static let separator = Color.black.opacity(0.07)
- static let cardShadow = Color.black.opacity(0.05)
- static let cardBorderColor = Color.black.opacity(0.07)
+ static let separator = Color(UIColor.separator)
+ static let cardShadow = Color(UIColor { t in
+ t.userInterfaceStyle == .dark
+ ? UIColor.clear
+ : UIColor.black.withAlphaComponent(0.048)
+ })
+ static let cardBorderColor = Color(UIColor.separator)
// MARK: — Metrics
- static let radius: CGFloat = 14
- static let radiusLarge: CGFloat = 18
- static let hPad: CGFloat = 20
- static let cardPad: CGFloat = 16
- static let sectionGap: CGFloat = 28
+ static let radius: CGFloat = 14
+ static let radiusLarge: CGFloat = 18
+ static let hPad: CGFloat = 20
+ static let cardPad: CGFloat = 16
+ static let sectionGap: CGFloat = 28
// MARK: — Typography
static func display(_ size: CGFloat = 32) -> Font {
.system(size: size, weight: .semibold, design: .default)
}
- static func title(_ size: CGFloat = 20) -> Font {
- .system(size: size, weight: .semibold)
- }
- static func headline(_ size: CGFloat = 15) -> Font {
- .system(size: size, weight: .medium)
- }
- static func body(_ size: CGFloat = 15) -> Font {
- .system(size: size, weight: .regular)
- }
- static func caption(_ size: CGFloat = 13) -> Font {
- .system(size: size, weight: .regular)
- }
- static func micro(_ size: CGFloat = 11) -> Font {
- .system(size: size, weight: .medium)
- }
+ static func title(_ size: CGFloat = 20) -> Font { .system(size: size, weight: .semibold) }
+ static func headline(_ size: CGFloat = 15) -> Font { .system(size: size, weight: .medium) }
+ static func body(_ size: CGFloat = 15) -> Font { .system(size: size, weight: .regular) }
+ static func caption(_ size: CGFloat = 13) -> Font { .system(size: size, weight: .regular) }
+ static func micro(_ size: CGFloat = 11) -> Font { .system(size: size, weight: .medium) }
- // MARK: — Back-compat aliases used by existing call sites
+ // MARK: — Back-compat aliases
static let blue = interactive
static let green = positive
static let red = destructive
@@ -82,9 +134,7 @@ struct CardStyle: ViewModifier {
}
extension View {
- func card(padded: Bool = true) -> some View {
- modifier(CardStyle(padded: padded))
- }
+ func card(padded: Bool = true) -> some View { modifier(CardStyle(padded: padded)) }
@ViewBuilder func `if`(_ condition: Bool, transform: (Self) -> T) -> some View {
if condition { transform(self) } else { self }
diff --git a/Tests/BackupEngineTests.swift b/Tests/BackupEngineTests.swift
new file mode 100644
index 0000000..d631347
--- /dev/null
+++ b/Tests/BackupEngineTests.swift
@@ -0,0 +1,117 @@
+import XCTest
+import Photos
+@testable import NASBackup
+
+@MainActor
+final class BackupEngineTests: XCTestCase {
+
+ private func makeAssets(count: Int) -> [PhotoAsset] {
+ (0.. NASConnection {
+ NASConnection(host: "192.168.1.10", nasProtocol: .smb,
+ username: "user", password: "pass", remotePath: "/backup")
+ }
+
+ func test_backup_uploadsAllAssets() async throws {
+ let mock = MockNASService()
+ let photos = MockPhotoLibraryService(assets: makeAssets(count: 3))
+ let engine = BackupEngine.testInstance(transfer: mock, photos: photos)
+
+ let result = try await engine.run(connection: makeConnection(), filter: BackupFilter())
+
+ XCTAssertEqual(result.uploadedCount, 3)
+ XCTAssertEqual(result.failedCount, 0)
+ XCTAssertEqual(mock.uploadCallCount, 3)
+ }
+
+ func test_backup_skipsExistingFiles_whenNewFilesOnly() async throws {
+ let mock = MockNASService()
+ mock.fileExistsAnswer = true
+ let photos = MockPhotoLibraryService(assets: makeAssets(count: 2))
+ let engine = BackupEngine.testInstance(transfer: mock, photos: photos)
+
+ var filter = BackupFilter()
+ filter.newFilesOnly = true
+ let result = try await engine.run(connection: makeConnection(), filter: filter)
+
+ XCTAssertEqual(result.skippedCount, 2)
+ XCTAssertEqual(result.uploadedCount, 0)
+ }
+
+ func test_backup_handlesUploadFailure_gracefully() async throws {
+ let mock = MockNASService()
+ mock.shouldFail = true
+ let photos = MockPhotoLibraryService(assets: makeAssets(count: 2))
+ let engine = BackupEngine.testInstance(transfer: mock, photos: photos)
+
+ let result = try await engine.run(connection: makeConnection(), filter: BackupFilter())
+
+ XCTAssertEqual(result.failedCount, 2)
+ XCTAssertEqual(result.uploadedCount, 0)
+ }
+
+ func test_backup_emitsProgressUpdates() async throws {
+ let mock = MockNASService()
+ let photos = MockPhotoLibraryService(assets: makeAssets(count: 5))
+ let engine = BackupEngine.testInstance(transfer: mock, photos: photos)
+
+ var progressEvents = 0
+ let cancellable = engine.$job.sink { job in
+ if case .running = job.status { progressEvents += 1 }
+ }
+ defer { cancellable.cancel() }
+
+ _ = try await engine.run(connection: makeConnection(), filter: BackupFilter())
+
+ XCTAssertGreaterThan(progressEvents, 0, "Expected progress events during backup")
+ }
+
+ func test_backup_canBePaused_andResumed() async throws {
+ let mock = MockNASService()
+ mock.uploadDelay = 0.05
+ let photos = MockPhotoLibraryService(assets: makeAssets(count: 4))
+ let engine = BackupEngine.testInstance(transfer: mock, photos: photos)
+
+ let task = Task { try await engine.run(connection: makeConnection(), filter: BackupFilter()) }
+ try await Task.sleep(nanoseconds: 70_000_000)
+ engine.pause()
+ let statusAfterPause = engine.job.status
+ engine.resume()
+ let result = try await task.value
+
+ if case .paused = statusAfterPause { } else {
+ XCTFail("Expected .paused status, got \(statusAfterPause)")
+ }
+ XCTAssertEqual(result.uploadedCount, 4)
+ }
+}
+
+// MARK: - MockPhotoLibraryService
+
+final class MockPhotoLibraryService: PhotoLibraryProtocol {
+ var authorizationStatus: PHAuthorizationStatus { .authorized }
+ private let assets: [PhotoAsset]
+
+ init(assets: [PhotoAsset]) { self.assets = assets }
+
+ func requestAuthorization() async -> PHAuthorizationStatus { .authorized }
+
+ func fetchAssets(filter: BackupFilter) -> [PhotoAsset] { assets }
+
+ func exportAsset(_ asset: PhotoAsset) async throws -> URL {
+ let url = FileManager.default.temporaryDirectory.appendingPathComponent(asset.filename)
+ try Data("mock-data".utf8).write(to: url)
+ return url
+ }
+}
diff --git a/Tests/ConnectViewModelTests.swift b/Tests/ConnectViewModelTests.swift
new file mode 100644
index 0000000..66dca3a
--- /dev/null
+++ b/Tests/ConnectViewModelTests.swift
@@ -0,0 +1,47 @@
+import XCTest
+@testable import NASBackup
+
+@MainActor
+final class ConnectViewModelTests: XCTestCase {
+
+ func test_canConnect_isFalse_whenFieldsEmpty() {
+ let vm = ConnectViewModel()
+ XCTAssertFalse(vm.canConnect)
+ }
+
+ func test_canConnect_isTrue_whenAllFieldsFilled() {
+ let vm = ConnectViewModel()
+ vm.host = "192.168.1.10"
+ vm.username = "admin"
+ vm.password = "secret"
+ XCTAssertTrue(vm.canConnect)
+ }
+
+ func test_connectionState_becomesVerified_onSuccess() async {
+ let vm = ConnectViewModel()
+ vm.host = "192.168.1.10"
+ vm.username = "admin"
+ vm.password = "secret"
+
+ // Override the service factory so no real network call is made.
+ let mock = MockNASService()
+ await vm.verifyWith(transfer: mock)
+
+ XCTAssertTrue(vm.isConnected)
+ XCTAssertNil(vm.verifyError)
+ }
+
+ func test_connectionState_becomesFailed_onError() async {
+ let vm = ConnectViewModel()
+ vm.host = "192.168.1.10"
+ vm.username = "admin"
+ vm.password = "wrong"
+
+ let mock = MockNASService()
+ mock.shouldFail = true
+ await vm.verifyWith(transfer: mock)
+
+ XCTAssertFalse(vm.isConnected)
+ XCTAssertNotNil(vm.verifyError)
+ }
+}
diff --git a/Tests/LANMonitorTests.swift b/Tests/LANMonitorTests.swift
new file mode 100644
index 0000000..7535d8a
--- /dev/null
+++ b/Tests/LANMonitorTests.swift
@@ -0,0 +1,48 @@
+import XCTest
+@testable import NASBackup
+
+final class LANMonitorTests: XCTestCase {
+
+ func test_ssidMatch_triggersCallback() async {
+ let monitor = LANMonitor.shared
+ let trusted = ["HomeNet"]
+
+ var triggered = false
+ monitor.onTrustedNetworkJoined = { _ in triggered = true }
+
+ // Inject known SSID via checkAndTriggerIfTrusted using a subclass stub
+ // Since fetchCurrentSSID uses NEHotspotNetwork (requires device), we test
+ // the guard logic directly through checkAndTriggerIfTrusted with a mock SSID.
+ let stub = LANMonitorSSIDStub(ssid: "HomeNet")
+ await stub.checkAndTrigger(trustedSSIDs: trusted) { ssid in triggered = true }
+
+ XCTAssertTrue(triggered)
+ }
+
+ func test_ssidMismatch_doesNotTrigger() async {
+ var triggered = false
+ let stub = LANMonitorSSIDStub(ssid: "RandomCafe")
+ await stub.checkAndTrigger(trustedSSIDs: ["HomeNet"]) { _ in triggered = true }
+ XCTAssertFalse(triggered)
+ }
+
+ func test_trustedSSIDs_caseInsensitiveMatch() async {
+ var triggered = false
+ let stub = LANMonitorSSIDStub(ssid: "homenet")
+ let trusted = ["HomeNet"]
+ // Case-sensitive check — verify current behaviour; adjust if policy changes.
+ await stub.checkAndTrigger(trustedSSIDs: trusted) { _ in triggered = true }
+ // Current implementation is case-sensitive; update assertion if behaviour changes.
+ XCTAssertFalse(triggered, "Current implementation is case-sensitive")
+ }
+}
+
+// Lightweight stub that bypasses NEHotspotNetwork entitlement requirement.
+struct LANMonitorSSIDStub {
+ let ssid: String?
+
+ func checkAndTrigger(trustedSSIDs: [String], callback: (String) -> Void) async {
+ guard let ssid, trustedSSIDs.contains(ssid) else { return }
+ callback(ssid)
+ }
+}
diff --git a/Tests/MockNASService.swift b/Tests/MockNASService.swift
new file mode 100644
index 0000000..2a42c8f
--- /dev/null
+++ b/Tests/MockNASService.swift
@@ -0,0 +1,49 @@
+import Foundation
+@testable import NASBackup
+
+final class MockNASService: NASTransferProtocol {
+ var shouldFail: Bool = false
+ var uploadDelay: TimeInterval = 0
+ var fakeItems: [NASItem] = []
+ var fileExistsAnswer: Bool = false
+
+ private(set) var connectCalled = false
+ private(set) var uploadCallCount = 0
+ private(set) var lastUploadedPath: String?
+
+ var isConnected: Bool = false
+
+ func connect(to host: String, port: Int, username: String, password: String) async throws {
+ connectCalled = true
+ if shouldFail { throw MockError.connectionFailed }
+ isConnected = true
+ }
+
+ func disconnect() { isConnected = false }
+
+ func listDirectory(at path: String) async throws -> [NASItem] {
+ if shouldFail { throw MockError.connectionFailed }
+ return fakeItems
+ }
+
+ func createDirectory(at path: String) async throws {
+ if shouldFail { throw MockError.connectionFailed }
+ }
+
+ func fileExists(at remotePath: String) async throws -> Bool { fileExistsAnswer }
+
+ func upload(localURL: URL, remotePath: String, progress: @escaping (Int64, Int64) -> Void) async throws {
+ if uploadDelay > 0 {
+ try await Task.sleep(nanoseconds: UInt64(uploadDelay * 1_000_000_000))
+ }
+ if shouldFail { throw MockError.uploadFailed }
+ progress(100, 100)
+ uploadCallCount += 1
+ lastUploadedPath = remotePath
+ }
+}
+
+enum MockError: Error {
+ case connectionFailed
+ case uploadFailed
+}
diff --git a/Tests/NASProtocolTests.swift b/Tests/NASProtocolTests.swift
new file mode 100644
index 0000000..0db96c2
--- /dev/null
+++ b/Tests/NASProtocolTests.swift
@@ -0,0 +1,22 @@
+import XCTest
+@testable import NASBackup
+
+final class NASProtocolTests: XCTestCase {
+
+ func test_allCases_haveLocalizedDescriptions() {
+ for proto in NASProtocol.allCases {
+ XCTAssertFalse(
+ proto.localizedDescription.isEmpty,
+ "\(proto.rawValue) has no localizedDescription"
+ )
+ }
+ }
+
+ func test_smb_defaultPort_is445() {
+ XCTAssertEqual(NASProtocol.smb.defaultPort, 445)
+ }
+
+ func test_sftp_defaultPort_is22() {
+ XCTAssertEqual(NASProtocol.sftp.defaultPort, 22)
+ }
+}
diff --git a/project.yml b/project.yml
index dc0d203..6972971 100644
--- a/project.yml
+++ b/project.yml
@@ -36,6 +36,8 @@ targets:
- Shared
resources:
- Assets.xcassets
+ - path: App/PrivacyInfo.xcprivacy
+ buildPhase: resources
info:
path: App/Info.plist
properties:
@@ -74,3 +76,17 @@ targets:
product: SMBClient
- package: Citadel
product: Citadel
+
+ NASBackupTests:
+ type: bundle.unit-test
+ platform: iOS
+ sources:
+ - Tests
+ dependencies:
+ - target: NASBackup
+ settings:
+ base:
+ TEST_HOST: $(BUILT_PRODUCTS_DIR)/NASBackup.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/NASBackup
+ BUNDLE_LOADER: $(TEST_HOST)
+ SWIFT_VERSION: "5.9"
+ IPHONEOS_DEPLOYMENT_TARGET: "16.0"