feat: Kisani rebrand, step-based onboarding flow, dark mode fixes
- Rename app to Kisani in all UI text and Face ID prompt - Replace flat 2-state RootView with 5-step flow: ConnectView → LoginView → FolderSetupView → PhotoPermissionView → MainTabView - Remove NavigationStack from LoginView (was causing nested-stack crash); drive navigation via ConnectionStore.isSessionActive - Add FolderSetupView and PhotoPermissionView onboarding screens with step indicator (2/3, 3/3), spring entrance animations, and UGreen connection chip - Fix dark mode button invisibility: PrimaryButtonStyle and PillButtonStyle now use AppTheme.inkInverse; GhostButtonStyle border uses ink.opacity(0.25) - Add AppTheme.inkInverse adaptive token (dark bg ink on dark, white on light) - Add ConnectionStore.isSessionActive (non-persisted) and onboardingPhotoShown (UserDefaults-persisted) - Add os_log logging in LoginViewModel at auth start/success/failure - Remove dead Help button from ConnectView - UGreenCompatibilityBadge, KeychainStore, SavedConnections, extensions, PrivacyInfo.xcprivacy, unit test target and test stubs - AppTheme: full UIColor dynamic provider tokens for dark/light adaptive color - AppearanceMode enum + segmented picker in SettingsView - BackupView: enlarged ring, removed options card and linear progress bar - HistoryView: duration and bytes formatting, improved typography hierarchy Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import Photos
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct NASBackupApp: App {
|
struct NASBackupApp: App {
|
||||||
@@ -14,6 +15,7 @@ struct NASBackupApp: App {
|
|||||||
.environmentObject(store)
|
.environmentObject(store)
|
||||||
.environmentObject(engine)
|
.environmentObject(engine)
|
||||||
.environmentObject(lanMonitor)
|
.environmentObject(lanMonitor)
|
||||||
|
.preferredColorScheme(store.appearanceMode.resolvedScheme)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -22,10 +24,21 @@ struct RootView: View {
|
|||||||
@EnvironmentObject var store: ConnectionStore
|
@EnvironmentObject var store: ConnectionStore
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if store.savedConnection != nil {
|
Group {
|
||||||
LoginView()
|
if store.savedConnection == nil {
|
||||||
} else {
|
|
||||||
ConnectView()
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
44
App/PrivacyInfo.xcprivacy
Normal file
44
App/PrivacyInfo.xcprivacy
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>NSPrivacyAccessedAPITypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>NSPrivacyAccessedAPIType</key>
|
||||||
|
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
|
||||||
|
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||||
|
<array>
|
||||||
|
<string>C617.1</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
<dict>
|
||||||
|
<key>NSPrivacyAccessedAPIType</key>
|
||||||
|
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
|
||||||
|
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||||
|
<array>
|
||||||
|
<string>CA92.1</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
<key>NSPrivacyCollectedDataTypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>NSPrivacyCollectedDataType</key>
|
||||||
|
<string>NSPrivacyCollectedDataTypePhotosorVideos</string>
|
||||||
|
<key>NSPrivacyCollectedDataTypeLinked</key>
|
||||||
|
<false/>
|
||||||
|
<key>NSPrivacyCollectedDataTypeTracking</key>
|
||||||
|
<false/>
|
||||||
|
<key>NSPrivacyCollectedDataTypePurposes</key>
|
||||||
|
<array>
|
||||||
|
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
<key>NSPrivacyTracking</key>
|
||||||
|
<false/>
|
||||||
|
<key>NSPrivacyTrackingDomains</key>
|
||||||
|
<array/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
23
Assets.xcassets/ugreen_logo.imageset/Contents.json
vendored
Normal file
23
Assets.xcassets/ugreen_logo.imageset/Contents.json
vendored
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
Assets.xcassets/ugreen_logo.imageset/ugreen_logo 1.png
vendored
Normal file
BIN
Assets.xcassets/ugreen_logo.imageset/ugreen_logo 1.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
BIN
Assets.xcassets/ugreen_logo.imageset/ugreen_logo 2.png
vendored
Normal file
BIN
Assets.xcassets/ugreen_logo.imageset/ugreen_logo 2.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
BIN
Assets.xcassets/ugreen_logo.imageset/ugreen_logo.png
vendored
Normal file
BIN
Assets.xcassets/ugreen_logo.imageset/ugreen_logo.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
BIN
Assets.xcassets/ugreen_logo.png
Normal file
BIN
Assets.xcassets/ugreen_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
@@ -10,6 +10,8 @@ enum NASProtocol: String, Codable, CaseIterable {
|
|||||||
case .sftp: return 22
|
case .sftp: return 22
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var localizedDescription: String { rawValue }
|
||||||
}
|
}
|
||||||
|
|
||||||
struct NASConnection: Codable, Identifiable, Equatable {
|
struct NASConnection: Codable, Identifiable, Equatable {
|
||||||
@@ -18,10 +20,15 @@ struct NASConnection: Codable, Identifiable, Equatable {
|
|||||||
var port: Int
|
var port: Int
|
||||||
var nasProtocol: NASProtocol
|
var nasProtocol: NASProtocol
|
||||||
var username: String
|
var username: String
|
||||||
var password: String
|
var password: String // Runtime only — persisted in Keychain, not UserDefaults
|
||||||
var remotePath: String
|
var remotePath: String
|
||||||
var displayName: 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(
|
init(
|
||||||
host: String,
|
host: String,
|
||||||
port: Int? = nil,
|
port: Int? = nil,
|
||||||
@@ -39,4 +46,16 @@ struct NASConnection: Codable, Identifiable, Equatable {
|
|||||||
self.remotePath = remotePath
|
self.remotePath = remotePath
|
||||||
self.displayName = displayName.isEmpty ? host : displayName
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,43 +8,81 @@ struct BackupView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color.white.ignoresSafeArea()
|
AppTheme.background.ignoresSafeArea()
|
||||||
|
|
||||||
ScrollView {
|
VStack(spacing: 0) {
|
||||||
VStack(spacing: AppTheme.sectionGap) {
|
Spacer(minLength: 0)
|
||||||
ringSection
|
|
||||||
nasRow
|
// ─── Ring hero ───────────────────────────────────────
|
||||||
optionsCard
|
ringHero
|
||||||
actionButton
|
.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)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
|
||||||
|
// ─── NAS status ───────────────────────────────────────
|
||||||
|
nasStatusRow
|
||||||
.padding(.horizontal, AppTheme.hPad)
|
.padding(.horizontal, AppTheme.hPad)
|
||||||
.padding(.top, 24)
|
.padding(.bottom, 16)
|
||||||
.padding(.bottom, 48)
|
|
||||||
|
// ─── 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) }
|
.task { vm.loadPhotoCount(filter: store.backupFilter) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: — Ring
|
// MARK: — Ring hero
|
||||||
|
|
||||||
private var ringSection: some View {
|
private var ringHero: some View {
|
||||||
VStack(spacing: 20) {
|
|
||||||
ZStack {
|
ZStack {
|
||||||
ProgressRing(
|
ProgressRing(
|
||||||
progress: engine.job.progress,
|
progress: engine.job.progress,
|
||||||
lineWidth: 8,
|
lineWidth: 7,
|
||||||
size: 180,
|
size: 220,
|
||||||
color: ringColor,
|
color: ringColor,
|
||||||
backgroundColor: Color(hex: "#EBEBEB")
|
backgroundColor: AppTheme.inkQuaternary.opacity(0.4)
|
||||||
)
|
)
|
||||||
|
.accessibilityLabel("Backup progress")
|
||||||
|
.accessibilityValue("\(Int(engine.job.progress * 100)) percent")
|
||||||
|
|
||||||
VStack(spacing: 3) {
|
VStack(spacing: 4) {
|
||||||
Text(percentText)
|
Text(percentText)
|
||||||
.font(.system(size: 40, weight: .semibold, design: .rounded))
|
.font(.system(size: 44, weight: .semibold, design: .rounded))
|
||||||
.foregroundStyle(AppTheme.ink)
|
.foregroundStyle(AppTheme.ink)
|
||||||
.contentTransition(.numericText())
|
.contentTransition(.numericText())
|
||||||
|
.monospacedDigit()
|
||||||
|
|
||||||
if case .running = engine.job.status {
|
ringSubtitle
|
||||||
|
.transition(.opacity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.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)")
|
Text("\(engine.job.uploadedFiles + engine.job.skippedFiles) of \(engine.job.totalFiles)")
|
||||||
.font(AppTheme.caption())
|
.font(AppTheme.caption())
|
||||||
.foregroundStyle(AppTheme.inkSecondary)
|
.foregroundStyle(AppTheme.inkSecondary)
|
||||||
@@ -53,62 +91,35 @@ struct BackupView: View {
|
|||||||
.font(.system(size: 11, weight: .regular))
|
.font(.system(size: 11, weight: .regular))
|
||||||
.foregroundStyle(AppTheme.inkTertiary)
|
.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)
|
Text(engine.job.currentFileName)
|
||||||
.font(.system(size: 12, weight: .regular))
|
.font(.system(size: 11, weight: .regular))
|
||||||
.foregroundStyle(AppTheme.inkSecondary)
|
.foregroundStyle(AppTheme.inkTertiary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.truncationMode(.middle)
|
.truncationMode(.middle)
|
||||||
Spacer()
|
.frame(maxWidth: 160)
|
||||||
Text(formatSpeed(engine.job.speedBytesPerSecond))
|
}
|
||||||
.font(.system(size: 12, weight: .regular))
|
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)
|
.foregroundStyle(AppTheme.inkTertiary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.transition(.opacity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.animation(.spring(response: 0.4, dampingFraction: 0.8), value: engine.job.status == .completed)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var ringColor: Color {
|
private var ringColor: Color {
|
||||||
switch engine.job.status {
|
switch engine.job.status {
|
||||||
@@ -120,11 +131,32 @@ struct BackupView: View {
|
|||||||
|
|
||||||
private var percentText: String { "\(Int(engine.job.progress * 100))%" }
|
private var percentText: String { "\(Int(engine.job.progress * 100))%" }
|
||||||
|
|
||||||
private func statCell(_ value: String, label: String, color: Color = AppTheme.ink) -> some View {
|
// MARK: — Completion strip
|
||||||
VStack(spacing: 2) {
|
|
||||||
|
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)
|
Text(value)
|
||||||
.font(.system(size: 18, weight: .semibold))
|
.font(.system(size: 17, weight: .semibold))
|
||||||
.foregroundStyle(color)
|
.foregroundStyle(valueColor)
|
||||||
|
.monospacedDigit()
|
||||||
Text(label)
|
Text(label)
|
||||||
.font(AppTheme.micro())
|
.font(AppTheme.micro())
|
||||||
.foregroundStyle(AppTheme.inkTertiary)
|
.foregroundStyle(AppTheme.inkTertiary)
|
||||||
@@ -133,21 +165,21 @@ struct BackupView: View {
|
|||||||
.padding(.vertical, 14)
|
.padding(.vertical, 14)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var dividerLine: some View {
|
private var thinDivider: some View {
|
||||||
Rectangle()
|
Rectangle()
|
||||||
.fill(AppTheme.separator)
|
.fill(AppTheme.separator)
|
||||||
.frame(width: 0.5)
|
.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) {
|
HStack(spacing: 10) {
|
||||||
ZStack {
|
ZStack {
|
||||||
Circle()
|
Circle()
|
||||||
.fill(AppTheme.surfaceSunken)
|
.fill(AppTheme.surfaceSunken)
|
||||||
.frame(width: 32, height: 32)
|
.frame(width: 34, height: 34)
|
||||||
Image(systemName: "externaldrive")
|
Image(systemName: "externaldrive")
|
||||||
.font(.system(size: 13, weight: .medium))
|
.font(.system(size: 13, weight: .medium))
|
||||||
.foregroundStyle(AppTheme.inkSecondary)
|
.foregroundStyle(AppTheme.inkSecondary)
|
||||||
@@ -158,98 +190,54 @@ struct BackupView: View {
|
|||||||
Text(conn.host)
|
Text(conn.host)
|
||||||
.font(AppTheme.headline())
|
.font(AppTheme.headline())
|
||||||
.foregroundStyle(AppTheme.ink)
|
.foregroundStyle(AppTheme.ink)
|
||||||
Text(conn.username)
|
Text(conn.nasProtocol.rawValue)
|
||||||
.font(AppTheme.caption())
|
.font(AppTheme.micro(10))
|
||||||
.foregroundStyle(AppTheme.inkSecondary)
|
.foregroundStyle(AppTheme.inkTertiary)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
Text("No NAS configured")
|
||||||
|
.font(AppTheme.body())
|
||||||
|
.foregroundStyle(AppTheme.inkTertiary)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
HStack(spacing: 5) {
|
HStack(spacing: 5) {
|
||||||
Circle()
|
Circle()
|
||||||
.fill(AppTheme.positive)
|
.fill(engine.job.status.isActive ? AppTheme.positive : AppTheme.inkQuaternary)
|
||||||
.frame(width: 6, height: 6)
|
.frame(width: 6, height: 6)
|
||||||
Text("Live")
|
Text(engine.job.status.isActive ? "Active" : "Ready")
|
||||||
.font(AppTheme.micro())
|
.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)
|
.background(AppTheme.surfaceSunken)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
|
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: — Options
|
// MARK: — Photos access nudge
|
||||||
|
|
||||||
private var optionsCard: some View {
|
private var photosAccessRow: 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() } }) {
|
Button(action: { Task { await vm.requestPhotosAccess() } }) {
|
||||||
HStack {
|
HStack(spacing: 10) {
|
||||||
Image(systemName: "photo.on.rectangle")
|
Image(systemName: "photo.on.rectangle")
|
||||||
.font(.system(size: 14, weight: .regular))
|
.font(.system(size: 13, weight: .medium))
|
||||||
.foregroundStyle(vm.photosAuthStatus == .authorized
|
.foregroundStyle(AppTheme.destructive)
|
||||||
? AppTheme.inkSecondary : AppTheme.destructive)
|
|
||||||
.frame(width: 20)
|
.frame(width: 20)
|
||||||
Text("Photos access")
|
Text("Grant photo library access to enable backup")
|
||||||
.font(AppTheme.body())
|
|
||||||
.foregroundStyle(AppTheme.ink)
|
|
||||||
Spacer()
|
|
||||||
Text(photosLabel)
|
|
||||||
.font(AppTheme.caption())
|
.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)
|
.foregroundStyle(AppTheme.inkSecondary)
|
||||||
.frame(width: 20)
|
|
||||||
Text(label)
|
|
||||||
.font(AppTheme.body())
|
|
||||||
.foregroundStyle(AppTheme.ink)
|
|
||||||
Spacer()
|
Spacer()
|
||||||
Image(systemName: "chevron.right")
|
Image(systemName: "chevron.right")
|
||||||
.font(.system(size: 12, weight: .medium))
|
.font(.system(size: 11, weight: .medium))
|
||||||
.foregroundStyle(AppTheme.inkQuaternary)
|
.foregroundStyle(AppTheme.inkQuaternary)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 14)
|
||||||
.padding(.vertical, 14)
|
.padding(.vertical, 11)
|
||||||
|
.background(AppTheme.destructive.opacity(0.06))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||||
}
|
}
|
||||||
.buttonStyle(ScaleButtonStyle())
|
.buttonStyle(ScaleButtonStyle())
|
||||||
}
|
}
|
||||||
@@ -266,24 +254,27 @@ struct BackupView: View {
|
|||||||
.buttonStyle(PrimaryButtonStyle(
|
.buttonStyle(PrimaryButtonStyle(
|
||||||
color: engine.job.status == .completed ? AppTheme.positive : AppTheme.ink
|
color: engine.job.status == .completed ? AppTheme.positive : AppTheme.ink
|
||||||
))
|
))
|
||||||
|
.accessibilityHint("Starts backing up your photos to the NAS")
|
||||||
|
|
||||||
case .running:
|
case .running:
|
||||||
Button { engine.pause() } label: { Text("Pause") }
|
Button { engine.pause() } label: { Text("Pause") }
|
||||||
.buttonStyle(GhostButtonStyle())
|
.buttonStyle(GhostButtonStyle())
|
||||||
|
.accessibilityLabel("Pause backup")
|
||||||
|
|
||||||
case .paused:
|
case .paused:
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
Button { engine.resume() } label: { Text("Resume") }
|
Button { engine.resume() } label: { Text("Resume") }
|
||||||
.buttonStyle(PrimaryButtonStyle())
|
.buttonStyle(PrimaryButtonStyle())
|
||||||
|
.accessibilityLabel("Resume backup")
|
||||||
Button { engine.cancel() } label: { Text("Cancel") }
|
Button { engine.cancel() } label: { Text("Cancel") }
|
||||||
.buttonStyle(GhostButtonStyle())
|
.buttonStyle(GhostButtonStyle())
|
||||||
.frame(width: 90)
|
.frame(width: 90)
|
||||||
|
.accessibilityLabel("Cancel backup")
|
||||||
}
|
}
|
||||||
|
|
||||||
case .preparing:
|
case .preparing:
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
ProgressView()
|
ProgressView().scaleEffect(0.8).tint(AppTheme.inkSecondary)
|
||||||
.scaleEffect(0.8)
|
|
||||||
Text("Preparing…")
|
Text("Preparing…")
|
||||||
.font(AppTheme.body())
|
.font(AppTheme.body())
|
||||||
.foregroundStyle(AppTheme.inkSecondary)
|
.foregroundStyle(AppTheme.inkSecondary)
|
||||||
@@ -291,7 +282,7 @@ struct BackupView: View {
|
|||||||
.frame(height: 50)
|
.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() {
|
private func startBackup() {
|
||||||
@@ -299,12 +290,6 @@ struct BackupView: View {
|
|||||||
Task { _ = try? await engine.run(connection: conn, filter: store.backupFilter) }
|
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 {
|
private func etaString(_ seconds: TimeInterval) -> String {
|
||||||
Int(seconds) / 60 > 0 ? "\(Int(seconds) / 60)m" : "\(Int(seconds))s"
|
Int(seconds) / 60 > 0 ? "\(Int(seconds) / 60)m" : "\(Int(seconds))s"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ struct BrowseView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color.white.ignoresSafeArea()
|
AppTheme.background.ignoresSafeArea()
|
||||||
|
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// Path bar
|
// Path bar
|
||||||
@@ -40,21 +40,31 @@ struct BrowseView: View {
|
|||||||
|
|
||||||
if isLoading {
|
if isLoading {
|
||||||
Spacer()
|
Spacer()
|
||||||
|
VStack(spacing: 10) {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.tint(AppTheme.inkTertiary)
|
.tint(AppTheme.inkTertiary)
|
||||||
|
Text("Loading…")
|
||||||
|
.font(AppTheme.caption())
|
||||||
|
.foregroundStyle(AppTheme.inkTertiary)
|
||||||
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
} else if let error {
|
} else if let error {
|
||||||
Spacer()
|
Spacer()
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
Image(systemName: "exclamationmark.triangle")
|
Image(systemName: "exclamationmark.triangle")
|
||||||
.font(.system(size: 28, weight: .light))
|
.font(.system(size: 28, weight: .ultraLight))
|
||||||
.foregroundStyle(AppTheme.inkQuaternary)
|
.foregroundStyle(AppTheme.inkQuaternary)
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Text("Could not load folder")
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundStyle(AppTheme.inkSecondary)
|
||||||
Text(error)
|
Text(error)
|
||||||
.font(AppTheme.caption())
|
.font(AppTheme.caption())
|
||||||
.foregroundStyle(AppTheme.inkSecondary)
|
.foregroundStyle(AppTheme.inkTertiary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
}
|
}
|
||||||
.padding()
|
}
|
||||||
|
.padding(.horizontal, 32)
|
||||||
Spacer()
|
Spacer()
|
||||||
} else {
|
} else {
|
||||||
List {
|
List {
|
||||||
|
|||||||
@@ -9,30 +9,33 @@ struct ConnectView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ZStack {
|
ZStack {
|
||||||
AppTheme.surfaceSunken.ignoresSafeArea()
|
AppTheme.background.ignoresSafeArea()
|
||||||
|
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
|
|
||||||
// Logo + title
|
// Header
|
||||||
VStack(spacing: 10) {
|
VStack(spacing: 20) {
|
||||||
|
// App icon
|
||||||
Image("nas_logo_clean")
|
Image("nas_logo_clean")
|
||||||
.resizable()
|
.resizable()
|
||||||
.scaledToFit()
|
.scaledToFit()
|
||||||
.frame(width: 44, height: 44)
|
.frame(width: 52, height: 52)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 11, style: .continuous))
|
.clipShape(RoundedRectangle(cornerRadius: 13, style: .continuous))
|
||||||
|
.shadow(color: .black.opacity(0.08), radius: 14, x: 0, y: 4)
|
||||||
.padding(.top, 44)
|
.padding(.top, 44)
|
||||||
|
|
||||||
Text("NAS Connect")
|
// Title
|
||||||
|
VStack(spacing: 6) {
|
||||||
|
Text("Kisani")
|
||||||
.font(.system(size: 26, weight: .semibold))
|
.font(.system(size: 26, weight: .semibold))
|
||||||
.foregroundStyle(AppTheme.ink)
|
.foregroundStyle(AppTheme.ink)
|
||||||
|
|
||||||
Text("Connect to your UGreen or any SMB / SFTP NAS")
|
// UGreen NAS compatibility badge
|
||||||
.font(AppTheme.caption())
|
UGreenCompatibilityBadge()
|
||||||
.foregroundStyle(AppTheme.inkTertiary)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
}
|
}
|
||||||
.padding(.bottom, 32)
|
}
|
||||||
|
.padding(.bottom, 28)
|
||||||
.padding(.horizontal, AppTheme.hPad)
|
.padding(.horizontal, AppTheme.hPad)
|
||||||
|
|
||||||
// Protocol picker
|
// Protocol picker
|
||||||
@@ -50,7 +53,7 @@ struct ConnectView: View {
|
|||||||
.padding(.vertical, 7)
|
.padding(.vertical, 7)
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: 7, style: .continuous)
|
RoundedRectangle(cornerRadius: 7, style: .continuous)
|
||||||
.fill(vm.selectedProtocol == proto ? Color.white : Color.clear)
|
.fill(vm.selectedProtocol == proto ? AppTheme.surfaceRaised : Color.clear)
|
||||||
.shadow(
|
.shadow(
|
||||||
color: vm.selectedProtocol == proto ? .black.opacity(0.06) : .clear,
|
color: vm.selectedProtocol == proto ? .black.opacity(0.06) : .clear,
|
||||||
radius: 3, x: 0, y: 1
|
radius: 3, x: 0, y: 1
|
||||||
@@ -61,7 +64,7 @@ struct ConnectView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(3)
|
.padding(3)
|
||||||
.background(Color.black.opacity(0.06))
|
.background(AppTheme.surfaceSunken)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||||
.padding(.horizontal, AppTheme.hPad)
|
.padding(.horizontal, AppTheme.hPad)
|
||||||
.padding(.bottom, 20)
|
.padding(.bottom, 20)
|
||||||
@@ -90,18 +93,13 @@ struct ConnectView: View {
|
|||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.vertical, 14)
|
.padding(.vertical, 14)
|
||||||
.background(Color.white)
|
.background(AppTheme.surfaceRaised)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
|
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
|
||||||
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
|
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
|
||||||
|
|
||||||
HStack(spacing: 20) {
|
Text("e.g. 192.168.1.10 or MY_NAS")
|
||||||
Text("IP Address, ex: 192.168.1.10")
|
|
||||||
.font(AppTheme.micro(11))
|
.font(AppTheme.micro(11))
|
||||||
.foregroundStyle(AppTheme.inkTertiary)
|
.foregroundStyle(AppTheme.inkTertiary)
|
||||||
Text("NAS Name, ex: MY_NAS")
|
|
||||||
.font(AppTheme.micro(11))
|
|
||||||
.foregroundStyle(AppTheme.inkTertiary)
|
|
||||||
}
|
|
||||||
.padding(.leading, 4)
|
.padding(.leading, 4)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, AppTheme.hPad)
|
.padding(.horizontal, AppTheme.hPad)
|
||||||
@@ -163,7 +161,7 @@ struct ConnectView: View {
|
|||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.vertical, 14)
|
.padding(.vertical, 14)
|
||||||
}
|
}
|
||||||
.background(Color.white)
|
.background(AppTheme.surfaceRaised)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
|
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
|
||||||
.overlay(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)
|
RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)
|
||||||
@@ -199,7 +197,7 @@ struct ConnectView: View {
|
|||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.vertical, 14)
|
.padding(.vertical, 14)
|
||||||
.background(Color.white)
|
.background(AppTheme.surfaceRaised)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
|
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
|
||||||
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
|
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
|
||||||
}
|
}
|
||||||
@@ -255,25 +253,11 @@ struct ConnectView: View {
|
|||||||
.disabled(!vm.canConnect)
|
.disabled(!vm.canConnect)
|
||||||
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: vm.isConnected)
|
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: vm.isConnected)
|
||||||
|
|
||||||
// Help
|
Spacer().frame(height: 48)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("NAS Connect")
|
.navigationBarHidden(true)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
.navigationDestination(isPresented: $navigateToDashboard) { MainTabView() }
|
.navigationDestination(isPresented: $navigateToDashboard) { MainTabView() }
|
||||||
.sheet(isPresented: $vm.showFolderBrowser) {
|
.sheet(isPresented: $vm.showFolderBrowser) {
|
||||||
if let conn = ConnectionStore.shared.savedConnection {
|
if let conn = ConnectionStore.shared.savedConnection {
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ final class ConnectViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func verify() async {
|
func verify() async {
|
||||||
|
let service: any NASTransferProtocol = selectedProtocol == .smb ? SMBService() : SFTPService()
|
||||||
|
await verifyWith(transfer: service)
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyWith(transfer: any NASTransferProtocol) async {
|
||||||
isVerifying = true
|
isVerifying = true
|
||||||
isConnected = false
|
isConnected = false
|
||||||
verifyError = nil
|
verifyError = nil
|
||||||
@@ -36,11 +41,10 @@ final class ConnectViewModel: ObservableObject {
|
|||||||
remotePath: remotePath
|
remotePath: remotePath
|
||||||
)
|
)
|
||||||
|
|
||||||
let service: any NASTransferProtocol = selectedProtocol == .smb ? SMBService() : SFTPService()
|
|
||||||
do {
|
do {
|
||||||
try await service.connect(to: connection.host, port: connection.port,
|
try await transfer.connect(to: connection.host, port: connection.port,
|
||||||
username: connection.username, password: connection.password)
|
username: connection.username, password: connection.password)
|
||||||
service.disconnect()
|
transfer.disconnect()
|
||||||
isConnected = true
|
isConnected = true
|
||||||
ConnectionStore.shared.savedConnection = connection
|
ConnectionStore.shared.savedConnection = connection
|
||||||
} catch let e as BackupError {
|
} catch let e as BackupError {
|
||||||
|
|||||||
@@ -5,29 +5,22 @@ struct HistoryView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color.white.ignoresSafeArea()
|
AppTheme.background.ignoresSafeArea()
|
||||||
|
|
||||||
if store.historyEntries.isEmpty {
|
if store.historyEntries.isEmpty {
|
||||||
VStack(spacing: 12) {
|
emptyState
|
||||||
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)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
List {
|
List {
|
||||||
ForEach(store.historyEntries) { entry in
|
ForEach(store.historyEntries) { entry in
|
||||||
HistoryRowView(entry: entry)
|
HistoryRowView(entry: entry)
|
||||||
.listRowSeparator(.hidden)
|
.listRowSeparator(.hidden)
|
||||||
.listRowInsets(EdgeInsets())
|
.listRowInsets(EdgeInsets())
|
||||||
|
.listRowBackground(AppTheme.background)
|
||||||
}
|
}
|
||||||
.onDelete { offsets in
|
.onDelete { store.removeHistoryEntries(at: $0) }
|
||||||
store.removeHistoryEntries(at: offsets)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.listStyle(.plain)
|
.listStyle(.plain)
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("History")
|
.navigationTitle("History")
|
||||||
@@ -36,32 +29,60 @@ struct HistoryView: View {
|
|||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
Button("Clear") { store.clearHistory() }
|
Button("Clear") { store.clearHistory() }
|
||||||
.font(AppTheme.caption())
|
.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 {
|
struct HistoryRowView: View {
|
||||||
let entry: BackupHistoryEntry
|
let entry: BackupHistoryEntry
|
||||||
|
|
||||||
private var hasErrors: Bool { entry.failedCount > 0 }
|
private var hasErrors: Bool { entry.failedCount > 0 }
|
||||||
|
private var statusColor: Color { hasErrors ? AppTheme.destructive : AppTheme.positive }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
HStack(alignment: .top, spacing: 14) {
|
HStack(alignment: .top, spacing: 14) {
|
||||||
|
// Status indicator
|
||||||
Circle()
|
Circle()
|
||||||
.fill(hasErrors ? AppTheme.destructive : AppTheme.positive)
|
.fill(statusColor)
|
||||||
.frame(width: 7, height: 7)
|
.frame(width: 6, height: 6)
|
||||||
.padding(.top, 5)
|
.padding(.top, 6)
|
||||||
|
|
||||||
|
// Content
|
||||||
VStack(alignment: .leading, spacing: 5) {
|
VStack(alignment: .leading, spacing: 5) {
|
||||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
// Date + badges
|
||||||
Text(entry.date, style: .date)
|
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||||
.font(.system(size: 15, weight: .medium))
|
Text(entry.date, format: .dateTime.month(.abbreviated).day().year())
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
.foregroundStyle(AppTheme.ink)
|
.foregroundStyle(AppTheme.ink)
|
||||||
|
|
||||||
|
Text(entry.date, format: .dateTime.hour().minute())
|
||||||
|
.font(.system(size: 13, weight: .regular))
|
||||||
|
.foregroundStyle(AppTheme.inkTertiary)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
if entry.triggeredByLAN {
|
if entry.triggeredByLAN {
|
||||||
Text("AUTO")
|
Text("AUTO")
|
||||||
.font(.system(size: 9, weight: .semibold))
|
.font(.system(size: 9, weight: .semibold))
|
||||||
@@ -73,37 +94,25 @@ struct HistoryRowView: View {
|
|||||||
.stroke(AppTheme.inkQuaternary, lineWidth: 0.75)
|
.stroke(AppTheme.inkQuaternary, lineWidth: 0.75)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Text(entry.date, style: .time)
|
|
||||||
.font(AppTheme.micro(11))
|
|
||||||
.foregroundStyle(AppTheme.inkTertiary)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
HStack(spacing: 8) {
|
// Stat line
|
||||||
Text("\(entry.uploadedCount) uploaded")
|
statLine
|
||||||
.font(AppTheme.caption())
|
|
||||||
.foregroundStyle(AppTheme.inkSecondary)
|
|
||||||
|
|
||||||
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)
|
Text(entry.nasHost)
|
||||||
.font(AppTheme.micro(11))
|
|
||||||
.foregroundStyle(AppTheme.inkQuaternary)
|
.foregroundStyle(AppTheme.inkQuaternary)
|
||||||
|
midDot
|
||||||
|
Text(formatDuration(entry.durationSeconds))
|
||||||
|
.foregroundStyle(AppTheme.inkQuaternary)
|
||||||
|
if entry.totalBytes > 0 {
|
||||||
|
midDot
|
||||||
|
Text(formatBytes(entry.totalBytes))
|
||||||
|
.foregroundStyle(AppTheme.inkQuaternary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.system(size: 11, weight: .regular))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
@@ -112,13 +121,48 @@ struct HistoryRowView: View {
|
|||||||
Rectangle()
|
Rectangle()
|
||||||
.fill(AppTheme.separator)
|
.fill(AppTheme.separator)
|
||||||
.frame(height: 0.5)
|
.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()
|
Circle()
|
||||||
.fill(AppTheme.inkQuaternary)
|
.fill(AppTheme.inkQuaternary)
|
||||||
.frame(width: 3, height: 3)
|
.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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct LoginView: View {
|
struct LoginView: View {
|
||||||
|
@EnvironmentObject var store: ConnectionStore
|
||||||
@StateObject private var vm = LoginViewModel()
|
@StateObject private var vm = LoginViewModel()
|
||||||
@State private var navigateToDashboard = false
|
|
||||||
@State private var logoAppeared = false
|
@State private var logoAppeared = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
|
||||||
ZStack {
|
ZStack {
|
||||||
Color.white.ignoresSafeArea()
|
AppTheme.background.ignoresSafeArea()
|
||||||
|
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -26,25 +25,14 @@ struct LoginView: View {
|
|||||||
|
|
||||||
Spacer().frame(height: 32)
|
Spacer().frame(height: 32)
|
||||||
|
|
||||||
// Device + account
|
// Title + account
|
||||||
VStack(spacing: 6) {
|
VStack(spacing: 5) {
|
||||||
Text("Forge")
|
Text("Kisani")
|
||||||
.font(.system(size: 22, weight: .semibold))
|
.font(.system(size: 22, weight: .semibold))
|
||||||
.foregroundStyle(AppTheme.ink)
|
.foregroundStyle(AppTheme.ink)
|
||||||
|
Text("Sign in to continue")
|
||||||
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())
|
.font(AppTheme.caption())
|
||||||
.foregroundStyle(AppTheme.inkSecondary)
|
.foregroundStyle(AppTheme.inkTertiary)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.opacity(logoAppeared ? 1 : 0)
|
.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.2), value: logoAppeared)
|
||||||
@@ -56,7 +44,7 @@ struct LoginView: View {
|
|||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
if vm.isAuthenticating {
|
if vm.isAuthenticating {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.tint(.white)
|
.tint(AppTheme.inkInverse)
|
||||||
.scaleEffect(0.8)
|
.scaleEffect(0.8)
|
||||||
} else {
|
} else {
|
||||||
Image(systemName: "faceid")
|
Image(systemName: "faceid")
|
||||||
@@ -90,21 +78,19 @@ struct LoginView: View {
|
|||||||
.animation(.spring(response: 0.5, dampingFraction: 0.7).delay(0.4), value: logoAppeared)
|
.animation(.spring(response: 0.5, dampingFraction: 0.7).delay(0.4), value: logoAppeared)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationDestination(isPresented: $navigateToDashboard) { MainTabView() }
|
|
||||||
.sheet(isPresented: $vm.showMoreSheet) {
|
.sheet(isPresented: $vm.showMoreSheet) {
|
||||||
MoreOptionsSheet(isPresented: $vm.showMoreSheet) {
|
MoreOptionsSheet(isPresented: $vm.showMoreSheet) {
|
||||||
vm.showMoreSheet = false
|
vm.showMoreSheet = false
|
||||||
navigateToDashboard = true
|
store.isSessionActive = true
|
||||||
}
|
}
|
||||||
.presentationDetents([.height(200)])
|
.presentationDetents([.height(200)])
|
||||||
.modifier(SheetCornerRadius(20))
|
.modifier(SheetCornerRadius(20))
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
vm.onAuthenticated = { navigateToDashboard = true }
|
vm.onAuthenticated = { store.isSessionActive = true }
|
||||||
logoAppeared = true
|
logoAppeared = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct MoreOptionsSheet: View {
|
struct MoreOptionsSheet: View {
|
||||||
@@ -138,7 +124,7 @@ struct MoreOptionsSheet: View {
|
|||||||
.padding(.horizontal, AppTheme.hPad)
|
.padding(.horizontal, AppTheme.hPad)
|
||||||
.padding(.bottom, 16)
|
.padding(.bottom, 16)
|
||||||
}
|
}
|
||||||
.background(Color.white)
|
.background(AppTheme.background)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func sheetRow(
|
private func sheetRow(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import LocalAuthentication
|
import LocalAuthentication
|
||||||
|
import os.log
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class LoginViewModel: ObservableObject {
|
final class LoginViewModel: ObservableObject {
|
||||||
@@ -9,26 +10,34 @@ final class LoginViewModel: ObservableObject {
|
|||||||
|
|
||||||
var onAuthenticated: (() -> Void)?
|
var onAuthenticated: (() -> Void)?
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "com.albert.nasbackup", category: "Login")
|
||||||
|
|
||||||
func authenticateWithFaceID() {
|
func authenticateWithFaceID() {
|
||||||
|
logger.info("Face ID authentication started")
|
||||||
isAuthenticating = true
|
isAuthenticating = true
|
||||||
authError = nil
|
authError = nil
|
||||||
let context = LAContext()
|
let context = LAContext()
|
||||||
var error: NSError?
|
var error: NSError?
|
||||||
guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
|
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"
|
authError = "Face ID not available"
|
||||||
isAuthenticating = false
|
isAuthenticating = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
context.evaluatePolicy(
|
context.evaluatePolicy(
|
||||||
.deviceOwnerAuthenticationWithBiometrics,
|
.deviceOwnerAuthenticationWithBiometrics,
|
||||||
localizedReason: "Sign in to NASBackup"
|
localizedReason: "Sign in to Kisani"
|
||||||
) { [weak self] success, evalError in
|
) { [weak self] success, evalError in
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self?.isAuthenticating = false
|
self?.isAuthenticating = false
|
||||||
if success {
|
if success {
|
||||||
|
self?.logger.info("Face ID authentication succeeded — advancing session")
|
||||||
self?.onAuthenticated?()
|
self?.onAuthenticated?()
|
||||||
} else {
|
} 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
151
Features/Onboarding/FolderSetupView.swift
Normal file
151
Features/Onboarding/FolderSetupView.swift
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
129
Features/Onboarding/PhotoPermissionView.swift
Normal file
129
Features/Onboarding/PhotoPermissionView.swift
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -131,7 +131,7 @@ struct LANTriggerSection: View {
|
|||||||
.padding(.horizontal, AppTheme.cardPad)
|
.padding(.horizontal, AppTheme.cardPad)
|
||||||
.padding(.vertical, 12)
|
.padding(.vertical, 12)
|
||||||
}
|
}
|
||||||
.background(Color.white)
|
.background(AppTheme.surfaceRaised)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
|
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
|
||||||
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
|
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ struct SettingsView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color.white.ignoresSafeArea()
|
AppTheme.background.ignoresSafeArea()
|
||||||
|
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: AppTheme.sectionGap) {
|
VStack(spacing: AppTheme.sectionGap) {
|
||||||
|
appearanceSection
|
||||||
LANTriggerSection()
|
LANTriggerSection()
|
||||||
mediaSection
|
mediaSection
|
||||||
quietHoursSection
|
quietHoursSection
|
||||||
@@ -26,6 +27,45 @@ struct SettingsView: View {
|
|||||||
.navigationBarTitleDisplayMode(.inline)
|
.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
|
// MARK: — Media
|
||||||
|
|
||||||
private var mediaSection: some View {
|
private var mediaSection: some View {
|
||||||
@@ -49,7 +89,7 @@ struct SettingsView: View {
|
|||||||
subtitle: "Skip files already on NAS",
|
subtitle: "Skip files already on NAS",
|
||||||
isOn: $store.backupFilter.newFilesOnly)
|
isOn: $store.backupFilter.newFilesOnly)
|
||||||
}
|
}
|
||||||
.background(Color.white)
|
.background(AppTheme.surfaceRaised)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
|
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
|
||||||
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
|
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
|
||||||
}
|
}
|
||||||
@@ -78,7 +118,7 @@ struct SettingsView: View {
|
|||||||
subtitle: "Preserve battery on large libraries",
|
subtitle: "Preserve battery on large libraries",
|
||||||
isOn: $store.chargingOnlyMode)
|
isOn: $store.chargingOnlyMode)
|
||||||
}
|
}
|
||||||
.background(Color.white)
|
.background(AppTheme.surfaceRaised)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
|
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
|
||||||
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
|
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
|
||||||
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: store.quietHoursEnabled)
|
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: store.quietHoursEnabled)
|
||||||
@@ -135,7 +175,7 @@ struct SettingsView: View {
|
|||||||
.padding(.vertical, 10)
|
.padding(.vertical, 10)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(Color.white)
|
.background(AppTheme.surfaceRaised)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
|
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
|
||||||
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
|
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
|
||||||
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: lanMonitor.isOnCellular)
|
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: lanMonitor.isOnCellular)
|
||||||
@@ -206,13 +246,15 @@ struct SettingsView: View {
|
|||||||
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
|
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
|
||||||
}
|
}
|
||||||
.buttonStyle(ScaleButtonStyle())
|
.buttonStyle(ScaleButtonStyle())
|
||||||
|
.accessibilityLabel("Open Tailscale app")
|
||||||
|
.accessibilityHint("Opens the Tailscale app to connect the VPN")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, AppTheme.cardPad)
|
.padding(.horizontal, AppTheme.cardPad)
|
||||||
.padding(.vertical, 12)
|
.padding(.vertical, 12)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(Color.white)
|
.background(AppTheme.surfaceRaised)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
|
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
|
||||||
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
|
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
|
||||||
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: store.useTailscaleWhenRemote)
|
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: store.useTailscaleWhenRemote)
|
||||||
@@ -233,7 +275,7 @@ struct SettingsView: View {
|
|||||||
hairline
|
hairline
|
||||||
notifRow(icon: "exclamationmark.triangle", title: "On errors", value: "On")
|
notifRow(icon: "exclamationmark.triangle", title: "On errors", value: "On")
|
||||||
}
|
}
|
||||||
.background(Color.white)
|
.background(AppTheme.surfaceRaised)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
|
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
|
||||||
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
|
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,26 +7,37 @@
|
|||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* 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 */; };
|
36FE83FFC24922EBC28287BB /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88E66C22B614E89A7A9E9B35 /* AppDelegate.swift */; };
|
||||||
37D808079BDEF32D04847D04 /* MainTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F09160B7B7A06BD900D0D87A /* MainTabView.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 */; };
|
45FFF2DCFE454EFDF642CEB1 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8F24C7DD7A503A0D561F3FB /* LoginView.swift */; };
|
||||||
512B235FB16DEFBBD9EA4E1C /* NASConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BE4774FFA1074FF5F1FAB2B /* NASConnection.swift */; };
|
512B235FB16DEFBBD9EA4E1C /* NASConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BE4774FFA1074FF5F1FAB2B /* NASConnection.swift */; };
|
||||||
57656BC4B5A2E81FE7BF37FD /* PhotoLibraryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46B47E3C76EA718ECF97BBAD /* PhotoLibraryService.swift */; };
|
57656BC4B5A2E81FE7BF37FD /* PhotoLibraryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46B47E3C76EA718ECF97BBAD /* PhotoLibraryService.swift */; };
|
||||||
6500E80B50EEF877C5127A2A /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EEBCD4A838173E557DC3EFC /* HistoryView.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 */; };
|
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 */; };
|
7872072DD0E0B8D51C1A12DF /* NASBackupApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B36E23852EF12DC14A2DF2DC /* NASBackupApp.swift */; };
|
||||||
7DF923F172E6DFE3DD110DC9 /* LANTriggerSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CBE4F65BE653C7C72E096D1 /* LANTriggerSection.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 */; };
|
834A766E13EF297273FFEE21 /* LoginViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A42E8CFCAECFDBF6EDDAC0 /* LoginViewModel.swift */; };
|
||||||
84E3EE64F74D00104BF8694B /* BackgroundTaskManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5643379BBA1FD39841F9EA55 /* BackgroundTaskManager.swift */; };
|
84E3EE64F74D00104BF8694B /* BackgroundTaskManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5643379BBA1FD39841F9EA55 /* BackgroundTaskManager.swift */; };
|
||||||
8657A3E4C5E377FE34840E9A /* BackupEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 824FADB40473BFC36FE91F0A /* BackupEngine.swift */; };
|
8657A3E4C5E377FE34840E9A /* BackupEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 824FADB40473BFC36FE91F0A /* BackupEngine.swift */; };
|
||||||
88879238876B1E5A1BE15DF3 /* SMBClient in Frameworks */ = {isa = PBXBuildFile; productRef = 9CEDCE7A5B4B267C66F39ABE /* SMBClient */; };
|
88879238876B1E5A1BE15DF3 /* SMBClient in Frameworks */ = {isa = PBXBuildFile; productRef = 9CEDCE7A5B4B267C66F39ABE /* SMBClient */; };
|
||||||
8C1C09CA16A5D71408468BCB /* NASTransferProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 055D92AB46FCDDF9C34B9D62 /* NASTransferProtocol.swift */; };
|
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 */; };
|
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 */; };
|
9758372DD65F5353C72DE5C5 /* SMBService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEBD92D10D70B957192E271F /* SMBService.swift */; };
|
||||||
9A2F717F6A8F4AA85FA636FC /* Citadel in Frameworks */ = {isa = PBXBuildFile; productRef = 7BEB5BEBEB935BC8E2182A7B /* Citadel */; };
|
9A2F717F6A8F4AA85FA636FC /* Citadel in Frameworks */ = {isa = PBXBuildFile; productRef = 7BEB5BEBEB935BC8E2182A7B /* Citadel */; };
|
||||||
9CF57AC05F30A51B6B5269DC /* AppTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B33F755DA5850D1FE3F695B /* AppTheme.swift */; };
|
9CF57AC05F30A51B6B5269DC /* AppTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B33F755DA5850D1FE3F695B /* AppTheme.swift */; };
|
||||||
A6276840D40211EB446293A9 /* BackupViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC73AD75762D4357D171CDA4 /* BackupViewModel.swift */; };
|
A6276840D40211EB446293A9 /* BackupViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC73AD75762D4357D171CDA4 /* BackupViewModel.swift */; };
|
||||||
A6D143C05EE16594E0D1AA01 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0AABE0AA63B1ACA1DEABB65 /* SceneDelegate.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 */; };
|
B08D787E99C3B2E193A0D694 /* ToggleRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8845003C87961174302AC990 /* ToggleRow.swift */; };
|
||||||
B50445A9010E20F97E9E6E48 /* ProgressRing.swift in Sources */ = {isa = PBXBuildFile; fileRef = C955FEF6710E3667C7A73A02 /* ProgressRing.swift */; };
|
B50445A9010E20F97E9E6E48 /* ProgressRing.swift in Sources */ = {isa = PBXBuildFile; fileRef = C955FEF6710E3667C7A73A02 /* ProgressRing.swift */; };
|
||||||
B7DBF1420838AD3397A280B5 /* BackupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55EA454B783C2AEDABFB00A7 /* BackupView.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 */; };
|
C6AAC362F058E7836A91AC30 /* ConnectViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91197DD51221A3277AE318E2 /* ConnectViewModel.swift */; };
|
||||||
C8E9BC6070E23992EED8D013 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97D17A73A05A6FDF2B89C6B2 /* SettingsView.swift */; };
|
C8E9BC6070E23992EED8D013 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97D17A73A05A6FDF2B89C6B2 /* SettingsView.swift */; };
|
||||||
CE47E9F2179F86BE0A630D91 /* LANMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5606A4C5894CFC72D555CAE1 /* LANMonitor.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 */; };
|
DCE9AF4F0160E323224E3553 /* PhotoLibraryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74E2AF8B56C469E0D94EFE94 /* PhotoLibraryProtocol.swift */; };
|
||||||
DF719EAD5727B335001DB50A /* ConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE318B60576B11E80862664D /* ConnectView.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 */; };
|
E830DB3078A8873382A77B63 /* BackupResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AEFE4959C08B94AE9584A11 /* BackupResult.swift */; };
|
||||||
ED0D2A1D05B5898BCCE9EA0A /* ButtonStyles.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA0969FC7D7E0EE313E29D55 /* ButtonStyles.swift */; };
|
ED0D2A1D05B5898BCCE9EA0A /* ButtonStyles.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA0969FC7D7E0EE313E29D55 /* ButtonStyles.swift */; };
|
||||||
F9219423E73718D8AB604EB1 /* ConnectionStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D87319355D902007618A91AE /* ConnectionStore.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 */; };
|
FDC18499CC21A58C2F6F1BDB /* BackupJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47E762898E351192399FC739 /* BackupJob.swift */; };
|
||||||
/* End PBXBuildFile section */
|
/* 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 */
|
/* Begin PBXFileReference section */
|
||||||
055D92AB46FCDDF9C34B9D62 /* NASTransferProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NASTransferProtocol.swift; sourceTree = "<group>"; };
|
055D92AB46FCDDF9C34B9D62 /* NASTransferProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NASTransferProtocol.swift; sourceTree = "<group>"; };
|
||||||
|
0C0E3EBE5A0DBD9D64196501 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
||||||
|
277DF2E5F4E566C869EC29F3 /* URL+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+.swift"; sourceTree = "<group>"; };
|
||||||
2B33F755DA5850D1FE3F695B /* AppTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTheme.swift; sourceTree = "<group>"; };
|
2B33F755DA5850D1FE3F695B /* AppTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTheme.swift; sourceTree = "<group>"; };
|
||||||
2BE4774FFA1074FF5F1FAB2B /* NASConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NASConnection.swift; sourceTree = "<group>"; };
|
2BE4774FFA1074FF5F1FAB2B /* NASConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NASConnection.swift; sourceTree = "<group>"; };
|
||||||
|
336D155A4D1C895215A4FFF2 /* BackupEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupEngineTests.swift; sourceTree = "<group>"; };
|
||||||
|
3C31F3F5EBCB51002188FA15 /* FolderSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderSetupView.swift; sourceTree = "<group>"; };
|
||||||
46B47E3C76EA718ECF97BBAD /* PhotoLibraryService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoLibraryService.swift; sourceTree = "<group>"; };
|
46B47E3C76EA718ECF97BBAD /* PhotoLibraryService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoLibraryService.swift; sourceTree = "<group>"; };
|
||||||
47E762898E351192399FC739 /* BackupJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupJob.swift; sourceTree = "<group>"; };
|
47E762898E351192399FC739 /* BackupJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupJob.swift; sourceTree = "<group>"; };
|
||||||
4AEFE4959C08B94AE9584A11 /* BackupResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupResult.swift; sourceTree = "<group>"; };
|
4AEFE4959C08B94AE9584A11 /* BackupResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupResult.swift; sourceTree = "<group>"; };
|
||||||
4B67584BB6D705BCB3D40192 /* SFTPService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SFTPService.swift; sourceTree = "<group>"; };
|
4B67584BB6D705BCB3D40192 /* SFTPService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SFTPService.swift; sourceTree = "<group>"; };
|
||||||
4CBE4F65BE653C7C72E096D1 /* LANTriggerSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LANTriggerSection.swift; sourceTree = "<group>"; };
|
4CBE4F65BE653C7C72E096D1 /* LANTriggerSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LANTriggerSection.swift; sourceTree = "<group>"; };
|
||||||
|
4EC8C588CD91ABA1EFED2F2B /* KeychainStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainStore.swift; sourceTree = "<group>"; };
|
||||||
|
50191596BF6E84CFAE7904D3 /* ConnectViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectViewModelTests.swift; sourceTree = "<group>"; };
|
||||||
55EA454B783C2AEDABFB00A7 /* BackupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupView.swift; sourceTree = "<group>"; };
|
55EA454B783C2AEDABFB00A7 /* BackupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupView.swift; sourceTree = "<group>"; };
|
||||||
5606A4C5894CFC72D555CAE1 /* LANMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LANMonitor.swift; sourceTree = "<group>"; };
|
5606A4C5894CFC72D555CAE1 /* LANMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LANMonitor.swift; sourceTree = "<group>"; };
|
||||||
5643379BBA1FD39841F9EA55 /* BackgroundTaskManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundTaskManager.swift; sourceTree = "<group>"; };
|
5643379BBA1FD39841F9EA55 /* BackgroundTaskManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundTaskManager.swift; sourceTree = "<group>"; };
|
||||||
6EEBCD4A838173E557DC3EFC /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = "<group>"; };
|
6EEBCD4A838173E557DC3EFC /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = "<group>"; };
|
||||||
74E2AF8B56C469E0D94EFE94 /* PhotoLibraryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoLibraryProtocol.swift; sourceTree = "<group>"; };
|
74E2AF8B56C469E0D94EFE94 /* PhotoLibraryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoLibraryProtocol.swift; sourceTree = "<group>"; };
|
||||||
|
7688CD7E9BC5475A3EB01021 /* NASProtocolTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NASProtocolTests.swift; sourceTree = "<group>"; };
|
||||||
|
7AC72ED4015671C51066AC3E /* PhotoPermissionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoPermissionView.swift; sourceTree = "<group>"; };
|
||||||
824FADB40473BFC36FE91F0A /* BackupEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupEngine.swift; sourceTree = "<group>"; };
|
824FADB40473BFC36FE91F0A /* BackupEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupEngine.swift; sourceTree = "<group>"; };
|
||||||
83C13DD3EB4823AA98D51AF2 /* FolderRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderRow.swift; sourceTree = "<group>"; };
|
83C13DD3EB4823AA98D51AF2 /* FolderRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderRow.swift; sourceTree = "<group>"; };
|
||||||
|
86FA0D1522E2C4FF7C1CABF3 /* PHAsset+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PHAsset+.swift"; sourceTree = "<group>"; };
|
||||||
8845003C87961174302AC990 /* ToggleRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToggleRow.swift; sourceTree = "<group>"; };
|
8845003C87961174302AC990 /* ToggleRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToggleRow.swift; sourceTree = "<group>"; };
|
||||||
88E66C22B614E89A7A9E9B35 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
88E66C22B614E89A7A9E9B35 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||||
91197DD51221A3277AE318E2 /* ConnectViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectViewModel.swift; sourceTree = "<group>"; };
|
91197DD51221A3277AE318E2 /* ConnectViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectViewModel.swift; sourceTree = "<group>"; };
|
||||||
@@ -66,18 +99,24 @@
|
|||||||
A8F24C7DD7A503A0D561F3FB /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = "<group>"; };
|
A8F24C7DD7A503A0D561F3FB /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = "<group>"; };
|
||||||
AC73AD75762D4357D171CDA4 /* BackupViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupViewModel.swift; sourceTree = "<group>"; };
|
AC73AD75762D4357D171CDA4 /* BackupViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupViewModel.swift; sourceTree = "<group>"; };
|
||||||
B36E23852EF12DC14A2DF2DC /* NASBackupApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NASBackupApp.swift; sourceTree = "<group>"; };
|
B36E23852EF12DC14A2DF2DC /* NASBackupApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NASBackupApp.swift; sourceTree = "<group>"; };
|
||||||
|
B55F95367E10145F50BF06E3 /* UGreenCompatibilityBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UGreenCompatibilityBadge.swift; sourceTree = "<group>"; };
|
||||||
|
BACE1D31086231C97DAD884D /* MockNASService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockNASService.swift; sourceTree = "<group>"; };
|
||||||
C755BFDD42D46A3FB490A4BB /* NASBackup.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = NASBackup.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
C755BFDD42D46A3FB490A4BB /* NASBackup.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = NASBackup.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
C955FEF6710E3667C7A73A02 /* ProgressRing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressRing.swift; sourceTree = "<group>"; };
|
C955FEF6710E3667C7A73A02 /* ProgressRing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressRing.swift; sourceTree = "<group>"; };
|
||||||
CA0969FC7D7E0EE313E29D55 /* ButtonStyles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonStyles.swift; sourceTree = "<group>"; };
|
CA0969FC7D7E0EE313E29D55 /* ButtonStyles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonStyles.swift; sourceTree = "<group>"; };
|
||||||
|
D1D1C00B125D5CC000988DD0 /* SavedConnections.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedConnections.swift; sourceTree = "<group>"; };
|
||||||
D87319355D902007618A91AE /* ConnectionStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionStore.swift; sourceTree = "<group>"; };
|
D87319355D902007618A91AE /* ConnectionStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionStore.swift; sourceTree = "<group>"; };
|
||||||
D947C8F8945A2AB081BBD0EF /* BrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseView.swift; sourceTree = "<group>"; };
|
D947C8F8945A2AB081BBD0EF /* BrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseView.swift; sourceTree = "<group>"; };
|
||||||
DEBD92D10D70B957192E271F /* SMBService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMBService.swift; sourceTree = "<group>"; };
|
DEBD92D10D70B957192E271F /* SMBService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMBService.swift; sourceTree = "<group>"; };
|
||||||
|
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 = "<group>"; };
|
EE318B60576B11E80862664D /* ConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectView.swift; sourceTree = "<group>"; };
|
||||||
F03F0ED1A914F3392BFE5E4A /* BackupError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupError.swift; sourceTree = "<group>"; };
|
F03F0ED1A914F3392BFE5E4A /* BackupError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupError.swift; sourceTree = "<group>"; };
|
||||||
F09160B7B7A06BD900D0D87A /* MainTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabView.swift; sourceTree = "<group>"; };
|
F09160B7B7A06BD900D0D87A /* MainTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabView.swift; sourceTree = "<group>"; };
|
||||||
F0AABE0AA63B1ACA1DEABB65 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
F0AABE0AA63B1ACA1DEABB65 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
||||||
|
F19B5E72ADF230C0C134942B /* LANMonitorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LANMonitorTests.swift; sourceTree = "<group>"; };
|
||||||
F1A42E8CFCAECFDBF6EDDAC0 /* LoginViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginViewModel.swift; sourceTree = "<group>"; };
|
F1A42E8CFCAECFDBF6EDDAC0 /* LoginViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginViewModel.swift; sourceTree = "<group>"; };
|
||||||
F5CE897758E9CD5BB8B80F63 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
F5CE897758E9CD5BB8B80F63 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||||
|
F6CD83D8292EF9EFBE3A9C6C /* Date+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+.swift"; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -102,6 +141,7 @@
|
|||||||
1AD5A741A7466C3510901C60 /* Connect */,
|
1AD5A741A7466C3510901C60 /* Connect */,
|
||||||
88D0BD5235374F2A264A0C96 /* History */,
|
88D0BD5235374F2A264A0C96 /* History */,
|
||||||
FDF5327105186A6456CF0B08 /* Login */,
|
FDF5327105186A6456CF0B08 /* Login */,
|
||||||
|
611BB6A8B01D82432C0A32EC /* Onboarding */,
|
||||||
147B04CA2B7A7723CCD13E93 /* Settings */,
|
147B04CA2B7A7723CCD13E93 /* Settings */,
|
||||||
);
|
);
|
||||||
path = Features;
|
path = Features;
|
||||||
@@ -115,6 +155,7 @@
|
|||||||
0A801C860EB4C4B22FD4D073 /* Features */,
|
0A801C860EB4C4B22FD4D073 /* Features */,
|
||||||
ADFBF8F90DF8352BD2833A66 /* Services */,
|
ADFBF8F90DF8352BD2833A66 /* Services */,
|
||||||
31C8DD8F9077E2B6C67A99BA /* Shared */,
|
31C8DD8F9077E2B6C67A99BA /* Shared */,
|
||||||
|
D7CC50C0264BFFC23DA2C868 /* Tests */,
|
||||||
8308FE572D56B8045F17F6A0 /* Products */,
|
8308FE572D56B8045F17F6A0 /* Products */,
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -123,6 +164,8 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
D87319355D902007618A91AE /* ConnectionStore.swift */,
|
D87319355D902007618A91AE /* ConnectionStore.swift */,
|
||||||
|
4EC8C588CD91ABA1EFED2F2B /* KeychainStore.swift */,
|
||||||
|
D1D1C00B125D5CC000988DD0 /* SavedConnections.swift */,
|
||||||
);
|
);
|
||||||
path = Persistence;
|
path = Persistence;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -136,6 +179,16 @@
|
|||||||
path = Settings;
|
path = Settings;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
185383972A68402ADF89D64C /* Extensions */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
F6CD83D8292EF9EFBE3A9C6C /* Date+.swift */,
|
||||||
|
86FA0D1522E2C4FF7C1CABF3 /* PHAsset+.swift */,
|
||||||
|
277DF2E5F4E566C869EC29F3 /* URL+.swift */,
|
||||||
|
);
|
||||||
|
path = Extensions;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
1AD5A741A7466C3510901C60 /* Connect */ = {
|
1AD5A741A7466C3510901C60 /* Connect */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -149,6 +202,7 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
3E7F8DE77B7CDC7009B54D63 /* Components */,
|
3E7F8DE77B7CDC7009B54D63 /* Components */,
|
||||||
|
185383972A68402ADF89D64C /* Extensions */,
|
||||||
0B27021136C1E85D5B8CFF97 /* Persistence */,
|
0B27021136C1E85D5B8CFF97 /* Persistence */,
|
||||||
C874CB05A914EE933FCFD5C2 /* Theme */,
|
C874CB05A914EE933FCFD5C2 /* Theme */,
|
||||||
);
|
);
|
||||||
@@ -162,10 +216,20 @@
|
|||||||
83C13DD3EB4823AA98D51AF2 /* FolderRow.swift */,
|
83C13DD3EB4823AA98D51AF2 /* FolderRow.swift */,
|
||||||
C955FEF6710E3667C7A73A02 /* ProgressRing.swift */,
|
C955FEF6710E3667C7A73A02 /* ProgressRing.swift */,
|
||||||
8845003C87961174302AC990 /* ToggleRow.swift */,
|
8845003C87961174302AC990 /* ToggleRow.swift */,
|
||||||
|
B55F95367E10145F50BF06E3 /* UGreenCompatibilityBadge.swift */,
|
||||||
);
|
);
|
||||||
path = Components;
|
path = Components;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
611BB6A8B01D82432C0A32EC /* Onboarding */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
3C31F3F5EBCB51002188FA15 /* FolderSetupView.swift */,
|
||||||
|
7AC72ED4015671C51066AC3E /* PhotoPermissionView.swift */,
|
||||||
|
);
|
||||||
|
path = Onboarding;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
803154EA96E2431367806BAF /* Backup */ = {
|
803154EA96E2431367806BAF /* Backup */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -188,6 +252,7 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
C755BFDD42D46A3FB490A4BB /* NASBackup.app */,
|
C755BFDD42D46A3FB490A4BB /* NASBackup.app */,
|
||||||
|
E3A1E9181F350DA9F091D019 /* NASBackupTests.xctest */,
|
||||||
);
|
);
|
||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -214,6 +279,7 @@
|
|||||||
88E66C22B614E89A7A9E9B35 /* AppDelegate.swift */,
|
88E66C22B614E89A7A9E9B35 /* AppDelegate.swift */,
|
||||||
F5CE897758E9CD5BB8B80F63 /* Info.plist */,
|
F5CE897758E9CD5BB8B80F63 /* Info.plist */,
|
||||||
B36E23852EF12DC14A2DF2DC /* NASBackupApp.swift */,
|
B36E23852EF12DC14A2DF2DC /* NASBackupApp.swift */,
|
||||||
|
0C0E3EBE5A0DBD9D64196501 /* PrivacyInfo.xcprivacy */,
|
||||||
F0AABE0AA63B1ACA1DEABB65 /* SceneDelegate.swift */,
|
F0AABE0AA63B1ACA1DEABB65 /* SceneDelegate.swift */,
|
||||||
);
|
);
|
||||||
path = App;
|
path = App;
|
||||||
@@ -258,6 +324,18 @@
|
|||||||
path = Theme;
|
path = Theme;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
D7CC50C0264BFFC23DA2C868 /* Tests */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
336D155A4D1C895215A4FFF2 /* BackupEngineTests.swift */,
|
||||||
|
50191596BF6E84CFAE7904D3 /* ConnectViewModelTests.swift */,
|
||||||
|
F19B5E72ADF230C0C134942B /* LANMonitorTests.swift */,
|
||||||
|
BACE1D31086231C97DAD884D /* MockNASService.swift */,
|
||||||
|
7688CD7E9BC5475A3EB01021 /* NASProtocolTests.swift */,
|
||||||
|
);
|
||||||
|
path = Tests;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
F75092ACBEF36F32816506F7 /* Models */ = {
|
F75092ACBEF36F32816506F7 /* Models */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -285,6 +363,7 @@
|
|||||||
buildConfigurationList = F29F169757D793211107ABA5 /* Build configuration list for PBXNativeTarget "NASBackup" */;
|
buildConfigurationList = F29F169757D793211107ABA5 /* Build configuration list for PBXNativeTarget "NASBackup" */;
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
E6D3CCCB60977A7DABFE7F19 /* Sources */,
|
E6D3CCCB60977A7DABFE7F19 /* Sources */,
|
||||||
|
6DB4B780F7FFCAAC2B48E78F /* Resources */,
|
||||||
BC332B29F6F9E065FF85AED8 /* Frameworks */,
|
BC332B29F6F9E065FF85AED8 /* Frameworks */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
@@ -300,6 +379,24 @@
|
|||||||
productReference = C755BFDD42D46A3FB490A4BB /* NASBackup.app */;
|
productReference = C755BFDD42D46A3FB490A4BB /* NASBackup.app */;
|
||||||
productType = "com.apple.product-type.application";
|
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 */
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
/* Begin PBXProject section */
|
/* Begin PBXProject section */
|
||||||
@@ -313,6 +410,10 @@
|
|||||||
DevelopmentTeam = "";
|
DevelopmentTeam = "";
|
||||||
ProvisioningStyle = Automatic;
|
ProvisioningStyle = Automatic;
|
||||||
};
|
};
|
||||||
|
88E89D71F1D8FA18A6A64BBF = {
|
||||||
|
DevelopmentTeam = "";
|
||||||
|
ProvisioningStyle = Automatic;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
buildConfigurationList = 1A69DB656166247B44D9B8F9 /* Build configuration list for PBXProject "NASBackup" */;
|
buildConfigurationList = 1A69DB656166247B44D9B8F9 /* Build configuration list for PBXProject "NASBackup" */;
|
||||||
@@ -334,11 +435,35 @@
|
|||||||
projectRoot = "";
|
projectRoot = "";
|
||||||
targets = (
|
targets = (
|
||||||
32316B985B8906FE4CB97730 /* NASBackup */,
|
32316B985B8906FE4CB97730 /* NASBackup */,
|
||||||
|
88E89D71F1D8FA18A6A64BBF /* NASBackupTests */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
/* End PBXProject section */
|
/* 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 */
|
/* 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 */ = {
|
E6D3CCCB60977A7DABFE7F19 /* Sources */ = {
|
||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@@ -357,8 +482,11 @@
|
|||||||
DF719EAD5727B335001DB50A /* ConnectView.swift in Sources */,
|
DF719EAD5727B335001DB50A /* ConnectView.swift in Sources */,
|
||||||
C6AAC362F058E7836A91AC30 /* ConnectViewModel.swift in Sources */,
|
C6AAC362F058E7836A91AC30 /* ConnectViewModel.swift in Sources */,
|
||||||
F9219423E73718D8AB604EB1 /* ConnectionStore.swift in Sources */,
|
F9219423E73718D8AB604EB1 /* ConnectionStore.swift in Sources */,
|
||||||
|
30693CB8EE711577978EF88C /* Date+.swift in Sources */,
|
||||||
6DED667F0AA66503730640F1 /* FolderRow.swift in Sources */,
|
6DED667F0AA66503730640F1 /* FolderRow.swift in Sources */,
|
||||||
|
77A3FF7E2E4F506E7F9E5CCD /* FolderSetupView.swift in Sources */,
|
||||||
6500E80B50EEF877C5127A2A /* HistoryView.swift in Sources */,
|
6500E80B50EEF877C5127A2A /* HistoryView.swift in Sources */,
|
||||||
|
8EB77E535C82274276C1E328 /* KeychainStore.swift in Sources */,
|
||||||
CE47E9F2179F86BE0A630D91 /* LANMonitor.swift in Sources */,
|
CE47E9F2179F86BE0A630D91 /* LANMonitor.swift in Sources */,
|
||||||
7DF923F172E6DFE3DD110DC9 /* LANTriggerSection.swift in Sources */,
|
7DF923F172E6DFE3DD110DC9 /* LANTriggerSection.swift in Sources */,
|
||||||
45FFF2DCFE454EFDF642CEB1 /* LoginView.swift in Sources */,
|
45FFF2DCFE454EFDF642CEB1 /* LoginView.swift in Sources */,
|
||||||
@@ -367,20 +495,50 @@
|
|||||||
7872072DD0E0B8D51C1A12DF /* NASBackupApp.swift in Sources */,
|
7872072DD0E0B8D51C1A12DF /* NASBackupApp.swift in Sources */,
|
||||||
512B235FB16DEFBBD9EA4E1C /* NASConnection.swift in Sources */,
|
512B235FB16DEFBBD9EA4E1C /* NASConnection.swift in Sources */,
|
||||||
8C1C09CA16A5D71408468BCB /* NASTransferProtocol.swift in Sources */,
|
8C1C09CA16A5D71408468BCB /* NASTransferProtocol.swift in Sources */,
|
||||||
|
4148416D31C8AC65FEE6394F /* PHAsset+.swift in Sources */,
|
||||||
DCE9AF4F0160E323224E3553 /* PhotoLibraryProtocol.swift in Sources */,
|
DCE9AF4F0160E323224E3553 /* PhotoLibraryProtocol.swift in Sources */,
|
||||||
57656BC4B5A2E81FE7BF37FD /* PhotoLibraryService.swift in Sources */,
|
57656BC4B5A2E81FE7BF37FD /* PhotoLibraryService.swift in Sources */,
|
||||||
|
E24CEE0D51131D991038205D /* PhotoPermissionView.swift in Sources */,
|
||||||
B50445A9010E20F97E9E6E48 /* ProgressRing.swift in Sources */,
|
B50445A9010E20F97E9E6E48 /* ProgressRing.swift in Sources */,
|
||||||
BC4346F01DAA4DE7D8F260DB /* SFTPService.swift in Sources */,
|
BC4346F01DAA4DE7D8F260DB /* SFTPService.swift in Sources */,
|
||||||
9758372DD65F5353C72DE5C5 /* SMBService.swift in Sources */,
|
9758372DD65F5353C72DE5C5 /* SMBService.swift in Sources */,
|
||||||
|
13FCA7D0BB01BFC46A5B725B /* SavedConnections.swift in Sources */,
|
||||||
A6D143C05EE16594E0D1AA01 /* SceneDelegate.swift in Sources */,
|
A6D143C05EE16594E0D1AA01 /* SceneDelegate.swift in Sources */,
|
||||||
C8E9BC6070E23992EED8D013 /* SettingsView.swift in Sources */,
|
C8E9BC6070E23992EED8D013 /* SettingsView.swift in Sources */,
|
||||||
B08D787E99C3B2E193A0D694 /* ToggleRow.swift in Sources */,
|
B08D787E99C3B2E193A0D694 /* ToggleRow.swift in Sources */,
|
||||||
|
96CC879835A8CFE5FDD21B85 /* UGreenCompatibilityBadge.swift in Sources */,
|
||||||
|
10062AA91BC883AE597443BD /* URL+.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
/* End PBXSourcesBuildPhase section */
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXTargetDependency section */
|
||||||
|
EA438E47054B868AA6F6727D /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
target = 32316B985B8906FE4CB97730 /* NASBackup */;
|
||||||
|
targetProxy = 2F6BE82D286218B2DC8D8B98 /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
/* Begin XCBuildConfiguration 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 */ = {
|
5496D0713D70962E349CB0C8 /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
@@ -397,6 +555,23 @@
|
|||||||
};
|
};
|
||||||
name = Debug;
|
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 */ = {
|
AAEAFE599F3F20DB264D4528 /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
@@ -554,6 +729,15 @@
|
|||||||
defaultConfigurationIsVisible = 0;
|
defaultConfigurationIsVisible = 0;
|
||||||
defaultConfigurationName = Debug;
|
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" */ = {
|
F29F169757D793211107ABA5 /* Build configuration list for PBXNativeTarget "NASBackup" */ = {
|
||||||
isa = XCConfigurationList;
|
isa = XCConfigurationList;
|
||||||
buildConfigurations = (
|
buildConfigurations = (
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
|
import os.log
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: "com.albert.nasbackup", category: "BackupEngine")
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class BackupEngine: ObservableObject {
|
final class BackupEngine: ObservableObject {
|
||||||
@@ -7,11 +10,30 @@ final class BackupEngine: ObservableObject {
|
|||||||
|
|
||||||
@Published private(set) var job: BackupJob = BackupJob()
|
@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 activeTransfer: (any NASTransferProtocol)?
|
||||||
private var isCancelled = false
|
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(
|
func run(
|
||||||
connection: NASConnection,
|
connection: NASConnection,
|
||||||
@@ -36,11 +58,7 @@ final class BackupEngine: ObservableObject {
|
|||||||
let assets = photoService.fetchAssets(filter: filter)
|
let assets = photoService.fetchAssets(filter: filter)
|
||||||
|
|
||||||
// 2. Connect to NAS
|
// 2. Connect to NAS
|
||||||
let transfer: any NASTransferProtocol
|
let transfer = transferFactory(connection.nasProtocol)
|
||||||
switch connection.nasProtocol {
|
|
||||||
case .smb: transfer = SMBService()
|
|
||||||
case .sftp: transfer = SFTPService()
|
|
||||||
}
|
|
||||||
|
|
||||||
try await transfer.connect(
|
try await transfer.connect(
|
||||||
to: host,
|
to: host,
|
||||||
@@ -102,6 +120,7 @@ final class BackupEngine: ObservableObject {
|
|||||||
job.fileCompleted(skipped: false)
|
job.fileCompleted(skipped: false)
|
||||||
uploaded += 1
|
uploaded += 1
|
||||||
} catch {
|
} catch {
|
||||||
|
logger.error("Upload failed for \(asset.filename, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||||||
job.fileFailed()
|
job.fileFailed()
|
||||||
failed += 1
|
failed += 1
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,9 +44,9 @@ final class PhotoLibraryService: PhotoLibraryProtocol {
|
|||||||
let result = PHAsset.fetchAssets(with: options)
|
let result = PHAsset.fetchAssets(with: options)
|
||||||
var assets: [PhotoAsset] = []
|
var assets: [PhotoAsset] = []
|
||||||
result.enumerateObjects { asset, _, _ in
|
result.enumerateObjects { asset, _, _ in
|
||||||
|
autoreleasepool {
|
||||||
let isScreenshot = asset.mediaSubtypes.contains(.photoScreenshot)
|
let isScreenshot = asset.mediaSubtypes.contains(.photoScreenshot)
|
||||||
let resources = PHAssetResource.assetResources(for: asset)
|
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 }
|
let isRAW = resources.contains { $0.type == .alternatePhoto }
|
||||||
if isRAW && !filter.includeRAW { return }
|
if isRAW && !filter.includeRAW { return }
|
||||||
|
|
||||||
@@ -64,6 +64,7 @@ final class PhotoLibraryService: PhotoLibraryProtocol {
|
|||||||
isRAW: isRAW
|
isRAW: isRAW
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return assets
|
return assets
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
// MARK: — Primary (black fill)
|
// MARK: — Primary (ink fill)
|
||||||
struct PrimaryButtonStyle: ButtonStyle {
|
struct PrimaryButtonStyle: ButtonStyle {
|
||||||
var color: Color = AppTheme.ink
|
var color: Color = AppTheme.ink
|
||||||
var height: CGFloat = 50
|
var height: CGFloat = 50
|
||||||
@@ -8,7 +8,7 @@ struct PrimaryButtonStyle: ButtonStyle {
|
|||||||
func makeBody(configuration: Configuration) -> some View {
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
configuration.label
|
configuration.label
|
||||||
.font(.system(size: 15, weight: .medium))
|
.font(.system(size: 15, weight: .medium))
|
||||||
.foregroundColor(.white)
|
.foregroundStyle(AppTheme.inkInverse)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.frame(height: height)
|
.frame(height: height)
|
||||||
.background(color)
|
.background(color)
|
||||||
@@ -25,12 +25,12 @@ struct GhostButtonStyle: ButtonStyle {
|
|||||||
func makeBody(configuration: Configuration) -> some View {
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
configuration.label
|
configuration.label
|
||||||
.font(.system(size: 15, weight: .medium))
|
.font(.system(size: 15, weight: .medium))
|
||||||
.foregroundColor(AppTheme.ink)
|
.foregroundStyle(AppTheme.ink)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.frame(height: height)
|
.frame(height: height)
|
||||||
.overlay(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)
|
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)
|
.scaleEffect(configuration.isPressed ? 0.97 : 1)
|
||||||
.animation(.spring(response: 0.25, dampingFraction: 0.7), value: configuration.isPressed)
|
.animation(.spring(response: 0.25, dampingFraction: 0.7), value: configuration.isPressed)
|
||||||
@@ -52,7 +52,7 @@ struct PillButtonStyle: ButtonStyle {
|
|||||||
func makeBody(configuration: Configuration) -> some View {
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
configuration.label
|
configuration.label
|
||||||
.font(.system(size: 13, weight: .medium))
|
.font(.system(size: 13, weight: .medium))
|
||||||
.foregroundColor(.white)
|
.foregroundStyle(AppTheme.inkInverse)
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
.padding(.vertical, 7)
|
.padding(.vertical, 7)
|
||||||
.background(color)
|
.background(color)
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ struct ToggleRow: View {
|
|||||||
Toggle("", isOn: $isOn)
|
Toggle("", isOn: $isOn)
|
||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
.tint(AppTheme.ink)
|
.tint(AppTheme.ink)
|
||||||
|
.accessibilityLabel(title)
|
||||||
|
.accessibilityValue(isOn ? "On" : "Off")
|
||||||
}
|
}
|
||||||
.padding(.horizontal, AppTheme.cardPad)
|
.padding(.horizontal, AppTheme.cardPad)
|
||||||
.padding(.vertical, 12)
|
.padding(.vertical, 12)
|
||||||
|
|||||||
46
Shared/Components/UGreenCompatibilityBadge.swift
Normal file
46
Shared/Components/UGreenCompatibilityBadge.swift
Normal file
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
30
Shared/Extensions/Date+.swift
Normal file
30
Shared/Extensions/Date+.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
14
Shared/Extensions/PHAsset+.swift
Normal file
14
Shared/Extensions/PHAsset+.swift
Normal file
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
14
Shared/Extensions/URL+.swift
Normal file
14
Shared/Extensions/URL+.swift
Normal file
@@ -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: "_")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,7 +15,10 @@ final class ConnectionStore: ObservableObject {
|
|||||||
static let shared = ConnectionStore()
|
static let shared = ConnectionStore()
|
||||||
|
|
||||||
@Published var savedConnection: NASConnection? {
|
@Published var savedConnection: NASConnection? {
|
||||||
didSet { saveUD(savedConnection, key: "savedConnection") }
|
didSet {
|
||||||
|
if let conn = savedConnection { SavedConnections.save(conn) }
|
||||||
|
else { SavedConnections.delete() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@Published var backupFilter: BackupFilter {
|
@Published var backupFilter: BackupFilter {
|
||||||
didSet { saveUD(backupFilter, key: "backupFilter") }
|
didSet { saveUD(backupFilter, key: "backupFilter") }
|
||||||
@@ -42,6 +45,19 @@ final class ConnectionStore: ObservableObject {
|
|||||||
didSet { UserDefaults.standard.set(quietHoursEnd, forKey: "quietHoursEnd") }
|
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
|
// Cellular & remote access
|
||||||
@Published var allowCellularBackup: Bool {
|
@Published var allowCellularBackup: Bool {
|
||||||
didSet { UserDefaults.standard.set(allowCellularBackup, forKey: "allowCellularBackup") }
|
didSet { UserDefaults.standard.set(allowCellularBackup, forKey: "allowCellularBackup") }
|
||||||
@@ -56,7 +72,7 @@ final class ConnectionStore: ObservableObject {
|
|||||||
@Published private(set) var historyEntries: [BackupHistoryEntry] = []
|
@Published private(set) var historyEntries: [BackupHistoryEntry] = []
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
savedConnection = ud(NASConnection.self, key: "savedConnection")
|
savedConnection = SavedConnections.load()
|
||||||
backupFilter = ud(BackupFilter.self, key: "backupFilter") ?? BackupFilter()
|
backupFilter = ud(BackupFilter.self, key: "backupFilter") ?? BackupFilter()
|
||||||
trustedSSIDs = UserDefaults.standard.stringArray(forKey: "trustedSSIDs") ?? []
|
trustedSSIDs = UserDefaults.standard.stringArray(forKey: "trustedSSIDs") ?? []
|
||||||
lanTriggerEnabled = UserDefaults.standard.object(forKey: "lanTriggerEnabled") as? Bool ?? true
|
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
|
quietHoursEnabled = UserDefaults.standard.object(forKey: "quietHoursEnabled") as? Bool ?? false
|
||||||
quietHoursStart = UserDefaults.standard.object(forKey: "quietHoursStart") as? Int ?? 23
|
quietHoursStart = UserDefaults.standard.object(forKey: "quietHoursStart") as? Int ?? 23
|
||||||
quietHoursEnd = UserDefaults.standard.object(forKey: "quietHoursEnd") as? Int ?? 7
|
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
|
allowCellularBackup = UserDefaults.standard.object(forKey: "allowCellularBackup") as? Bool ?? false
|
||||||
useTailscaleWhenRemote = UserDefaults.standard.object(forKey: "useTailscaleWhenRemote") as? Bool ?? false
|
useTailscaleWhenRemote = UserDefaults.standard.object(forKey: "useTailscaleWhenRemote") as? Bool ?? false
|
||||||
tailscaleHost = UserDefaults.standard.string(forKey: "tailscaleHost") ?? ""
|
tailscaleHost = UserDefaults.standard.string(forKey: "tailscaleHost") ?? ""
|
||||||
|
|||||||
48
Shared/Persistence/KeychainStore.swift
Normal file
48
Shared/Persistence/KeychainStore.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
30
Shared/Persistence/SavedConnections.swift
Normal file
30
Shared/Persistence/SavedConnections.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,74 @@
|
|||||||
import SwiftUI
|
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 {
|
enum AppTheme {
|
||||||
// MARK: — Surfaces
|
// MARK: — Surfaces
|
||||||
static let background = Color.white
|
static let background = Color(UIColor.systemBackground)
|
||||||
static let surfaceRaised = Color.white
|
static let surfaceRaised = Color(UIColor.systemBackground)
|
||||||
static let surfaceSunken = Color(hex: "#F5F5F5")
|
static let surfaceSunken = Color(UIColor { t in
|
||||||
|
t.userInterfaceStyle == .dark
|
||||||
|
? UIColor(white: 0.11, alpha: 1)
|
||||||
|
: UIColor(white: 0.961, alpha: 1)
|
||||||
|
})
|
||||||
|
|
||||||
// MARK: — Ink
|
// MARK: — Ink
|
||||||
static let ink = Color(hex: "#0F0F0F")
|
static let ink = Color(UIColor { t in
|
||||||
static let inkSecondary = Color(hex: "#6B6B6B")
|
t.userInterfaceStyle == .dark
|
||||||
static let inkTertiary = Color(hex: "#ABABAB")
|
? UIColor(white: 0.93, alpha: 1)
|
||||||
static let inkQuaternary = Color(hex: "#D4D4D4")
|
: 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
|
// MARK: — Semantic
|
||||||
static let positive = Color(hex: "#16A34A")
|
static let positive = Color(hex: "#16A34A")
|
||||||
@@ -18,9 +76,13 @@ enum AppTheme {
|
|||||||
static let interactive = Color(hex: "#2563EB")
|
static let interactive = Color(hex: "#2563EB")
|
||||||
|
|
||||||
// MARK: — Chrome
|
// MARK: — Chrome
|
||||||
static let separator = Color.black.opacity(0.07)
|
static let separator = Color(UIColor.separator)
|
||||||
static let cardShadow = Color.black.opacity(0.05)
|
static let cardShadow = Color(UIColor { t in
|
||||||
static let cardBorderColor = Color.black.opacity(0.07)
|
t.userInterfaceStyle == .dark
|
||||||
|
? UIColor.clear
|
||||||
|
: UIColor.black.withAlphaComponent(0.048)
|
||||||
|
})
|
||||||
|
static let cardBorderColor = Color(UIColor.separator)
|
||||||
|
|
||||||
// MARK: — Metrics
|
// MARK: — Metrics
|
||||||
static let radius: CGFloat = 14
|
static let radius: CGFloat = 14
|
||||||
@@ -33,23 +95,13 @@ enum AppTheme {
|
|||||||
static func display(_ size: CGFloat = 32) -> Font {
|
static func display(_ size: CGFloat = 32) -> Font {
|
||||||
.system(size: size, weight: .semibold, design: .default)
|
.system(size: size, weight: .semibold, design: .default)
|
||||||
}
|
}
|
||||||
static func title(_ size: CGFloat = 20) -> Font {
|
static func title(_ size: CGFloat = 20) -> Font { .system(size: size, weight: .semibold) }
|
||||||
.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 headline(_ size: CGFloat = 15) -> Font {
|
static func caption(_ size: CGFloat = 13) -> Font { .system(size: size, weight: .regular) }
|
||||||
.system(size: size, weight: .medium)
|
static func micro(_ size: CGFloat = 11) -> 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 blue = interactive
|
||||||
static let green = positive
|
static let green = positive
|
||||||
static let red = destructive
|
static let red = destructive
|
||||||
@@ -82,9 +134,7 @@ struct CardStyle: ViewModifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension View {
|
extension View {
|
||||||
func card(padded: Bool = true) -> some View {
|
func card(padded: Bool = true) -> some View { modifier(CardStyle(padded: padded)) }
|
||||||
modifier(CardStyle(padded: padded))
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder func `if`<T: View>(_ condition: Bool, transform: (Self) -> T) -> some View {
|
@ViewBuilder func `if`<T: View>(_ condition: Bool, transform: (Self) -> T) -> some View {
|
||||||
if condition { transform(self) } else { self }
|
if condition { transform(self) } else { self }
|
||||||
|
|||||||
117
Tests/BackupEngineTests.swift
Normal file
117
Tests/BackupEngineTests.swift
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import XCTest
|
||||||
|
import Photos
|
||||||
|
@testable import NASBackup
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class BackupEngineTests: XCTestCase {
|
||||||
|
|
||||||
|
private func makeAssets(count: Int) -> [PhotoAsset] {
|
||||||
|
(0..<count).map { i in
|
||||||
|
PhotoAsset(
|
||||||
|
localIdentifier: "asset-\(i)",
|
||||||
|
filename: "photo_\(i).jpg",
|
||||||
|
creationDate: Date(),
|
||||||
|
mediaType: .image,
|
||||||
|
pixelWidth: 1024, pixelHeight: 768,
|
||||||
|
duration: 0, isScreenshot: false, isRAW: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeConnection() -> 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
|
||||||
|
}
|
||||||
|
}
|
||||||
47
Tests/ConnectViewModelTests.swift
Normal file
47
Tests/ConnectViewModelTests.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
48
Tests/LANMonitorTests.swift
Normal file
48
Tests/LANMonitorTests.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
49
Tests/MockNASService.swift
Normal file
49
Tests/MockNASService.swift
Normal file
@@ -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
|
||||||
|
}
|
||||||
22
Tests/NASProtocolTests.swift
Normal file
22
Tests/NASProtocolTests.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
16
project.yml
16
project.yml
@@ -36,6 +36,8 @@ targets:
|
|||||||
- Shared
|
- Shared
|
||||||
resources:
|
resources:
|
||||||
- Assets.xcassets
|
- Assets.xcassets
|
||||||
|
- path: App/PrivacyInfo.xcprivacy
|
||||||
|
buildPhase: resources
|
||||||
info:
|
info:
|
||||||
path: App/Info.plist
|
path: App/Info.plist
|
||||||
properties:
|
properties:
|
||||||
@@ -74,3 +76,17 @@ targets:
|
|||||||
product: SMBClient
|
product: SMBClient
|
||||||
- package: Citadel
|
- package: Citadel
|
||||||
product: 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"
|
||||||
|
|||||||
Reference in New Issue
Block a user