From 089c9dfd673c948c38af3d0f9d1f7d3e7f86559c Mon Sep 17 00:00:00 2001 From: Robin Kutesa Date: Tue, 19 May 2026 01:19:43 +0300 Subject: [PATCH] Add screenshot cleanup and NAS quarantine features MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NASTransferProtocol: add deleteFile(at:) to enable server-side deletion - SFTPService: implement deleteFile using Citadel sftp.remove(at:) - SMBService: implement deleteFile using SMBClient deleteFile(path:) - SyncView FREE UP SPACE: two new cards — • "Clean up screenshots" — shows count of phone screenshots already backed up to NAS, opens review sheet for bulk PHPhotoLibrary deletion • "NAS screenshot quarantine" — identifies NAS files that match phone screenshots, offers to move them into a Screenshots/ subfolder via download → writeData → deleteFile per file Co-Authored-By: Kutesir Co-Authored-By: Sentry --- Core/Protocols/NASTransferProtocol.swift | 1 + Features/Sync/SyncView.swift | 307 +++++++++++++++++++++++ Services/SFTPService.swift | 5 + Services/SMBService.swift | 7 + 4 files changed, 320 insertions(+) diff --git a/Core/Protocols/NASTransferProtocol.swift b/Core/Protocols/NASTransferProtocol.swift index db33ae2..66f5cc6 100644 --- a/Core/Protocols/NASTransferProtocol.swift +++ b/Core/Protocols/NASTransferProtocol.swift @@ -25,4 +25,5 @@ protocol NASTransferProtocol: AnyObject { ) async throws func downloadData(at remotePath: String) async throws -> Data func writeData(_ data: Data, to remotePath: String) async throws + func deleteFile(at remotePath: String) async throws } diff --git a/Features/Sync/SyncView.swift b/Features/Sync/SyncView.swift index 514aaee..3c2b6bf 100644 --- a/Features/Sync/SyncView.swift +++ b/Features/Sync/SyncView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Photos struct SyncView: View { @EnvironmentObject var store: ConnectionStore @@ -14,6 +15,11 @@ struct SyncView: View { private var completedItems: [BackupQueueItem] { queueItems.filter { $0.status == .uploaded || $0.status == .skipped } } private var failedItems: [BackupQueueItem] { queueItems.filter { $0.status == .failed } } + @State private var phoneScreenshots: [LocalPhotoIndex.Record] = [] + @State private var nasScreenshotNames: [String] = [] + @State private var showPhoneCleanup = false + @State private var showNASQuarantine = false + var body: some View { ZStack { AppTheme.background.ignoresSafeArea() @@ -55,6 +61,17 @@ struct SyncView: View { .refreshable { // Pull-to-refresh: show latest queue from engine (already live) } + .task { await computeScreenshots() } + .sheet(isPresented: $showPhoneCleanup, onDismiss: { Task { await computeScreenshots() } }) { + if let conn = connection { + PhoneScreenshotCleanupSheet(records: phoneScreenshots, connection: conn) + } + } + .sheet(isPresented: $showNASQuarantine, onDismiss: { Task { await computeScreenshots() } }) { + if let conn = connection { + NASQuarantineSheet(filenames: nasScreenshotNames, connection: conn) + } + } } // MARK: — NAS header (no pill, no border) @@ -198,6 +215,7 @@ struct SyncView: View { .kerning(0.8) .padding(.horizontal, 2) + // Auto-delete after backup VStack(spacing: 0) { HStack(spacing: 12) { Image(systemName: "iphone.and.arrow.forward") @@ -244,9 +262,94 @@ struct SyncView: View { .background(AppTheme.surfaceRaised) .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)) .shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2) + + // Phone screenshot cleanup + Button { showPhoneCleanup = true } label: { + HStack(spacing: 12) { + Image(systemName: "camera.viewfinder") + .font(.system(size: 14)) + .foregroundStyle(AppTheme.inkSecondary) + .frame(width: 20) + VStack(alignment: .leading, spacing: 2) { + Text("Clean up screenshots") + .font(AppTheme.body()) + .foregroundStyle(AppTheme.ink) + Text(phoneScreenshots.isEmpty + ? "No backed-up screenshots found" + : "\(phoneScreenshots.count) screenshots safe to remove from phone") + .font(AppTheme.caption()) + .foregroundStyle(AppTheme.inkTertiary) + } + Spacer() + if !phoneScreenshots.isEmpty { + Image(systemName: "chevron.right") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(AppTheme.inkQuaternary) + } + } + .padding(AppTheme.cardPad) + } + .buttonStyle(.plain) + .disabled(phoneScreenshots.isEmpty) + .background(AppTheme.surfaceRaised) + .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)) + .shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2) + + // NAS screenshot quarantine + Button { showNASQuarantine = true } label: { + HStack(spacing: 12) { + Image(systemName: "folder.badge.minus") + .font(.system(size: 14)) + .foregroundStyle(AppTheme.inkSecondary) + .frame(width: 20) + VStack(alignment: .leading, spacing: 2) { + Text("NAS screenshot quarantine") + .font(AppTheme.body()) + .foregroundStyle(AppTheme.ink) + Text(nasScreenshotNames.isEmpty + ? "No screenshots found on NAS" + : "\(nasScreenshotNames.count) screenshots on NAS") + .font(AppTheme.caption()) + .foregroundStyle(AppTheme.inkTertiary) + } + Spacer() + if !nasScreenshotNames.isEmpty { + Image(systemName: "chevron.right") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(AppTheme.inkQuaternary) + } + } + .padding(AppTheme.cardPad) + } + .buttonStyle(.plain) + .disabled(nasScreenshotNames.isEmpty) + .background(AppTheme.surfaceRaised) + .clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)) + .shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2) } } + private func computeScreenshots() async { + await LocalPhotoIndex.shared.loadOrBuild() + let allRecords = await LocalPhotoIndex.shared.allRecords() + let screenshotRecords = allRecords.filter { $0.isScreenshot } + + let nasFilenames: [String] + if let cached = await NASManifestCache.shared.directoryFilenames { + nasFilenames = cached + } else if let manifest = await NASManifestCache.shared.manifest { + nasFilenames = manifest.entries.map { $0.filename } + } else { + nasFilenames = [] + } + let nasLower = Set(nasFilenames.map { $0.lowercased() }) + + phoneScreenshots = screenshotRecords.filter { nasLower.contains($0.filename.lowercased()) } + + let screenshotLower = Set(screenshotRecords.map { $0.filename.lowercased() }) + nasScreenshotNames = nasFilenames.filter { screenshotLower.contains($0.lowercased()) } + } + private var retentionLabel: String { switch store.localRetentionDays { case 0: "Never" @@ -398,3 +501,207 @@ struct QueueFileRow: View { return String(format: "%.1f GB", mb / 1024) } } + +// MARK: — Phone Screenshot Cleanup Sheet + +struct PhoneScreenshotCleanupSheet: View { + let records: [LocalPhotoIndex.Record] + let connection: NASConnection + @Environment(\.dismiss) private var dismiss + @State private var isDeleting = false + @State private var done = false + + var body: some View { + NavigationStack { + List { + Section { + ForEach(records, id: \.localIdentifier) { record in + HStack(spacing: 10) { + Image(systemName: "camera.viewfinder") + .font(.system(size: 13)) + .foregroundStyle(AppTheme.inkTertiary) + .frame(width: 22) + VStack(alignment: .leading, spacing: 2) { + Text(record.filename) + .font(.system(size: 13)) + .lineLimit(1) + if let date = record.creationDate { + Text(date, style: .date) + .font(.system(size: 11)) + .foregroundStyle(AppTheme.inkTertiary) + } + } + } + } + } header: { + Text("\(records.count) screenshots already backed up to NAS") + } + } + .navigationTitle("Phone Screenshots") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + } + .safeAreaInset(edge: .bottom) { + VStack(spacing: 0) { + Divider() + Button { + Task { await deleteAll() } + } label: { + Group { + if isDeleting { + ProgressView().tint(.white) + } else if done { + Label("Deleted", systemImage: "checkmark") + } else { + Text("Delete \(records.count) Screenshots from Phone") + } + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .tint(AppTheme.destructive) + .disabled(isDeleting || done) + .padding() + } + .background(AppTheme.background) + } + } + } + + private func deleteAll() async { + isDeleting = true + let ids = records.map { $0.localIdentifier } + let assets = PHAsset.fetchAssets(withLocalIdentifiers: ids, options: nil) + try? await PHPhotoLibrary.shared().performChanges { + PHAssetChangeRequest.deleteAssets(assets) + } + isDeleting = false + done = true + try? await Task.sleep(nanoseconds: 1_000_000_000) + dismiss() + } +} + +// MARK: — NAS Quarantine Sheet + +struct NASQuarantineSheet: View { + let filenames: [String] + let connection: NASConnection + @Environment(\.dismiss) private var dismiss + @State private var progress: (done: Int, total: Int) = (0, 0) + @State private var isMoving = false + @State private var done = false + @State private var errorMessage: String? + + var body: some View { + NavigationStack { + ScrollView { + VStack(spacing: 24) { + Image(systemName: "folder.badge.plus") + .font(.system(size: 52, weight: .ultraLight)) + .foregroundStyle(AppTheme.inkQuaternary) + .padding(.top, 40) + + VStack(spacing: 6) { + Text("\(filenames.count) screenshots on NAS") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(AppTheme.ink) + Text("Move to a Screenshots/ subfolder in your NAS backup directory.") + .font(AppTheme.caption()) + .foregroundStyle(AppTheme.inkTertiary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + Text(connection.remotePath + "/Screenshots/") + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(AppTheme.inkQuaternary) + .padding(.top, 2) + } + + if isMoving { + VStack(spacing: 8) { + ProgressView(value: Double(progress.done), + total: Double(max(progress.total, 1))) + .padding(.horizontal, 40) + Text("\(progress.done) of \(progress.total) moved") + .font(AppTheme.micro()) + .foregroundStyle(AppTheme.inkTertiary) + } + } + + if done { + Label("All screenshots moved", systemImage: "checkmark.circle.fill") + .foregroundStyle(AppTheme.positive) + .font(.system(size: 14, weight: .medium)) + } + + if let err = errorMessage { + Text(err) + .font(AppTheme.caption()) + .foregroundStyle(AppTheme.destructive) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + } + } + .frame(maxWidth: .infinity) + } + .navigationTitle("NAS Quarantine") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { dismiss() } + } + } + .safeAreaInset(edge: .bottom) { + if !done && !isMoving { + VStack(spacing: 0) { + Divider() + Button { + Task { await quarantine() } + } label: { + Text("Move \(filenames.count) Screenshots") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .padding() + } + .background(AppTheme.background) + } + } + } + } + + private func quarantine() async { + isMoving = true + errorMessage = nil + progress = (0, filenames.count) + let conn = connection + let destDir = "\(conn.remotePath)/Screenshots" + do { + let transfer: any NASTransferProtocol = conn.nasProtocol == .smb ? SMBService() : SFTPService() + try await transfer.connect(to: conn.host, port: conn.port, + username: conn.username, password: conn.password) + if !(try await transfer.fileExists(at: destDir)) { + try await transfer.createDirectory(at: destDir) + } + for (i, filename) in filenames.enumerated() { + let src = "\(conn.remotePath)/\(filename)" + let dst = "\(destDir)/\(filename)" + if let data = try? await transfer.downloadData(at: src) { + try? await transfer.writeData(data, to: dst) + try? await transfer.deleteFile(at: src) + } + progress = (i + 1, filenames.count) + } + transfer.disconnect() + done = true + try? await Task.sleep(nanoseconds: 1_500_000_000) + dismiss() + } catch { + errorMessage = error.localizedDescription + isMoving = false + } + } +} diff --git a/Services/SFTPService.swift b/Services/SFTPService.swift index e4d6693..35aac50 100644 --- a/Services/SFTPService.swift +++ b/Services/SFTPService.swift @@ -100,6 +100,11 @@ final class SFTPService: NASTransferProtocol { } } + func deleteFile(at remotePath: String) async throws { + guard let sftp else { throw BackupError.connectionFailed("Not connected") } + try await sftp.remove(at: remotePath) + } + func upload(localURL: URL, remotePath: String, progress: @escaping (Int64, Int64) -> Void) async throws { guard let sftp else { throw BackupError.connectionFailed("Not connected") } let filename = localURL.lastPathComponent diff --git a/Services/SMBService.swift b/Services/SMBService.swift index ff1797c..9fcfb88 100644 --- a/Services/SMBService.swift +++ b/Services/SMBService.swift @@ -119,6 +119,13 @@ final class SMBService: NASTransferProtocol { } } + func deleteFile(at remotePath: String) async throws { + guard let client else { throw BackupError.connectionFailed("Not connected") } + let (share, rel) = splitPath(remotePath) + try await client.connectShare(share) + try await client.deleteFile(path: rel) + } + func upload(localURL: URL, remotePath: String, progress: @escaping (Int64, Int64) -> Void) async throws { guard let client else { throw BackupError.connectionFailed("Not connected") } let (share, rel) = splitPath(remotePath)