diff --git a/SnipSnaps.xcodeproj/project.pbxproj b/SnipSnaps.xcodeproj/project.pbxproj index 8f80c80..7ed6e82 100644 --- a/SnipSnaps.xcodeproj/project.pbxproj +++ b/SnipSnaps.xcodeproj/project.pbxproj @@ -280,6 +280,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = SnipSnaps/SnipSnaps.entitlements; + "CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = SnipSnaps/SnipSnaps-macOS.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 45; DEAD_CODE_STRIPPING = YES; @@ -287,9 +288,11 @@ ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; - ENABLE_USER_SELECTED_FILES = readonly; + ENABLE_USER_SELECTED_FILES = readwrite; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = SnipSnaps/Info.plist; + "INFOPLIST_KEY_LSApplicationCategoryType[sdk=macosx*]" = "public.app-category.utilities"; + "INFOPLIST_KEY_NSHumanReadableCopyright[sdk=macosx*]" = "Copyright © 2026 Kyter, LLC. All rights reserved."; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; @@ -307,7 +310,8 @@ PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; SDKROOT = auto; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + MACOSX_DEPLOYMENT_TARGET = 15.0; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -320,6 +324,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = SnipSnaps/SnipSnaps.entitlements; + "CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = SnipSnaps/SnipSnaps-macOS.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 45; DEAD_CODE_STRIPPING = YES; @@ -327,9 +332,11 @@ ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; - ENABLE_USER_SELECTED_FILES = readonly; + ENABLE_USER_SELECTED_FILES = readwrite; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = SnipSnaps/Info.plist; + "INFOPLIST_KEY_LSApplicationCategoryType[sdk=macosx*]" = "public.app-category.utilities"; + "INFOPLIST_KEY_NSHumanReadableCopyright[sdk=macosx*]" = "Copyright © 2026 Kyter, LLC. All rights reserved."; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; @@ -347,7 +354,8 @@ PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; SDKROOT = auto; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + MACOSX_DEPLOYMENT_TARGET = 15.0; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; diff --git a/SnipSnaps/Assets.xcassets/AppIcon.appiconset/Contents.json b/SnipSnaps/Assets.xcassets/AppIcon.appiconset/Contents.json index 3d184c9..853f14c 100644 --- a/SnipSnaps/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/SnipSnaps/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -31,51 +31,61 @@ "size" : "1024x1024" }, { + "filename" : "mac_16.png", "idiom" : "mac", "scale" : "1x", "size" : "16x16" }, { + "filename" : "mac_32.png", "idiom" : "mac", "scale" : "2x", "size" : "16x16" }, { + "filename" : "mac_32.png", "idiom" : "mac", "scale" : "1x", "size" : "32x32" }, { + "filename" : "mac_64.png", "idiom" : "mac", "scale" : "2x", "size" : "32x32" }, { + "filename" : "mac_128.png", "idiom" : "mac", "scale" : "1x", "size" : "128x128" }, { + "filename" : "mac_256.png", "idiom" : "mac", "scale" : "2x", "size" : "128x128" }, { + "filename" : "mac_256.png", "idiom" : "mac", "scale" : "1x", "size" : "256x256" }, { + "filename" : "mac_512.png", "idiom" : "mac", "scale" : "2x", "size" : "256x256" }, { + "filename" : "mac_512.png", "idiom" : "mac", "scale" : "1x", "size" : "512x512" }, { + "filename" : "mac_1024.png", "idiom" : "mac", "scale" : "2x", "size" : "512x512" diff --git a/SnipSnaps/Assets.xcassets/AppIcon.appiconset/mac_1024.png b/SnipSnaps/Assets.xcassets/AppIcon.appiconset/mac_1024.png new file mode 100644 index 0000000..e353543 Binary files /dev/null and b/SnipSnaps/Assets.xcassets/AppIcon.appiconset/mac_1024.png differ diff --git a/SnipSnaps/Assets.xcassets/AppIcon.appiconset/mac_128.png b/SnipSnaps/Assets.xcassets/AppIcon.appiconset/mac_128.png new file mode 100644 index 0000000..cf22bdd Binary files /dev/null and b/SnipSnaps/Assets.xcassets/AppIcon.appiconset/mac_128.png differ diff --git a/SnipSnaps/Assets.xcassets/AppIcon.appiconset/mac_16.png b/SnipSnaps/Assets.xcassets/AppIcon.appiconset/mac_16.png new file mode 100644 index 0000000..ea9f909 Binary files /dev/null and b/SnipSnaps/Assets.xcassets/AppIcon.appiconset/mac_16.png differ diff --git a/SnipSnaps/Assets.xcassets/AppIcon.appiconset/mac_256.png b/SnipSnaps/Assets.xcassets/AppIcon.appiconset/mac_256.png new file mode 100644 index 0000000..ddc5ed2 Binary files /dev/null and b/SnipSnaps/Assets.xcassets/AppIcon.appiconset/mac_256.png differ diff --git a/SnipSnaps/Assets.xcassets/AppIcon.appiconset/mac_32.png b/SnipSnaps/Assets.xcassets/AppIcon.appiconset/mac_32.png new file mode 100644 index 0000000..125530d Binary files /dev/null and b/SnipSnaps/Assets.xcassets/AppIcon.appiconset/mac_32.png differ diff --git a/SnipSnaps/Assets.xcassets/AppIcon.appiconset/mac_512.png b/SnipSnaps/Assets.xcassets/AppIcon.appiconset/mac_512.png new file mode 100644 index 0000000..244afe4 Binary files /dev/null and b/SnipSnaps/Assets.xcassets/AppIcon.appiconset/mac_512.png differ diff --git a/SnipSnaps/Assets.xcassets/AppIcon.appiconset/mac_64.png b/SnipSnaps/Assets.xcassets/AppIcon.appiconset/mac_64.png new file mode 100644 index 0000000..6f9a9c2 Binary files /dev/null and b/SnipSnaps/Assets.xcassets/AppIcon.appiconset/mac_64.png differ diff --git a/SnipSnaps/Commands/AppCommands.swift b/SnipSnaps/Commands/AppCommands.swift new file mode 100644 index 0000000..f41c52f --- /dev/null +++ b/SnipSnaps/Commands/AppCommands.swift @@ -0,0 +1,110 @@ +#if os(macOS) +import SwiftUI +import AppKit + +// Native macOS menu-bar commands. The Help menu is retargeted at SnipSnaps' real +// support/legal URLs (the default SwiftUI Help menu is empty). The Review menu +// and Edit ▸ Undo are wired to the active review surface via focused values. +struct AppCommands: Commands { + var body: some Commands { + // Edit ▸ Undo routed to whichever review owns the focus, so ⌘Z works + // regardless of which control is focused (and on the summary screen). + CommandGroup(replacing: .undoRedo) { + FocusedReviewUndoButton() + } + + // File ▸ Add Folder… for the Files surface. + CommandGroup(after: .newItem) { + FocusedAddFolderButton() + } + + CommandMenu("Review") { + FocusedReviewActionButtons() + } + + CommandGroup(replacing: .help) { + Button("SnipSnaps Support") { Self.open("https://kyter.com/snipsnaps/support/") } + Button("GitHub Repository") { Self.open("https://github.com/Kyter-com/SnipSnaps") } + Button("Send Feedback…") { Self.open("mailto:dev@kyter.com?subject=SnipSnaps%20App%20Feedback") } + Divider() + Button("Privacy Policy") { Self.open("https://kyter.com/snipsnaps/privacy/") } + Button("Terms & Conditions") { Self.open("https://kyter.com/snipsnaps/terms/") } + } + } + + static func open(_ string: String) { + if let url = URL(string: string) { + NSWorkspace.shared.open(url) + } + } +} + +// MARK: - Focused review bridge + +// A review screen publishes this so the menu bar can drive Keep / Delete / Undo / +// Skip on the currently-active review, with items auto-disabling elsewhere. +struct ReviewActions { + var keep: (() -> Void)? + var delete: (() -> Void)? + var undo: (() -> Void)? + var skipGroup: (() -> Void)? + var canUndo: Bool = false +} + +struct ReviewActionsFocusedValueKey: FocusedValueKey { + typealias Value = ReviewActions +} + +// Files surface publishes its "add folder" hook so File ▸ Add Folder… works. +struct AddFolderFocusedValueKey: FocusedValueKey { + typealias Value = () -> Void +} + +extension FocusedValues { + var reviewActions: ReviewActions? { + get { self[ReviewActionsFocusedValueKey.self] } + set { self[ReviewActionsFocusedValueKey.self] = newValue } + } + + var addFolderAction: (() -> Void)? { + get { self[AddFolderFocusedValueKey.self] } + set { self[AddFolderFocusedValueKey.self] = newValue } + } +} + +private struct FocusedReviewUndoButton: View { + @FocusedValue(\.reviewActions) private var actions + + var body: some View { + Button("Undo") { actions?.undo?() } + .keyboardShortcut("z", modifiers: .command) + .disabled(!(actions?.canUndo ?? false)) + } +} + +private struct FocusedAddFolderButton: View { + @FocusedValue(\.addFolderAction) private var addFolder + + var body: some View { + Button("Add Folder…") { addFolder?() } + .keyboardShortcut("o", modifiers: [.command, .shift]) + .disabled(addFolder == nil) + } +} + +// Click-only menu items (discoverability + VoiceOver). The matching ←/→/Delete/s +// keys are owned by in-view buttons on the active review, so these intentionally +// carry NO keyboardShortcut to avoid a double-owned key equivalent. +private struct FocusedReviewActionButtons: View { + @FocusedValue(\.reviewActions) private var actions + + var body: some View { + Button("Keep →") { actions?.keep?() } + .disabled(actions?.keep == nil) + Button("Move to Trash ←") { actions?.delete?() } + .disabled(actions?.delete == nil) + Button("Skip Group S") { actions?.skipGroup?() } + .disabled(actions?.skipGroup == nil) + } +} +#endif diff --git a/SnipSnaps/ContentView.swift b/SnipSnaps/ContentView.swift index 6481beb..d1a2b93 100644 --- a/SnipSnaps/ContentView.swift +++ b/SnipSnaps/ContentView.swift @@ -8,20 +8,34 @@ import SwiftUI struct ContentView: View { - @State private var selectedTab = 0 - var body: some View { #if DEBUG if let screenshotScreen = ProcessInfo.processInfo.environment["SNIPSNAPS_SCREENSHOT_SCREEN"] { ScreenshotDemoView(screen: screenshotScreen) } else { - tabView + shell } #else + shell + #endif + } + + @ViewBuilder + private var shell: some View { + #if os(macOS) + // Mac uses a native sidebar instead of the iOS bottom tab bar; Settings lives + // in the ⌘, Settings scene (see SnipSnapsApp), so it's not a sidebar row. + MacSidebarShell() + .tint(AppColor.primary) + .frame(minWidth: 720, minHeight: 480) + #else tabView #endif } + #if os(iOS) + @State private var selectedTab = 0 + private var tabView: some View { TabView(selection: $selectedTab) { HomeView() @@ -35,10 +49,73 @@ struct ContentView: View { } .tag(1) } - .accentColor(AppColor.primary) - .onChange(of: selectedTab) { - let generator = UIImpactFeedbackGenerator(style: .light) - generator.impactOccurred() + .tint(AppColor.primary) + .onChange(of: selectedTab) { + let generator = UIImpactFeedbackGenerator(style: .light) + generator.impactOccurred() + } + } + #endif +} + +#if os(macOS) +enum SidebarItem: String, Hashable, CaseIterable, Identifiable { + case photos + case files + + var id: String { rawValue } + + var title: String { + switch self { + case .photos: return "Photos" + case .files: return "Files" + } + } + + var systemImage: String { + switch self { + case .photos: return "photo.on.rectangle.angled" + case .files: return "folder" + } + } +} + +private struct MacSidebarShell: View { + // Persist the selection across launches; never let it resolve to nil so the + // detail pane is always populated. + @SceneStorage("sidebarSelection") private var selectionRaw: String = SidebarItem.photos.rawValue + + private var selection: SidebarItem { + SidebarItem(rawValue: selectionRaw) ?? .photos + } + + private var selectionBinding: Binding { + Binding( + get: { selection }, + set: { newValue in + if let newValue { selectionRaw = newValue.rawValue } } + ) + } + + var body: some View { + NavigationSplitView { + List(selection: selectionBinding) { + Section("Clean Up") { + ForEach(SidebarItem.allCases) { item in + Label(item.title, systemImage: item.systemImage) + .tag(item) + } + } + } + .navigationSplitViewColumnWidth(min: 188, ideal: 210, max: 280) + .navigationTitle("SnipSnaps") + } detail: { + switch selection { + case .photos: HomeView() + case .files: FilesView() + } + } } } +#endif diff --git a/SnipSnaps/Design/AppColors.swift b/SnipSnaps/Design/AppColors.swift index 8e95c6b..d7f8a50 100644 --- a/SnipSnaps/Design/AppColors.swift +++ b/SnipSnaps/Design/AppColors.swift @@ -1,16 +1,53 @@ import SwiftUI +#if canImport(UIKit) +import UIKit +#elseif canImport(AppKit) +import AppKit +#endif + +// Cross-platform semantic colors. The label/fill/background families differ +// between UIKit and AppKit (e.g. there is no NSColor.systemGroupedBackground), +// so the platform-specific values are split below. The vivid system accents +// (systemRed/Pink/Green) and SwiftUI's hierarchical primary/secondary resolve +// on both platforms, so they stay shared. enum AppColor { static let primary = Color.accentColor - static let background = Color(.systemGroupedBackground) - static let card = Color(.secondarySystemGroupedBackground) - static let text = Color(.label) - static let subtext = Color(.secondaryLabel) - static let separator = Color(.separator) + static let text = Color.primary + static let subtext = Color.secondary static let shadow = Color.black.opacity(0.05) static let delete = Color(.systemRed) static let keep = Color(.systemPink) static let success = Color(.systemGreen) static let deleteBackground = Color(.systemRed).opacity(0.16) static let keepBackground = Color(.systemGreen).opacity(0.16) + + // Hairline edge for raised cards. iOS keeps the specular-white highlight; macOS + // uses an adaptive separator so the edge is visible in both Light and Dark. + #if canImport(UIKit) + static let cardEdge = Color.white.opacity(0.28) + #elseif canImport(AppKit) + static let cardEdge = Color(nsColor: .separatorColor) + #endif + + #if canImport(UIKit) + static let background = Color(.systemGroupedBackground) + static let card = Color(.secondarySystemGroupedBackground) + static let chip = Color(.tertiarySystemGroupedBackground) + static let elevatedCard = Color(.secondarySystemBackground) + static let fill = Color(.tertiarySystemFill) + static let separator = Color(.separator) + #elseif canImport(AppKit) + // macOS card hierarchy: the window/scroll surface must read as RECESSED and the + // cards as RAISED, or both collapse to the same light gray and every card + // becomes invisible in Light mode. underPageBackgroundColor is the recessed + // backdrop; controlBackgroundColor / textBackgroundColor are the raised (near + // white) card surfaces. + static let background = Color(nsColor: .underPageBackgroundColor) + static let card = Color(nsColor: .controlBackgroundColor) + static let chip = Color(nsColor: .unemphasizedSelectedContentBackgroundColor) + static let elevatedCard = Color(nsColor: .textBackgroundColor) + static let fill = Color(nsColor: .quaternaryLabelColor) + static let separator = Color(nsColor: .separatorColor) + #endif } diff --git a/SnipSnaps/Design/GlassStyle.swift b/SnipSnaps/Design/GlassStyle.swift new file mode 100644 index 0000000..feae37e --- /dev/null +++ b/SnipSnaps/Design/GlassStyle.swift @@ -0,0 +1,87 @@ +import SwiftUI + +// Liquid Glass styling helpers. On macOS 26 (Tahoe) these use the real Liquid Glass +// APIs; on macOS 15/16 and on iOS they fall back to the app's existing bordered / +// material look. The macOS floor is 15.0, so the glass calls are availability-gated. +extension View { + // Primary call-to-action button. + @ViewBuilder + func prominentActionButton() -> some View { + #if os(macOS) + if #available(macOS 26, *) { + buttonStyle(.glassProminent) + } else { + buttonStyle(.borderedProminent) + } + #else + buttonStyle(.borderedProminent) + #endif + } + + // Secondary / neutral button. + @ViewBuilder + func secondaryActionButton() -> some View { + #if os(macOS) + if #available(macOS 26, *) { + buttonStyle(.glass) + } else { + buttonStyle(.bordered) + } + #else + buttonStyle(.bordered) + #endif + } + + // Subtle hover lift + pointer cursor for clickable cards on macOS (no-op on iOS). + @ViewBuilder + func interactiveCardHover() -> some View { + #if os(macOS) + modifier(MacHoverHighlight()) + #else + self + #endif + } + + // Background for a small floating info chip (e.g. the review date/size pill). + @ViewBuilder + func infoChipBackground(cornerRadius: CGFloat = 14) -> some View { + #if os(macOS) + if #available(macOS 26, *) { + glassEffect(.regular, in: .rect(cornerRadius: cornerRadius)) + } else { + materialChipBackground(cornerRadius: cornerRadius) + } + #else + materialChipBackground(cornerRadius: cornerRadius) + #endif + } + + // Pre-Liquid-Glass (macOS 15/16) and iOS chip background: a translucent material card. + @ViewBuilder + fileprivate func materialChipBackground(cornerRadius: CGFloat) -> some View { + background { + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .fill(.ultraThinMaterial) + .overlay { + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .fill(AppColor.card.opacity(0.72)) + } + } + } +} + +#if os(macOS) +private struct MacHoverHighlight: ViewModifier { + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var hovering = false + + func body(content: Content) -> some View { + content + // Honor Reduce Motion: keep the pointer/link affordance but drop the lift. + .scaleEffect(reduceMotion ? 1.0 : (hovering ? 1.012 : 1.0)) + .animation(reduceMotion ? nil : .easeOut(duration: 0.12), value: hovering) + .onHover { hovering = $0 } + .pointerStyle(.link) + } +} +#endif diff --git a/SnipSnaps/Files/FileFolderStore.swift b/SnipSnaps/Files/FileFolderStore.swift new file mode 100644 index 0000000..a82289c --- /dev/null +++ b/SnipSnaps/Files/FileFolderStore.swift @@ -0,0 +1,169 @@ +#if os(macOS) +import Foundation +import SwiftUI + +// Persists the folders the user has granted access to as app-scoped +// security-scoped bookmarks (com.apple.security.files.bookmarks.app-scope), and +// keeps that access open for the app session so the scan/trash engine can read +// and write inside them. Sandbox rule: the user must pick each folder via the +// system panel — there is no Desktop/Documents entitlement for silent scanning. +@MainActor +final class FileFolderStore: ObservableObject { + struct Folder: Identifiable, Hashable { + let url: URL + var id: URL { url } + var name: String { url.lastPathComponent } + } + + @Published private(set) var folders: [Folder] = [] + // Folders that resolved but whose security scope could not be opened this launch + // (the user sees them but scans find nothing — surfaced as "couldn't access"). + @Published private(set) var inaccessiblePaths: Set = [] + + private let defaultsKey = "filesGrantedFolderBookmarksV1" + private var accessing: [URL] = [] + // Maps a resolved folder path → the exact stored bookmark bytes, so remove() can + // drop precisely that bookmark (even if it later stops resolving) without + // disturbing other — possibly transiently-offline — grants. + private var bookmarkByPath: [String: Data] = [:] + + init() { + restore() + } + + // Balance the security scopes opened in restore() if the store is ever torn down + // mid-process (deinit is nonisolated; stopAccessingSecurityScopedResource is + // thread-safe and `accessing` is a plain value array). + deinit { + for url in accessing { + url.stopAccessingSecurityScopedResource() + } + } + + // The URL comes from NSOpenPanel (or a Finder drag) and is already accessible; + // capture a durable app-scoped bookmark so the grant survives relaunch. + func add(_ url: URL) { + // Already granted AND working — nothing to do. But if the path is listed yet + // inaccessible (stale bookmark), fall through to re-grant with the fresh one. + if folders.contains(where: { $0.url.path == url.path }), !inaccessiblePaths.contains(url.path) { + return + } + do { + let data = try url.bookmarkData( + options: .withSecurityScope, + includingResourceValuesForKeys: nil, + relativeTo: nil + ) + var stored = storedBookmarks() + // Re-granting a stale/inaccessible folder: drop its old bookmark first so + // the fresh, working one replaces it instead of being deduped away. + if let old = bookmarkByPath[url.path] { + stored.removeAll { $0 == old } + } + stored.append(data) + save(stored) + restore() + } catch { + NSLog("SnipSnaps: failed to bookmark folder \(url.path): \(error)") + } + } + + func remove(_ folder: Folder) { + if let targetData = bookmarkByPath[folder.url.path] { + // Drop exactly the targeted bookmark; keep every other stored bookmark + // (including any that are transiently offline) untouched. + save(storedBookmarks().filter { $0 != targetData }) + } else { + // Fallback: no stored mapping (shouldn't happen for a listed folder) — drop + // any bookmark that currently resolves to this path. + save(storedBookmarks().filter { resolve($0)?.url.path != folder.url.path }) + } + restore() + } + + // MARK: - Private + + private func restore() { + // Diff against the scopes already open rather than blanket stop-all/start-all: + // re-opening every folder on each add()/remove() would momentarily revoke access + // to retained folders, which a background scan/trash task may still be using. + let previouslyAccessing = accessing + var stillAccessing: [URL] = [] + + var resolved: [Folder] = [] + var refreshed: [Data] = [] + var byPath: [String: Data] = [:] + var inaccessible: Set = [] + for data in storedBookmarks() { + guard let entry = resolve(data) else { + // Transient failure (e.g. an external/network volume that's offline right + // now). Keep the bookmark so the grant survives relaunch; just don't list + // the folder this session. Only an explicit remove() drops it. + refreshed.append(data) + continue + } + if resolved.contains(where: { $0.url.path == entry.url.path }) { continue } + let freshData = entry.refreshedData ?? data + // Reuse the already-open scope (and its URL instance) for a retained folder so + // its access is never interrupted; only newly-granted folders open a scope. + let folderURL: URL + if let open = previouslyAccessing.first(where: { $0.path == entry.url.path }) { + stillAccessing.append(open) + folderURL = open + } else if entry.url.startAccessingSecurityScopedResource() { + stillAccessing.append(entry.url) + folderURL = entry.url + } else { + inaccessible.insert(entry.url.path) + folderURL = entry.url + } + resolved.append(Folder(url: folderURL)) + refreshed.append(freshData) + byPath[folderURL.path] = freshData + } + + // Release only the scopes for folders that are no longer granted. + let retainedPaths = Set(stillAccessing.map(\.path)) + for url in previouslyAccessing where !retainedPaths.contains(url.path) { + url.stopAccessingSecurityScopedResource() + } + + accessing = stillAccessing + folders = resolved + bookmarkByPath = byPath + inaccessiblePaths = inaccessible + save(refreshed) + } + + private func resolve(_ data: Data) -> (url: URL, refreshedData: Data?)? { + var stale = false + guard let url = try? URL( + resolvingBookmarkData: data, + options: .withSecurityScope, + relativeTo: nil, + bookmarkDataIsStale: &stale + ) else { + return nil + } + guard stale else { return (url, nil) } + let fresh = try? url.bookmarkData( + options: .withSecurityScope, + includingResourceValuesForKeys: nil, + relativeTo: nil + ) + return (url, fresh) + } + + private func storedBookmarks() -> [Data] { + UserDefaults.standard.array(forKey: defaultsKey) as? [Data] ?? [] + } + + private func save(_ bookmarks: [Data]) { + if bookmarks.isEmpty { + UserDefaults.standard.removeObject(forKey: defaultsKey) + } else { + UserDefaults.standard.set(bookmarks, forKey: defaultsKey) + } + } +} +#endif diff --git a/SnipSnaps/Files/FileLibrary.swift b/SnipSnaps/Files/FileLibrary.swift new file mode 100644 index 0000000..f483a7c --- /dev/null +++ b/SnipSnaps/Files/FileLibrary.swift @@ -0,0 +1,264 @@ +#if os(macOS) +import Foundation +import UniformTypeIdentifiers +import CryptoKit + +// On-disk analog of PhotoLibrary: enumerates the user-granted folders, buckets +// files into review categories, and moves confirmed files to the Trash +// (recoverable) — never a permanent delete. All work is value-type based so it +// runs off the main actor. +enum FileLibrary { + static let largeFileMinimumBytes: Int64 = 50 * 1024 * 1024 + static let oldFileAgeDays = 180 + // Cap enumeration so a huge folder tree can't stall a scan/count indefinitely. + // When the cap is hit, callers are told (truncated) so they don't mistake a + // partial scan for an empty result. + static let maxFilesExamined = 200_000 + + // Outcome of a scan: the ranked items plus whether the file cap was reached. + struct ScanResult: Sendable { + let items: [FileItem] + let truncated: Bool + + static let empty = ScanResult(items: [], truncated: false) + } + + private static let resourceKeys: Set = [ + .fileSizeKey, .totalFileAllocatedSizeKey, .contentModificationDateKey, .creationDateKey, + .isRegularFileKey, .isDirectoryKey, .isSymbolicLinkKey, + .isPackageKey, .contentTypeKey, + .isUbiquitousItemKey, .ubiquitousItemDownloadingStatusKey + ] + + static func scan(folders: [URL], category: FileReviewCategory, limit: Int, excluding reviewed: Set = [], sort: FileSortOption = .largest) -> ScanResult { + if category == .duplicates { + return duplicateRedundantCopies(folders: folders, excluding: reviewed, limit: limit, sort: sort) + } + + let oldCutoff = Calendar.current.date(byAdding: .day, value: -oldFileAgeDays, to: Date()) ?? .distantPast + var items: [FileItem] = [] + var seen = Set() + var examined = 0 + var truncated = false + + folderLoop: for folder in folders { + guard let enumerator = enumerator(for: folder) else { continue } + for case let url as URL in enumerator { + if Task.isCancelled { return .empty } + // Count every enumerated entry (files, directories, skipped placeholders) + // so a directory-heavy or all-iCloud tree still trips the cap instead of + // looping for a very long time without ever reaching it. + if examined >= maxFilesExamined { truncated = true; break folderLoop } + examined += 1 + guard let item = makeItem(url) else { continue } + guard seen.insert(dedupKey(item.url)).inserted else { continue } + if matches(item, category: category, oldCutoff: oldCutoff), !reviewed.contains(item.url.path) { + items.append(item) + } + } + } + + return ScanResult(items: Array(sortItems(items, by: sort).prefix(limit)), truncated: truncated) + } + + // Single-pass tally for the category cards: total and not-yet-reviewed per + // category (duplicates is scan-on-demand, so it is omitted here). + static func counts(folders: [URL], reviewedPaths reviewed: Set) -> [FileReviewCategory: FileCounts] { + let oldCutoff = Calendar.current.date(byAdding: .day, value: -oldFileAgeDays, to: Date()) ?? .distantPast + var total: [FileReviewCategory: Int] = [:] + var fresh: [FileReviewCategory: Int] = [:] + var seen = Set() + var examined = 0 + + folderLoop: for folder in folders { + guard let enumerator = enumerator(for: folder) else { continue } + for case let url as URL in enumerator { + if Task.isCancelled { break folderLoop } + // Cap is global across all granted folders, not per-folder, so a labeled + // break is required (a bare break would only end this folder's loop). Count + // every enumerated entry so a directory-heavy tree still trips the cap. + if examined >= maxFilesExamined { break folderLoop } + examined += 1 + guard let item = makeItem(url) else { continue } + // Skip files already tallied via an overlapping grant so the cards don't + // double-count the same physical file. + guard seen.insert(dedupKey(item.url)).inserted else { continue } + let notReviewed = !reviewed.contains(item.url.path) + func bump(_ category: FileReviewCategory) { + total[category, default: 0] += 1 + if notReviewed { fresh[category, default: 0] += 1 } + } + bump(.everything) + if item.size >= largeFileMinimumBytes { bump(.large) } + if item.modified < oldCutoff { bump(.old) } + if item.isScreenshot { bump(.screenshots) } + } + } + + var result: [FileReviewCategory: FileCounts] = [:] + for category in [FileReviewCategory.everything, .large, .old, .screenshots] { + result[category] = FileCounts(total: total[category] ?? 0, notReviewed: fresh[category] ?? 0) + } + return result + } + + // Exact-content duplicates: bucket by size, hash only collision buckets, then + // surface the redundant copies (every copy except the oldest "original" in + // each identical group). Unique file sizes never get hashed. + static func duplicateRedundantCopies(folders: [URL], excluding reviewed: Set, limit: Int, sort: FileSortOption = .largest) -> ScanResult { + var bySize: [Int64: [FileItem]] = [:] + var seen = Set() + var examined = 0 + var truncated = false + folderLoop: for folder in folders { + guard let enumerator = enumerator(for: folder) else { continue } + for case let url as URL in enumerator { + if Task.isCancelled { return .empty } + if examined >= maxFilesExamined { truncated = true; break folderLoop } + examined += 1 + // Bucket by logical byte length, not allocated size: identical files on + // volumes with different block sizes share a logical size but not an + // allocated one, and must still be compared. + guard let item = makeItem(url), item.logicalSize > 0 else { continue } + // Dedup by real path BEFORE bucketing: a file reachable through two + // overlapping grants would otherwise hash-match itself and be surfaced as a + // redundant copy — trashing it would delete the only copy of a unique file. + guard seen.insert(dedupKey(item.url)).inserted else { continue } + bySize[item.logicalSize, default: []].append(item) + } + } + + var redundant: [FileItem] = [] + for (_, sameSize) in bySize where sameSize.count > 1 { + if Task.isCancelled { return .empty } + var byHash: [String: [FileItem]] = [:] + for item in sameSize { + guard let hash = contentHash(item.url) else { continue } + byHash[hash, default: []].append(item) + } + for (_, identical) in byHash where identical.count > 1 { + let ordered = identical.sorted { $0.created < $1.created } + for copy in ordered.dropFirst() where !reviewed.contains(copy.url.path) { + redundant.append(copy) + } + } + } + return ScanResult(items: Array(sortItems(redundant, by: sort).prefix(limit)), truncated: truncated) + } + + private static func contentHash(_ url: URL) -> String? { + guard let handle = try? FileHandle(forReadingFrom: url) else { return nil } + defer { try? handle.close() } + var hasher = SHA256() + // Read explicitly (not `try?`) so a mid-file read error returns nil rather than + // hashing partial content — a partial hash could collide falsely. + do { + while let chunk = try handle.read(upToCount: 1 << 20), !chunk.isEmpty { + if Task.isCancelled { return nil } + hasher.update(data: chunk) + } + } catch { + return nil + } + return hasher.finalize().map { String(format: "%02x", $0) }.joined() + } + + struct TrashResult: Sendable { + let trashed: Int + let freedBytes: Int64 + let failed: [String] + } + + static func moveToTrash(_ items: [FileItem]) -> TrashResult { + let fm = FileManager.default + var trashed = 0 + var freed: Int64 = 0 + var failed: [String] = [] + for item in items { + var resultingURL: NSURL? + do { + try fm.trashItem(at: item.url, resultingItemURL: &resultingURL) + trashed += 1 + freed += item.size + } catch { + failed.append(item.name) + } + } + return TrashResult(trashed: trashed, freedBytes: freed, failed: failed) + } + + // MARK: - Private + + private static func enumerator(for folder: URL) -> FileManager.DirectoryEnumerator? { + FileManager.default.enumerator( + at: folder, + includingPropertiesForKeys: Array(resourceKeys), + options: [.skipsHiddenFiles, .skipsPackageDescendants] + ) + } + + // Stable per-pass identity for a file. Standardizing collapses the path so a file + // reachable through overlapping grants (e.g. both ~/Downloads and + // ~/Downloads/installers are granted) resolves to one key — otherwise the same + // physical file is enumerated twice, inflating counts and, worse, colliding with + // itself in duplicate detection so its only copy gets offered for trashing. + private static func dedupKey(_ url: URL) -> String { + url.standardizedFileURL.path + } + + private static func makeItem(_ url: URL) -> FileItem? { + guard let rv = try? url.resourceValues(forKeys: resourceKeys) else { return nil } + // Only loose regular files: skip directories, symlinks, and packages (.app etc). + guard rv.isRegularFile == true, + rv.isDirectory != true, + rv.isSymbolicLink != true, + rv.isPackage != true else { + return nil + } + // Skip iCloud placeholders that aren't downloaded locally. Enumerating, hashing, + // or trashing them would force a (potentially multi-GB) download, and they use + // ~no local disk so they don't belong in a "free up space" review. + if rv.isUbiquitousItem == true, rv.ubiquitousItemDownloadingStatus == .notDownloaded { + return nil + } + let modified = rv.contentModificationDate ?? .distantPast + // Prefer the on-disk allocated size so "Large Files" / "space freed" reflect + // disk actually reclaimed; fall back to the logical size. + let onDiskSize = rv.totalFileAllocatedSize ?? rv.fileSize ?? 0 + let logical = Int64(rv.fileSize ?? onDiskSize) + return FileItem( + url: url, + size: Int64(onDiskSize), + logicalSize: logical, + modified: modified, + created: rv.creationDate ?? modified, + contentType: rv.contentType + ) + } + + private static func matches(_ item: FileItem, category: FileReviewCategory, oldCutoff: Date) -> Bool { + switch category { + case .everything: return true + case .large: return item.size >= largeFileMinimumBytes + case .old: return item.modified < oldCutoff + case .screenshots: return item.isScreenshot + case .duplicates: return false // handled separately via duplicateRedundantCopies + } + } + + private static func sortItems(_ items: [FileItem], by option: FileSortOption) -> [FileItem] { + switch option { + case .largest: + return items.sorted { $0.size > $1.size } + case .smallest: + return items.sorted { $0.size < $1.size } + case .recent: + return items.sorted { $0.modified > $1.modified } + case .oldest: + return items.sorted { $0.modified < $1.modified } + case .name: + return items.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } + } + } +} +#endif diff --git a/SnipSnaps/Files/FileModels.swift b/SnipSnaps/Files/FileModels.swift new file mode 100644 index 0000000..7c72bc2 --- /dev/null +++ b/SnipSnaps/Files/FileModels.swift @@ -0,0 +1,141 @@ +#if os(macOS) +import Foundation +import UniformTypeIdentifiers + +enum FileDecision { + case keep + case delete +} + +// One loose on-disk file surfaced for review. Value type built from URL resource +// values so it is Sendable and can be scanned off the main actor. +struct FileItem: Identifiable, Hashable, Sendable { + let url: URL + let size: Int64 // on-disk allocated size — drives "Large Files" + "space freed" + let logicalSize: Int64 // logical byte length — used for duplicate size-bucketing + let modified: Date + let created: Date + let contentType: UTType? + + var id: URL { url } + var name: String { url.lastPathComponent } + var parentPath: String { url.deletingLastPathComponent().path } + + var sizeText: String { + ByteCountFormatter.string(fromByteCount: size, countStyle: .file) + } + + var isImage: Bool { + contentType?.conforms(to: .image) ?? false + } + + // macOS screenshots land on disk as images named "Screenshot …" / "Screen Shot …". + // Filenames can be localized or renamed, so this is a heuristic the user confirms. + // Matched against the common localized stems so non-English Macs aren't blank. + var isScreenshot: Bool { + guard isImage else { return false } + let lower = name.lowercased() + return Self.screenshotStems.contains { lower.contains($0) } + } + + private static let screenshotStems: [String] = [ + "screenshot", // English (also matches "screen shot" lacks space; handled below) + "screen shot", // English (older) + "bildschirmfoto", // German + "capture d'écran", // French (straight quote) + "capture d’écran", // French (typographic quote) + "captura de pantalla", // Spanish + "captura de tela", // Portuguese + "schermata", // Italian + "schermafbeelding", // Dutch + "снимок экрана", // Russian + "スクリーンショット", // Japanese + "스크린샷", // Korean + "截屏", // Chinese (Simplified) + "螢幕快照" // Chinese (Traditional) + ] +} + +// Total vs not-yet-reviewed tally for a category, mirroring the Photos +// ReviewModeCounts so the Files cards can show "X not reviewed · Y total". +struct FileCounts: Sendable { + let total: Int + let notReviewed: Int +} + +// User-selectable ordering for a Files review session, the on-disk parallel to +// the Photos sort options. +enum FileSortOption: String, CaseIterable, Identifiable, Sendable { + case largest + case smallest + case recent + case oldest + case name + + var id: String { rawValue } + + var title: String { + switch self { + case .largest: return "Largest" + case .smallest: return "Smallest" + case .recent: return "Recent" + case .oldest: return "Oldest" + case .name: return "Name" + } + } + + var systemImage: String { + switch self { + case .largest: return "arrow.down.left.and.arrow.up.right" + case .smallest: return "arrow.up.right.and.arrow.down.left" + case .recent: return "clock.arrow.circlepath" + case .oldest: return "calendar" + case .name: return "textformat" + } + } +} + +enum FileReviewCategory: String, CaseIterable, Identifiable, Sendable { + case everything + case large + case old + case screenshots + case duplicates + + var id: String { rawValue } + + // Like Photos' "Similar", duplicate detection hashes file contents, so it is a + // scan-on-demand surface rather than a precomputed count on the cards. + var showsScanAction: Bool { self == .duplicates } + + var title: String { + switch self { + case .everything: return "Everything" + case .large: return "Large Files" + case .old: return "Old Files" + case .screenshots: return "Screenshots" + case .duplicates: return "Duplicates" + } + } + + var subtitle: String { + switch self { + case .everything: return "Every file in your folders" + case .large: return "Files 50 MB and up" + case .old: return "Untouched for 6+ months" + case .screenshots: return "Screenshots saved to disk" + case .duplicates: return "Identical copies of a file" + } + } + + var systemImage: String { + switch self { + case .everything: return "folder" + case .large: return "internaldrive" + case .old: return "clock.badge.xmark" + case .screenshots: return "rectangle.on.rectangle" + case .duplicates: return "square.on.square" + } + } +} +#endif diff --git a/SnipSnaps/Files/FileReviewHistory.swift b/SnipSnaps/Files/FileReviewHistory.swift new file mode 100644 index 0000000..7378dfe --- /dev/null +++ b/SnipSnaps/Files/FileReviewHistory.swift @@ -0,0 +1,96 @@ +#if os(macOS) +import Foundation + +// On-disk analog of PhotoReviewHistory: remembers which files the user has +// already reviewed so the shared "Remember Reviewed" setting skips them on later +// scans, using the same ReviewMemoryOption windows as the Photos surface. +// Keyed on file path — a moved/renamed file is treated as new, which is the +// pragmatic stable key for loose files. +enum FileReviewHistory { + private static let storeKey = "fileReviewedPaths" + private static let maxEntries = 20_000 + private static let maxPersistentAge: TimeInterval = 5 * 365 * 24 * 60 * 60 + private static let sessionLock = NSLock() + nonisolated(unsafe) private static var sessionPaths: Set = [] + + static func reviewedPaths(memoryOption: ReviewMemoryOption) -> Set { + guard memoryOption != .never else { return [] } + if memoryOption == .session { + sessionLock.lock() + defer { sessionLock.unlock() } + return sessionPaths + } + return Set(filteredEntries(memoryOption: memoryOption).keys) + } + + static func markReviewed(_ path: String, memoryOption: ReviewMemoryOption) { + guard memoryOption != .never else { return } + if memoryOption == .session { + sessionLock.lock() + sessionPaths.insert(path) + while sessionPaths.count > maxEntries, let excess = sessionPaths.first { + sessionPaths.remove(excess) + } + sessionLock.unlock() + return + } + var entries = persistentEntries() + entries[path] = Date().timeIntervalSince1970 + store(entries) + } + + static func unmarkReviewed(_ path: String, memoryOption: ReviewMemoryOption) { + guard memoryOption != .never else { return } + if memoryOption == .session { + sessionLock.lock() + sessionPaths.remove(path) + sessionLock.unlock() + return + } + var entries = persistentEntries() + entries.removeValue(forKey: path) + store(entries) + } + + static func clearAll() { + sessionLock.lock() + sessionPaths.removeAll() + sessionLock.unlock() + UserDefaults.standard.removeObject(forKey: storeKey) + } + + static func compact() { + store(persistentEntries()) + } + + // MARK: - Private + + private static func filteredEntries(memoryOption: ReviewMemoryOption) -> [String: TimeInterval] { + let entries = persistentEntries() + guard let interval = memoryOption.expirationInterval, interval > 0 else { return entries } + let cutoff = Date().timeIntervalSince1970 - interval + return entries.filter { $0.value >= cutoff } + } + + private static func persistentEntries() -> [String: TimeInterval] { + guard let dictionary = UserDefaults.standard.dictionary(forKey: storeKey) else { return [:] } + return dictionary.compactMapValues { value in + if let number = value as? NSNumber { return number.doubleValue } + return value as? TimeInterval + } + } + + private static func store(_ entries: [String: TimeInterval]) { + let cutoff = Date().timeIntervalSince1970 - maxPersistentAge + let limited = entries + .filter { $0.value >= cutoff } + .sorted { $0.value > $1.value } + .prefix(maxEntries) + guard !limited.isEmpty else { + UserDefaults.standard.removeObject(forKey: storeKey) + return + } + UserDefaults.standard.set(Dictionary(uniqueKeysWithValues: limited.map { ($0.key, $0.value) }), forKey: storeKey) + } +} +#endif diff --git a/SnipSnaps/Files/FinderActions.swift b/SnipSnaps/Files/FinderActions.swift new file mode 100644 index 0000000..66b7865 --- /dev/null +++ b/SnipSnaps/Files/FinderActions.swift @@ -0,0 +1,27 @@ +#if os(macOS) +import AppKit + +// Thin wrappers over NSWorkspace for the desktop-native file affordances on the +// Files surface. The granted folders' security scopes are already held open by +// FileFolderStore, so these operate on URLs the app can see. +enum FinderActions { + // Reveal & select the file in a Finder window. + static func revealInFinder(_ url: URL) { + NSWorkspace.shared.activateFileViewerSelecting([url]) + } + + // Open the file in its default application (or the folder in Finder). + static func open(_ url: URL) { + NSWorkspace.shared.open(url) + } + + // Open the user's Trash in Finder so they can confirm / restore what was moved. + static func openTrash() { + if let trash = try? FileManager.default.url( + for: .trashDirectory, in: .userDomainMask, appropriateFor: nil, create: false + ) { + NSWorkspace.shared.open(trash) + } + } +} +#endif diff --git a/SnipSnaps/Info.plist b/SnipSnaps/Info.plist index b1fbc45..32cd71a 100644 --- a/SnipSnaps/Info.plist +++ b/SnipSnaps/Info.plist @@ -6,5 +6,11 @@ NSPhotoLibraryUsageDescription SnipSnaps needs access to your photo library so you can review your photos and pick which ones to delete. For example, you swipe through your recent photos or screenshots, mark the ones you do not want, and SnipSnaps deletes only the ones you confirm. + NSDesktopFolderUsageDescription + SnipSnaps reviews files in folders you choose so you can clean up clutter. It only ever moves the files you confirm to the Trash. + NSDocumentsFolderUsageDescription + SnipSnaps reviews files in folders you choose so you can clean up clutter. It only ever moves the files you confirm to the Trash. + NSDownloadsFolderUsageDescription + SnipSnaps reviews files in folders you choose so you can clean up clutter. It only ever moves the files you confirm to the Trash. diff --git a/SnipSnaps/SnipSnaps-macOS.entitlements b/SnipSnaps/SnipSnaps-macOS.entitlements new file mode 100644 index 0000000..618ffad --- /dev/null +++ b/SnipSnaps/SnipSnaps-macOS.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.personal-information.photos-library + + com.apple.security.files.user-selected.read-write + + com.apple.security.files.bookmarks.app-scope + + + diff --git a/SnipSnaps/SnipSnapsApp.swift b/SnipSnaps/SnipSnapsApp.swift index 339854e..dd68ca4 100644 --- a/SnipSnaps/SnipSnapsApp.swift +++ b/SnipSnaps/SnipSnapsApp.swift @@ -13,6 +13,9 @@ struct SnipSnapsApp: App { init() { PhotoReviewHistory.compactStoredHistory() + #if os(macOS) + FileReviewHistory.compact() + #endif } var body: some Scene { @@ -32,5 +35,20 @@ struct SnipSnapsApp: App { } } } + #if os(macOS) + .defaultSize(width: 1000, height: 720) + .windowResizability(.contentMinSize) + .commands { + AppCommands() + } + #endif + + // Native Settings window, opened by the standard ⌘, menu item. + #if os(macOS) + Settings { + SettingsView() + .frame(width: 480, height: 560) + } + #endif } } diff --git a/SnipSnaps/Utils/Photos.swift b/SnipSnaps/Utils/Photos.swift index f02b108..345313d 100644 --- a/SnipSnaps/Utils/Photos.swift +++ b/SnipSnaps/Utils/Photos.swift @@ -512,7 +512,6 @@ enum PhotoLibrary { progressHandler: ((SimilarPhotoScanProgress) async -> Void)? = nil, partialGroupsHandler: (([SimilarPhotoGroup]) async -> Void)? = nil ) async -> [SimilarPhotoGroup] { - #if canImport(UIKit) let options = PHFetchOptions() options.sortDescriptors = [ NSSortDescriptor(key: "creationDate", ascending: sort == .oldest) @@ -557,12 +556,12 @@ enum PhotoLibrary { for (index, asset) in assets.enumerated() { guard !Task.isCancelled else { return sortedSimilarGroups(groups, sort: sort).prefix(maxGroups).map { $0 } } if !usedIdentifiers.contains(asset.localIdentifier), - let image = thumbnailImage( + let cgImage = thumbnailCGImage( for: asset, targetSize: CGSize(width: 18, height: 16), deliveryMode: .highQualityFormat ), - let hash = differenceHash(for: image) { + let hash = differenceHash(for: cgImage) { fingerprints.append(SimilarPhotoFingerprint(asset: asset, hash: hash, aspectRatio: aspectRatio(for: asset))) } if index == 0 || index == assets.count - 1 || index.isMultiple(of: 20) { @@ -676,9 +675,6 @@ enum PhotoLibrary { )) await partialGroupsHandler?(sortedGroups) return sortedGroups - #else - return [] - #endif } @discardableResult @@ -1292,21 +1288,23 @@ enum PhotoLibrary { (lhs ^ rhs).nonzeroBitCount } - #if canImport(UIKit) - private static func thumbnailImage( + // Renders a PHAsset thumbnail straight to a CGImage so the dHash and Vision + // comparison core is shared across platforms; only the PlatformImage -> + // CGImage step differs (UIImage.cgImage vs NSImage.cgImage(forProposedRect:)). + private static func thumbnailCGImage( for asset: PHAsset, targetSize: CGSize, contentMode: PHImageContentMode = .aspectFill, deliveryMode: PHImageRequestOptionsDeliveryMode = .fastFormat, allowsNetworkAccess: Bool = false - ) -> UIImage? { + ) -> CGImage? { let options = PHImageRequestOptions() options.isNetworkAccessAllowed = allowsNetworkAccess options.deliveryMode = deliveryMode options.resizeMode = .exact options.isSynchronous = true - var image: UIImage? + var cgImage: CGImage? imageManager.requestImage( for: asset, targetSize: targetSize, @@ -1314,10 +1312,15 @@ enum PhotoLibrary { options: options ) { result, info in let isCancelled = (info?[PHImageCancelledKey] as? Bool) ?? false - guard !isCancelled else { return } - image = result + guard !isCancelled, let result else { return } + #if canImport(UIKit) + cgImage = result.cgImage + #elseif canImport(AppKit) + var proposedRect = CGRect(origin: .zero, size: result.size) + cgImage = result.cgImage(forProposedRect: &proposedRect, context: nil, hints: nil) + #endif } - return image + return cgImage } private static func featurePrintDistance( @@ -1347,12 +1350,11 @@ enum PhotoLibrary { return cached } - guard let image = thumbnailImage( + guard let cgImage = thumbnailCGImage( for: asset, targetSize: CGSize(width: 160, height: 160), contentMode: .aspectFit - ), - let cgImage = image.cgImage else { + ) else { return nil } @@ -1370,9 +1372,7 @@ enum PhotoLibrary { } } - private static func differenceHash(for image: UIImage) -> UInt64? { - guard let cgImage = image.cgImage else { return nil } - + private static func differenceHash(for cgImage: CGImage) -> UInt64? { let width = 9 let height = 8 var pixels = [UInt8](repeating: 0, count: width * height) @@ -1410,7 +1410,6 @@ enum PhotoLibrary { } return hash } - #endif private static func onThisDayPredicate(referenceDate: Date = Date()) -> NSPredicate? { let calendar = Calendar.current diff --git a/SnipSnaps/Views/Files/FileReviewSessionView.swift b/SnipSnaps/Views/Files/FileReviewSessionView.swift new file mode 100644 index 0000000..208b10d --- /dev/null +++ b/SnipSnaps/Views/Files/FileReviewSessionView.swift @@ -0,0 +1,655 @@ +#if os(macOS) +import SwiftUI +import AppKit +import QuickLook +import QuickLookThumbnailing + +struct FileReviewSessionView: View { + let category: FileReviewCategory + let folders: [URL] + + @Environment(\.dismiss) private var dismiss + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var isScanning = true + @State private var items: [FileItem] = [] + @State private var index = 0 + @State private var kept: [FileItem] = [] + @State private var toDelete: [FileItem] = [] + @State private var lastUndo: UndoStep? + @State private var showSummary = false + @State private var deleteInProgress = false + @State private var resultMessage: String? + @State private var deletedCount = 0 + @State private var dragOffset: CGSize = .zero + @State private var quickLookURL: URL? + @State private var scanTask: Task? + @State private var didTruncate = false + @State private var trashFailures: [String] = [] + // The Remember-Reviewed window in effect when this scan started. Snapshotting it + // keeps the scan exclusion and mark/unmark consistent even if the user changes + // the setting mid-session (the .session and persistent stores are different). + @State private var sessionMemoryOption: ReviewMemoryOption = .thirtyDays + // Shared with the Photos review so "Review Size" and the lifetime "Space + // freed" stats behave the same across both surfaces. + @AppStorage("reviewLimit") private var reviewLimit: Int = 20 + @AppStorage("totalDeletedCount") private var totalDeletedCount: Int = 0 + @AppStorage("totalDeletedBytes") private var totalDeletedBytes: Int = 0 + @AppStorage("reviewMemoryOption") private var reviewMemoryOptionRawValue: String = ReviewMemoryOption.thirtyDays.rawValue + @AppStorage("fileSortOption") private var fileSortOptionRawValue: String = FileSortOption.largest.rawValue + + private var fileSortOption: FileSortOption { + FileSortOption(rawValue: fileSortOptionRawValue) ?? .largest + } + + private struct UndoStep { + let item: FileItem + let decision: FileDecision + let index: Int + } + + private var reviewMemoryOption: ReviewMemoryOption { + ReviewMemoryOption(rawValue: reviewMemoryOptionRawValue) ?? .thirtyDays + } + + private var current: FileItem? { + items.indices.contains(index) ? items[index] : nil + } + + var body: some View { + ZStack { + AppColor.background.ignoresSafeArea() + if isScanning { + scanningView + } else if items.isEmpty { + emptyView + } else if showSummary { + summaryView + } else { + reviewView + } + // The pushed NavigationStack already shows a Back chevron, so no visible close + // button is needed; keep Esc as an invisible accelerator on every state. + Button("Close") { dismiss() } + .keyboardShortcut(.cancelAction) + .opacity(0) + .frame(width: 0, height: 0) + .accessibilityHidden(true) + } + .navigationTitle(category.title) + .toolbar { + ToolbarItem(placement: .primaryAction) { + // Re-sorting re-scans from scratch, which would discard in-progress + // decisions (and leave them marked reviewed). Lock it once the user has + // started deciding so no queued keep/trash choices are silently lost. + sortMenu + .disabled(isScanning || !kept.isEmpty || !toDelete.isEmpty) + } + } + .onAppear(perform: load) + .onDisappear { scanTask?.cancel() } + .onChange(of: fileSortOptionRawValue) { _, _ in load() } + .focusedSceneValue(\.reviewActions, reviewActions) + .quickLookPreview($quickLookURL) + .alert("Some files couldn't be moved", isPresented: trashFailureBinding) { + Button("OK", role: .cancel) {} + } message: { + Text(trashFailureMessage) + } + } + + private var trashFailureBinding: Binding { + Binding(get: { !trashFailures.isEmpty }, set: { if !$0 { trashFailures = [] } }) + } + + private var trashFailureMessage: String { + let names = trashFailures.prefix(5).joined(separator: "\n") + let extra = trashFailures.count > 5 ? "\n…and \(trashFailures.count - 5) more" : "" + return "These items are still in place (they may be locked or in use):\n\(names)\(extra)" + } + + private var sortMenu: some View { + Menu { + Picker("Sort By", selection: $fileSortOptionRawValue) { + ForEach(FileSortOption.allCases) { option in + Label(option.title, systemImage: option.systemImage).tag(option.rawValue) + } + } + .pickerStyle(.inline) + } label: { + Label("Sort", systemImage: "arrow.up.arrow.down") + } + .help("Change the review order") + } + + // Desktop file keys (Quick Look / Reveal / Open) live on hidden buttons so they + // fire from anywhere in the review window without stealing focus. + private var fileKeyboardShortcuts: some View { + ZStack { + // Forward-delete is the second Trash key (← is bound on the visible button), + // matching the Photos review where both ← and Delete trash the current item. + Button("Move to Trash") { applyDecision(.delete) } + .keyboardShortcut(.delete, modifiers: []) + Button("Quick Look") { toggleQuickLook() } + .keyboardShortcut(.space, modifiers: []) + Button("Reveal in Finder") { if let current { FinderActions.revealInFinder(current.url) } } + .keyboardShortcut("r", modifiers: .command) + Button("Open") { if let current { FinderActions.open(current.url) } } + .keyboardShortcut("o", modifiers: .command) + } + .opacity(0) + .frame(width: 0, height: 0) + .accessibilityHidden(true) + } + + private func toggleQuickLook() { + if quickLookURL == nil { + quickLookURL = current?.url + } else { + quickLookURL = nil + } + } + + // Right-click menu shared by the review card and (lighter) the summary rows. + @ViewBuilder + private func fileContextMenu(for item: FileItem) -> some View { + Button("Quick Look") { quickLookURL = item.url } + Button("Open") { FinderActions.open(item.url) } + Button("Reveal in Finder") { FinderActions.revealInFinder(item.url) } + Divider() + Button("Keep") { if item.id == current?.id { applyDecision(.keep) } } + .disabled(item.id != current?.id) + Button("Move to Trash", role: .destructive) { if item.id == current?.id { applyDecision(.delete) } } + .disabled(item.id != current?.id) + } + + // Published to the menu bar (Review ▸ … and Edit ▸ Undo) while this review is + // on screen. ⌘Z routes here; Keep/Delete disable on the summary screen. + private var reviewActions: ReviewActions { + let reviewing = !isScanning && !items.isEmpty && !showSummary + return ReviewActions( + keep: reviewing ? { applyDecision(.keep) } : nil, + delete: reviewing ? { applyDecision(.delete) } : nil, + undo: { undo() }, + skipGroup: nil, + canUndo: lastUndo != nil + ) + } + + // MARK: - States + + private var scanningView: some View { + VStack(spacing: 12) { + ProgressView().controlSize(.large) + Text("Scanning your folders…") + .font(.headline) + .foregroundStyle(.secondary) + } + } + + private var emptyView: some View { + ContentUnavailableView { + Label("Nothing to review", systemImage: category.systemImage) + } description: { + Text(didTruncate + ? "Scanned the first \(FileLibrary.maxFilesExamined.formatted()) files without finding any \(category.title.lowercased()). These folders are very large — try a more specific folder." + : "No \(category.title.lowercased()) found in the folders you granted.") + } actions: { + Button("Back") { dismiss() } + } + } + + private var truncationBanner: some View { + Label( + "Showing matches from the first \(FileLibrary.maxFilesExamined.formatted()) files scanned.", + systemImage: "exclamationmark.triangle.fill" + ) + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var reviewView: some View { + VStack(spacing: 16) { + header + .padding(.horizontal, 24) + .padding(.top, 12) + if didTruncate { + truncationBanner + .padding(.horizontal, 24) + } + if let current { + card(for: current) + .padding(.horizontal, 24) + } + } + // Pin the decision bar in a .bar footer, matching the Photos review and this + // view's own summary screen so the primary actions sit in a consistent strip. + .safeAreaInset(edge: .bottom, spacing: 0) { + decisionBar + .padding(.horizontal, 24) + .padding(.top, 12) + .padding(.bottom, 8) + .background(.bar) + } + .background(fileKeyboardShortcuts) + } + + private var header: some View { + HStack(spacing: 12) { + Text("\(min(index + 1, items.count)) of \(items.count)") + .font(.footnote.weight(.medium)) + .foregroundStyle(.secondary) + .monospacedDigit() + ProgressView(value: Double(index), total: Double(max(items.count, 1))) + .tint(AppColor.primary) + if !toDelete.isEmpty { + Label("\(toDelete.count)", systemImage: "trash") + .font(.footnote.weight(.semibold)) + .foregroundStyle(AppColor.delete) + } + } + } + + private func card(for item: FileItem) -> some View { + VStack(spacing: 0) { + FileThumbnailView(url: item.url) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .frame(minHeight: 260) + .background(AppColor.elevatedCard) + .clipped() + + VStack(alignment: .leading, spacing: 6) { + Text(item.name) + .font(.headline) + .lineLimit(2) + .truncationMode(.middle) + HStack(spacing: 10) { + Label(item.sizeText, systemImage: "internaldrive") + Label(item.modified.formatted(.relative(presentation: .named)), systemImage: "calendar") + } + .font(.caption) + .foregroundStyle(.secondary) + Text(item.parentPath) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(14) + .background(AppColor.card) + } + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .strokeBorder(AppColor.separator.opacity(0.4), lineWidth: 0.5) + ) + .overlay(swipeBadge) + .offset(dragOffset) + .rotationEffect(.degrees(Double(dragOffset.width / 28))) + .gesture( + DragGesture(minimumDistance: 6) + .onChanged { dragOffset = $0.translation } + .onEnded(handleDragEnd) + ) + .onTapGesture(count: 2) { FinderActions.open(item.url) } + .contextMenu { fileContextMenu(for: item) } + .help("Space to preview · double-click to open") + .accessibilityElement(children: .combine) + .accessibilityLabel("\(item.name), \(item.sizeText), modified \(item.modified.formatted(.relative(presentation: .named)))") + .accessibilityActions { + Button("Keep") { applyDecision(.keep) } + Button("Move to Trash") { applyDecision(.delete) } + Button("Quick Look") { quickLookURL = item.url } + Button("Reveal in Finder") { FinderActions.revealInFinder(item.url) } + } + .animation(reduceMotion ? nil : .snappy(duration: 0.28), value: index) + .id(item.id) + } + + private var swipeBadge: some View { + ZStack { + if dragOffset.width < -24 { + badge(systemImage: "trash.fill", tint: AppColor.delete, alignment: .topLeading) + } else if dragOffset.width > 24 { + badge(systemImage: "checkmark", tint: AppColor.success, alignment: .topTrailing) + } + } + .allowsHitTesting(false) + } + + private func badge(systemImage: String, tint: Color, alignment: Alignment) -> some View { + Image(systemName: systemImage) + .font(.system(size: 26, weight: .bold)) + .foregroundStyle(.white) + .padding(16) + .background(tint, in: Circle()) + .padding(20) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: alignment) + .opacity(min(abs(dragOffset.width) / 120, 1)) + } + + private var decisionBar: some View { + HStack(spacing: 18) { + Button { + applyDecision(.delete) + } label: { + Label("Trash", systemImage: "xmark") + .frame(maxWidth: .infinity) + } + .tint(AppColor.delete) + .keyboardShortcut(.leftArrow, modifiers: []) + + Button { + undo() + } label: { + Image(systemName: "arrow.uturn.backward") + } + // Keep Undo visually secondary (the HStack makes Keep/Trash prominent); this + // override mirrors the Photos review, where Undo is a secondary action. + .secondaryActionButton() + .disabled(lastUndo == nil) + .help("Undo (⌘Z)") + + Button { + applyDecision(.keep) + } label: { + Label("Keep", systemImage: "checkmark") + .frame(maxWidth: .infinity) + } + .tint(AppColor.success) + .keyboardShortcut(.rightArrow, modifiers: []) + } + .controlSize(.large) + .prominentActionButton() + } + + private var summaryView: some View { + ScrollView { + VStack(spacing: 20) { + VStack(spacing: 6) { + Text("Review complete") + .font(.title2.weight(.semibold)) + Text("\(kept.count) kept · \(toDelete.count + deletedCount) to trash") + .font(.subheadline) + .foregroundStyle(.secondary) + } + .padding(.top, 12) + + if let resultMessage { + Label(resultMessage, systemImage: "checkmark.circle.fill") + .font(.subheadline.weight(.medium)) + .foregroundStyle(AppColor.success) + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(AppColor.card, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + } + + if deletedCount > 0 { + Button { + FinderActions.openTrash() + } label: { + Label("Show in Trash", systemImage: "trash") + .frame(maxWidth: .infinity) + } + .secondaryActionButton() + .controlSize(.large) + .help("Open the Trash in Finder to review or restore") + } + + if !toDelete.isEmpty { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("To move to Trash") + .font(.subheadline.weight(.semibold)) + Spacer() + Text(reclaimText) + .font(.footnote) + .foregroundStyle(.secondary) + } + ForEach(toDelete) { item in + HStack(spacing: 10) { + FileThumbnailView(url: item.url) + .frame(width: 40, height: 40) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + Text(item.name) + .font(.callout) + .lineLimit(1) + .truncationMode(.middle) + Spacer() + Text(item.sizeText) + .font(.caption) + .foregroundStyle(.secondary) + } + .contentShape(Rectangle()) + .contextMenu { + Button("Quick Look") { quickLookURL = item.url } + Button("Reveal in Finder") { FinderActions.revealInFinder(item.url) } + } + } + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(AppColor.card, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + } + + if totalDeletedCount > 0 { + VStack(alignment: .leading, spacing: 4) { + Text("Lifetime") + .font(.footnote.weight(.semibold)) + .foregroundStyle(.secondary) + HStack { + Text("\(totalDeletedCount) deleted") + Spacer() + Text(totalDeletedBytesText) + } + .font(.footnote) + .foregroundStyle(.secondary) + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(AppColor.card, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + } + } + .padding(20) + } + .safeAreaInset(edge: .bottom) { + summaryActionBar + } + } + + private var summaryActionBar: some View { + Group { + if toDelete.isEmpty { + Button { + dismiss() + } label: { + Text("Done").fontWeight(.semibold).frame(maxWidth: .infinity) + } + .prominentActionButton() + .controlSize(.large) + .keyboardShortcut(.defaultAction) + } else { + Button(role: .destructive) { + performDelete() + } label: { + HStack { + if deleteInProgress { + ProgressView().controlSize(.small) + } else { + Image(systemName: "trash.fill") + } + Text("Move \(toDelete.count) to Trash") + .fontWeight(.semibold) + } + .frame(maxWidth: .infinity) + } + .prominentActionButton() + .tint(AppColor.delete) + .controlSize(.large) + .disabled(deleteInProgress) + .keyboardShortcut(.defaultAction) + } + } + .padding(16) + .background(.bar) + } + + private var reclaimText: String { + let bytes = toDelete.reduce(Int64(0)) { $0 + $1.size } + return ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file) + } + + private var totalDeletedBytesText: String { + guard totalDeletedBytes > 0 else { return "0 KB" } + return ByteCountFormatter.string(fromByteCount: Int64(totalDeletedBytes), countStyle: .file) + } + + // MARK: - Actions + + private func load() { + scanTask?.cancel() + isScanning = true + let folders = folders + let category = category + let limit = max(5, min(reviewLimit, 200)) + sessionMemoryOption = reviewMemoryOption + let memory = sessionMemoryOption + let reviewed = FileReviewHistory.reviewedPaths(memoryOption: memory) + let sort = fileSortOption + scanTask = Task.detached(priority: .userInitiated) { + let result = FileLibrary.scan(folders: folders, category: category, limit: limit, excluding: reviewed, sort: sort) + if Task.isCancelled { return } + await MainActor.run { + items = result.items + didTruncate = result.truncated + index = 0 + kept = [] + toDelete = [] + lastUndo = nil + deletedCount = 0 + showSummary = false + isScanning = false + } + } + } + + private func applyDecision(_ decision: FileDecision) { + guard let item = current else { return } + lastUndo = UndoStep(item: item, decision: decision, index: index) + switch decision { + case .keep: kept.append(item) + case .delete: toDelete.append(item) + } + FileReviewHistory.markReviewed(item.url.path, memoryOption: sessionMemoryOption) + dragOffset = .zero + advance() + } + + private func advance() { + index += 1 + if index >= items.count { + showSummary = true + } + } + + private func undo() { + guard let step = lastUndo else { return } + switch step.decision { + case .keep: kept.removeAll { $0.id == step.item.id } + case .delete: toDelete.removeAll { $0.id == step.item.id } + } + FileReviewHistory.unmarkReviewed(step.item.url.path, memoryOption: sessionMemoryOption) + showSummary = false + index = step.index + lastUndo = nil + } + + private func handleDragEnd(_ value: DragGesture.Value) { + let threshold: CGFloat = 110 + if value.translation.width < -threshold { + applyDecision(.delete) + } else if value.translation.width > threshold { + applyDecision(.keep) + } else { + withAnimation(reduceMotion ? nil : .snappy(duration: 0.25)) { dragOffset = .zero } + } + } + + private func performDelete() { + guard !toDelete.isEmpty, !deleteInProgress else { return } + deleteInProgress = true + let targets = toDelete + Task { + let result = await Task.detached(priority: .userInitiated) { + FileLibrary.moveToTrash(targets) + }.value + await MainActor.run { + deleteInProgress = false + let freed = ByteCountFormatter.string(fromByteCount: result.freedBytes, countStyle: .file) + if result.failed.isEmpty { + resultMessage = "Moved \(result.trashed) to Trash · \(freed) freed" + } else { + resultMessage = "Moved \(result.trashed) · \(result.failed.count) couldn't be moved" + } + deletedCount += result.trashed + totalDeletedCount += result.trashed + totalDeletedBytes += Int(result.freedBytes) + toDelete = [] + lastUndo = nil + trashFailures = result.failed + } + } + } +} + +// Async QuickLook thumbnail for any file type (falls back to the file's icon). +private struct FileThumbnailView: View { + let url: URL + + @Environment(\.displayScale) private var displayScale + @State private var image: NSImage? + + var body: some View { + GeometryReader { proxy in + ZStack { + if let image { + Image(nsImage: image) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + Image(systemName: "doc") + .font(.system(size: 32)) + .foregroundStyle(.secondary) + } + } + .frame(width: proxy.size.width, height: proxy.size.height) + .onAppear { load(into: proxy.size) } + .onChange(of: url) { _, _ in load(into: proxy.size) } + } + } + + private func load(into size: CGSize) { + let target = CGSize(width: max(size.width, 80), height: max(size.height, 80)) + let request = QLThumbnailGenerator.Request( + fileAt: url, + size: target, + scale: displayScale, + representationTypes: .all + ) + QLThumbnailGenerator.shared.generateBestRepresentation(for: request) { representation, _ in + guard let nsImage = representation?.nsImage else { return } + let boxed = UncheckedSendableBox(nsImage) + Task { @MainActor in image = boxed.value } + } + } +} + +// Hands a known-safe non-Sendable value (the rendered thumbnail) from QuickLook's +// completion queue to the main actor without tripping Swift 6's data-race check. +private struct UncheckedSendableBox: @unchecked Sendable { + let value: T + init(_ value: T) { self.value = value } +} +#endif diff --git a/SnipSnaps/Views/Files/FilesView.swift b/SnipSnaps/Views/Files/FilesView.swift new file mode 100644 index 0000000..c9e691c --- /dev/null +++ b/SnipSnaps/Views/Files/FilesView.swift @@ -0,0 +1,350 @@ +#if os(macOS) +import SwiftUI +import AppKit + +struct FilesView: View { + @StateObject private var store = FileFolderStore() + @State private var counts: [FileReviewCategory: FileCounts] = [:] + @State private var isCounting = false + @State private var selectedCategory: FileReviewCategory? + @State private var folderPendingRemoval: FileFolderStore.Folder? + @State private var isDropTargeted = false + // Tracks the in-flight tally so a superseded count can't overwrite a newer one + // when several triggers (.onChange/.onAppear) fire close together. + @State private var countTask: Task? + @AppStorage("reviewMemoryOption") private var reviewMemoryOptionRawValue: String = ReviewMemoryOption.thirtyDays.rawValue + + private var reviewMemoryOption: ReviewMemoryOption { + ReviewMemoryOption(rawValue: reviewMemoryOptionRawValue) ?? .thirtyDays + } + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + if store.folders.isEmpty { + onboarding + } else { + grantedFoldersSection + categoriesSection + } + } + .padding(20) + } + .background(AppColor.background) + .navigationTitle("Files") + .toolbar { + ToolbarItem(placement: .primaryAction) { + if store.folders.isEmpty { + if isCounting { ProgressView().controlSize(.small) } + } else { + Button { + chooseFolder() + } label: { + Label("Add Folder", systemImage: "plus") + } + } + } + } + .navigationDestination(item: $selectedCategory) { category in + FileReviewSessionView(category: category, folders: store.folders.map(\.url)) + } + } + .focusedSceneValue(\.addFolderAction, { chooseFolder() }) + .confirmationDialog( + "Stop reviewing this folder?", + isPresented: Binding( + get: { folderPendingRemoval != nil }, + set: { if !$0 { folderPendingRemoval = nil } } + ), + presenting: folderPendingRemoval + ) { folder in + Button("Stop Reviewing", role: .destructive) { store.remove(folder) } + Button("Cancel", role: .cancel) {} + } message: { folder in + Text("SnipSnaps will lose access to “\(folder.name)”. No files are deleted — you can grant access again anytime.") + } + .onChange(of: store.folders) { _, _ in refreshCounts() } + // Re-tally when a review session closes (files may have been trashed), the + // same way HomeView refreshes counts after a Photos review. + .onChange(of: selectedCategory) { _, newValue in + if newValue == nil { refreshCounts() } + } + .onChange(of: reviewMemoryOptionRawValue) { _, _ in refreshCounts() } + .onAppear { refreshCounts() } + .onDisappear { countTask?.cancel() } + } + + // MARK: - Onboarding + + private var onboarding: some View { + VStack(alignment: .leading, spacing: 18) { + VStack(alignment: .leading, spacing: 8) { + Image(systemName: "folder.badge.plus") + .font(.system(size: 44)) + .foregroundStyle(AppColor.primary) + Text("Clean up your files") + .font(.title2.weight(.semibold)) + Text("Choose a folder and SnipSnaps helps you review what's inside — big files, old files, leftover screenshots. Only the files you confirm are moved to the Trash, so nothing is deleted permanently.") + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + VStack(spacing: 10) { + quickFolderButton("Downloads", systemImage: "arrow.down.circle.fill", url: Self.downloadsURL) + quickFolderButton("Desktop", systemImage: "menubar.dock.rectangle", url: Self.desktopURL) + quickFolderButton("Documents", systemImage: "doc.fill", url: Self.documentsURL) + Button { + chooseFolder() + } label: { + Label("Choose Another Folder…", systemImage: "folder") + .frame(maxWidth: .infinity) + } + .controlSize(.large) + } + + Text("SnipSnaps can only see folders you pick here. You can also drag a folder here from Finder.") + .font(.footnote) + .foregroundStyle(.tertiary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(20) + .background(AppColor.card, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .strokeBorder(AppColor.primary, style: StrokeStyle(lineWidth: 2, dash: [7])) + .opacity(isDropTargeted ? 1 : 0) + ) + .dropDestination(for: URL.self) { urls, _ in + addDroppedFolders(urls) + } isTargeted: { isDropTargeted = $0 } + } + + // A folder dragged from Finder carries an implicit user-selected grant, so we + // can bookmark it immediately. Normalize first, then accept only directories. + private func addDroppedFolders(_ urls: [URL]) -> Bool { + var added = false + for url in urls { + let standardized = url.standardizedFileURL + let isDirectory = (try? standardized.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory ?? false + if isDirectory { + store.add(standardized) + added = true + } + } + return added + } + + private func quickFolderButton(_ title: String, systemImage: String, url: URL?) -> some View { + Button { + chooseFolder(startingAt: url) + } label: { + Label("Add \(title)", systemImage: systemImage) + .frame(maxWidth: .infinity, alignment: .leading) + } + .controlSize(.large) + .prominentActionButton() + .tint(AppColor.primary) + .disabled(url == nil) + } + + // MARK: - Granted folders + + private var grantedFoldersSection: some View { + VStack(alignment: .leading, spacing: 10) { + Text("FOLDERS") + .font(.footnote.weight(.semibold)) + .foregroundStyle(.secondary) + VStack(spacing: 8) { + ForEach(store.folders) { folder in + HStack(spacing: 12) { + Image(systemName: "folder.fill") + .foregroundStyle(AppColor.primary) + VStack(alignment: .leading, spacing: 2) { + Text(folder.name).font(.headline) + if store.inaccessiblePaths.contains(folder.url.path) { + Label("Couldn't access — remove and re-add this folder", systemImage: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundStyle(.orange) + .lineLimit(1) + } else { + Text(folder.url.path) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + } + Spacer() + Button { + FinderActions.revealInFinder(folder.url) + } label: { + Image(systemName: "arrow.up.forward.app") + } + .buttonStyle(.borderless) + .help("Reveal in Finder") + Button(role: .destructive) { + folderPendingRemoval = folder + } label: { + Image(systemName: "minus.circle.fill") + } + .buttonStyle(.borderless) + .frame(width: 28, height: 28) + .contentShape(Rectangle()) + .help("Stop reviewing this folder") + .accessibilityLabel("Stop reviewing \(folder.name)") + } + .padding(12) + .background(AppColor.card, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .contextMenu { + Button("Reveal in Finder") { FinderActions.revealInFinder(folder.url) } + Button("Open in Finder") { FinderActions.open(folder.url) } + Divider() + Button("Stop Reviewing", role: .destructive) { folderPendingRemoval = folder } + } + } + } + } + } + + // MARK: - Categories + + private var categoriesSection: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("REVIEW") + .font(.footnote.weight(.semibold)) + .foregroundStyle(.secondary) + if isCounting { + ProgressView().controlSize(.small) + } + } + VStack(spacing: 12) { + ForEach(FileReviewCategory.allCases) { category in + Button { + selectedCategory = category + } label: { + FileCategoryCard( + category: category, + counts: counts[category], + memoryActive: reviewMemoryOption != .never + ) + } + .buttonStyle(.plain) + .interactiveCardHover() + } + } + } + } + + // MARK: - Folder picking + + private func chooseFolder(startingAt directory: URL? = nil) { + let panel = NSOpenPanel() + panel.canChooseDirectories = true + panel.canChooseFiles = false + panel.allowsMultipleSelection = true + panel.prompt = "Grant Access" + panel.message = "Choose a folder for SnipSnaps to review. Only files you confirm are moved to the Trash." + if let directory { panel.directoryURL = directory } + guard panel.runModal() == .OK else { return } + for url in panel.urls { + store.add(url) + } + } + + private func refreshCounts() { + // Supersede any in-flight tally so its (now-stale) result is discarded rather + // than racing to overwrite this one. + countTask?.cancel() + let folders = store.folders.map(\.url) + guard !folders.isEmpty else { + counts = [:] + isCounting = false + return + } + isCounting = true + let reviewed = FileReviewHistory.reviewedPaths(memoryOption: reviewMemoryOption) + countTask = Task { + let tally = await Task.detached(priority: .utility) { + FileLibrary.counts(folders: folders, reviewedPaths: reviewed) + }.value + if Task.isCancelled { return } + await MainActor.run { + counts = tally + isCounting = false + } + } + } + + private static var downloadsURL: URL? { + try? FileManager.default.url(for: .downloadsDirectory, in: .userDomainMask, appropriateFor: nil, create: false) + } + private static var desktopURL: URL? { + try? FileManager.default.url(for: .desktopDirectory, in: .userDomainMask, appropriateFor: nil, create: false) + } + private static var documentsURL: URL? { + try? FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: false) + } +} + +private struct FileCategoryCard: View { + let category: FileReviewCategory + let counts: FileCounts? + let memoryActive: Bool + + var body: some View { + HStack(spacing: 16) { + Image(systemName: category.systemImage) + .font(.title3.weight(.semibold)) + .foregroundStyle(AppColor.primary) + .frame(width: 44, height: 44) + .background(AppColor.primary.opacity(0.12), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + VStack(alignment: .leading, spacing: 3) { + Text(category.title) + .font(.headline) + .foregroundStyle(AppColor.text) + Text(category.subtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + if let summary = countSummary { + Text(summary) + .font(.caption.weight(.medium)) + .foregroundStyle(.secondary) + .monospacedDigit() + } + } + Spacer() + Text(displayCount) + .font(.title2.weight(.bold)) + .foregroundStyle(.tertiary) + .monospacedDigit() + } + .padding(16) + .frame(maxWidth: .infinity) + .background(AppColor.card, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + // Consolidate into one VoiceOver element with a clean label/value/hint so the + // title, subtitle, count summary, and trailing count aren't read as fragments. + .accessibilityElement(children: .ignore) + .accessibilityLabel(category.title) + .accessibilityValue(countSummary ?? category.subtitle) + .accessibilityHint(category.showsScanAction ? "Scans this folder set for duplicates" : "Opens \(category.title) review") + } + + private var displayCount: String { + if category.showsScanAction { return "Scan" } + guard let counts else { return "…" } + let value = memoryActive ? counts.notReviewed : counts.total + return value > 9999 ? "9999+" : "\(value)" + } + + private var countSummary: String? { + guard !category.showsScanAction, let counts else { return nil } + if memoryActive { + return "\(counts.notReviewed) not reviewed · \(counts.total) total" + } + return "\(counts.total) total" + } +} +#endif diff --git a/SnipSnaps/Views/Marketing/ScreenshotDemoView.swift b/SnipSnaps/Views/Marketing/ScreenshotDemoView.swift index fab104d..43458b3 100644 --- a/SnipSnaps/Views/Marketing/ScreenshotDemoView.swift +++ b/SnipSnaps/Views/Marketing/ScreenshotDemoView.swift @@ -103,7 +103,7 @@ private struct ScreenshotHomeDemo: View { .foregroundStyle(.secondary) .padding(.horizontal, 10) .padding(.vertical, 6) - .background(Color(.tertiarySystemGroupedBackground), in: Capsule(style: .continuous)) + .background(AppColor.chip, in: Capsule(style: .continuous)) .padding(.top, 2) } if title == "Similar Photos" { @@ -117,7 +117,7 @@ private struct ScreenshotHomeDemo: View { .foregroundStyle(.secondary) .padding(.horizontal, 10) .padding(.vertical, 6) - .background(Color(.tertiarySystemGroupedBackground), in: Capsule(style: .continuous)) + .background(AppColor.chip, in: Capsule(style: .continuous)) .padding(.top, 2) } if title == "Videos" { @@ -131,7 +131,7 @@ private struct ScreenshotHomeDemo: View { .foregroundStyle(.secondary) .padding(.horizontal, 10) .padding(.vertical, 6) - .background(Color(.tertiarySystemGroupedBackground), in: Capsule(style: .continuous)) + .background(AppColor.chip, in: Capsule(style: .continuous)) .padding(.top, 2) } } @@ -307,7 +307,7 @@ private struct ScreenshotSummaryDemo: View { } } .padding(10) - .background(Color(.tertiarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .background(AppColor.chip, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) } } .padding(16) @@ -368,7 +368,7 @@ private struct ScreenshotSettingsDemo: View { Image(systemName: "chevron.right") .font(.footnote) .fontWeight(.semibold) - .foregroundStyle(Color(UIColor.tertiaryLabel)) + .foregroundStyle(.tertiary) } } footer: { Text("Resets review size, sorting, review memory, and lifetime deleted stats on this device. This does not delete photos.") diff --git a/SnipSnaps/Views/Review/ReviewSessionView.swift b/SnipSnaps/Views/Review/ReviewSessionView.swift index 2335a0d..d728d09 100644 --- a/SnipSnaps/Views/Review/ReviewSessionView.swift +++ b/SnipSnaps/Views/Review/ReviewSessionView.swift @@ -22,6 +22,27 @@ import AppKit typealias PlatformImage = NSImage #endif +#if os(macOS) +// A concise, fetch-free VoiceOver label for a review card (media type + capture date). +private extension PHAsset { + var reviewAccessibilityLabel: String { + var parts: [String] = [] + if mediaType == .video { + parts.append("Video") + } else if mediaSubtypes.contains(.photoScreenshot) { + parts.append("Screenshot") + } else { + parts.append("Photo") + } + if mediaSubtypes.contains(.photoLive) { parts.append("Live Photo") } + if let date = creationDate { + parts.append(date.formatted(date: .abbreviated, time: .omitted)) + } + return parts.joined(separator: ", ") + } +} +#endif + enum PhotoDecision { case keep case delete @@ -161,6 +182,7 @@ struct ReviewSessionView: View { @Environment(\.dismiss) private var dismiss @Environment(\.displayScale) private var displayScale @Environment(\.scenePhase) private var scenePhase + @Environment(\.accessibilityReduceMotion) private var reduceMotion @State private var authStatus = PhotoLibrary.authorizationStatus() @State private var isLoading = true @State private var assets: [PHAsset] = [] @@ -298,19 +320,33 @@ struct ReviewSessionView: View { } else { reviewView } + #if os(macOS) + // The pushed NavigationStack shows a Back chevron; keep Esc as an invisible + // accelerator on every state instead of a redundant visible close button. + Button("Close") { dismiss() } + .keyboardShortcut(.cancelAction) + .opacity(0) + .frame(width: 0, height: 0) + .accessibilityHidden(true) + #endif } .navigationTitle(mode.title) + #if os(iOS) .navigationBarTitleDisplayMode(.inline) .navigationBarBackButtonHidden(true) + #endif .sheet(isPresented: $showMetadataSheet) { if let asset = currentAsset, let currentPhotoDetails { PhotoMetadataSheet(asset: asset, details: currentPhotoDetails) } } + // iOS hides the back button, so the explicit close stays there; macOS uses the + // native Back chevron + the Esc accelerator above. + #if os(iOS) .toolbar { if !showSummary || !deleteAssets.isEmpty { - ToolbarItem(placement: .topBarLeading) { + ToolbarItem(placement: .cancellationAction) { Button { dismiss() } label: { @@ -321,6 +357,7 @@ struct ReviewSessionView: View { } } .toolbar(.hidden, for: .tabBar) + #endif .onAppear { loadAssets() } .onChange(of: currentIndex) { _, _ in updateCaching() @@ -335,7 +372,44 @@ struct ReviewSessionView: View { } message: { Text(errorMessage) } + #if os(macOS) + .focusedSceneValue(\.reviewActions, reviewActions) + #endif + } + + #if os(macOS) + // Published to the menu bar so Review ▸ … and Edit ▸ Undo drive this review + // while it's on screen. Keep/Delete are nil off the review surface so the menu + // items disable; ⌘Z routes here via the focused Undo. + private var reviewActions: ReviewActions { + let reviewing = canAccessPhotos && !isLoading && !assets.isEmpty && !showSummary + return ReviewActions( + keep: reviewing ? { applyDecision(.keep) } : nil, + delete: reviewing ? { applyDecision(.delete) } : nil, + undo: { undoLastReviewDecision() }, + skipGroup: nil, + canUndo: lastReviewUndo != nil && !isAnimatingCard + ) + } + + // Hidden buttons own the bare review keys (proven pattern). Present only while + // the review card is showing, so Return is free for the summary's default action. + private var reviewKeyboardShortcuts: some View { + ZStack { + Button("Delete") { applyDecision(.delete) } + .keyboardShortcut(.leftArrow, modifiers: []) + Button("Delete") { applyDecision(.delete) } + .keyboardShortcut(.delete, modifiers: []) + Button("Keep") { applyDecision(.keep) } + .keyboardShortcut(.rightArrow, modifiers: []) + Button("Details") { showMetadataSheet = true } + .keyboardShortcut(.space, modifiers: []) + } + .opacity(0) + .frame(width: 0, height: 0) + .accessibilityHidden(true) } + #endif private var reviewView: some View { VStack(spacing: 18) { @@ -353,6 +427,9 @@ struct ReviewSessionView: View { .padding(.bottom, 8) .background(.bar) } + #if os(macOS) + .background(reviewKeyboardShortcuts) + #endif } private var reviewHeader: some View { @@ -386,14 +463,7 @@ struct ReviewSessionView: View { .padding(.horizontal, 14) .padding(.vertical, 12) .frame(maxWidth: .infinity) - .background { - RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill(.ultraThinMaterial) - .overlay { - RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill(AppColor.card.opacity(0.72)) - } - } + .infoChipBackground() } .id(currentAsset?.localIdentifier) .transition(.opacity.combined(with: .scale(scale: 0.985))) @@ -461,6 +531,13 @@ struct ReviewSessionView: View { .onTapGesture { showMetadataSheet = true } + #if os(macOS) + // VoiceOver: label the otherwise-unlabeled photo and expose its tap (Details) + // as a rotor action. Keep/Delete stay on the labeled decision buttons. + .accessibilityElement(children: .ignore) + .accessibilityLabel(asset.reviewAccessibilityLabel) + .accessibilityAction(named: "Details") { showMetadataSheet = true } + #endif .gesture( DragGesture(minimumDistance: 4) .updating($gestureOffset) { value, state, _ in @@ -484,6 +561,9 @@ struct ReviewSessionView: View { ) { applyDecision(.delete) } + #if os(macOS) + .help("Delete (← or Delete)") + #endif Spacer(minLength: 0) @@ -492,10 +572,13 @@ struct ReviewSessionView: View { undoLastReviewDecision() } .font(.footnote.weight(.semibold)) - .buttonStyle(.bordered) + .secondaryActionButton() .controlSize(.small) + #if os(macOS) + .help("Undo (⌘Z)") + #endif } else { - Text("Swipe or tap") + Text(macSwipeHint) .font(.footnote.weight(.medium)) .foregroundStyle(.secondary) .lineLimit(1) @@ -511,10 +594,21 @@ struct ReviewSessionView: View { ) { applyDecision(.keep) } + #if os(macOS) + .help("Keep (→ or Return)") + #endif } .frame(maxWidth: .infinity) } + private var macSwipeHint: String { + #if os(macOS) + return "← delete · → keep" + #else + return "Swipe or tap" + #endif + } + private var summaryView: some View { ScrollView { VStack(spacing: 20) { @@ -617,10 +711,13 @@ struct ReviewSessionView: View { } .frame(maxWidth: .infinity) } - .buttonStyle(.borderedProminent) + .prominentActionButton() .tint(AppColor.delete) .controlSize(.large) .disabled(deleteInProgress) + #if os(macOS) + .keyboardShortcut(.defaultAction) + #endif } else { Button { dismiss() @@ -629,8 +726,11 @@ struct ReviewSessionView: View { .fontWeight(.semibold) .frame(maxWidth: .infinity) } - .buttonStyle(.borderedProminent) + .prominentActionButton() .controlSize(.large) + #if os(macOS) + .keyboardShortcut(.defaultAction) + #endif } } .padding(.horizontal, 20) @@ -667,7 +767,7 @@ struct ReviewSessionView: View { loadAssets() } } - .buttonStyle(.borderedProminent) + .prominentActionButton() } } @@ -752,6 +852,15 @@ struct ReviewSessionView: View { deleteAssets.append(asset) } PhotoReviewHistory.markReviewed(asset, for: mode, memoryOption: reviewMemoryOption) + #if os(macOS) + if reduceMotion { + // Skip the card fling; advance with a brief cross-fade and release the lock. + cardDepartureOffset = .zero + withAnimation(.easeInOut(duration: 0.15)) { advance() } + isAnimatingCard = false + return + } + #endif let direction: CGFloat = decision == .keep ? 1 : -1 let exitDistance = max(cardSize.width, 500) * 1.35 cardDepartureOffset = startingOffset @@ -899,6 +1008,7 @@ struct SimilarReviewSessionView: View { @Environment(\.dismiss) private var dismiss @Environment(\.displayScale) private var displayScale @Environment(\.scenePhase) private var scenePhase + @Environment(\.accessibilityReduceMotion) private var reduceMotion @State private var authStatus = PhotoLibrary.authorizationStatus() @State private var isScanning = true @State private var groups: [SimilarPhotoGroup] = [] @@ -1058,13 +1168,23 @@ struct SimilarReviewSessionView: View { } else { reviewView } + #if os(macOS) + // The pushed NavigationStack shows a Back chevron; keep Esc as an invisible + // accelerator (cancelling any in-flight scan) instead of a visible close. + Button("Close") { cancelScan(); dismiss() } + .keyboardShortcut(.cancelAction) + .opacity(0) + .frame(width: 0, height: 0) + .accessibilityHidden(true) + #endif } .navigationTitle("Similar") + #if os(iOS) .navigationBarTitleDisplayMode(.inline) .navigationBarBackButtonHidden(true) .toolbar { if !showSummary || !deleteAssets.isEmpty { - ToolbarItem(placement: .topBarLeading) { + ToolbarItem(placement: .cancellationAction) { Button { cancelScan() dismiss() @@ -1076,9 +1196,16 @@ struct SimilarReviewSessionView: View { } } .toolbar(.hidden, for: .tabBar) + #endif + #if os(iOS) .fullScreenCover(item: $zoomTarget) { target in FullScreenPhotoView(asset: target.asset) } + #else + .sheet(item: $zoomTarget) { target in + FullScreenPhotoView(asset: target.asset) + } + #endif .sheet(item: $metadataTarget) { target in PhotoMetadataSheet(asset: target.asset, details: target.details) } @@ -1091,12 +1218,53 @@ struct SimilarReviewSessionView: View { } message: { Text(errorMessage) } + #if os(macOS) + .focusedSceneValue(\.reviewActions, reviewActions) + #endif + } + + #if os(macOS) + private var reviewActions: ReviewActions { + let reviewing = canAccessPhotos && !isScanning && !groups.isEmpty && !showSummary + return ReviewActions( + keep: reviewing ? { applyPhotoDecision(.keep) } : nil, + delete: reviewing ? { applyPhotoDecision(.delete) } : nil, + undo: { undo() }, + skipGroup: reviewing ? { skipGroup() } : nil, + canUndo: !undoStack.isEmpty && !isAnimatingCard + ) + } + + private var similarKeyboardShortcuts: some View { + ZStack { + Button("Delete") { applyPhotoDecision(.delete) } + .keyboardShortcut(.leftArrow, modifiers: []) + Button("Delete") { applyPhotoDecision(.delete) } + .keyboardShortcut(.delete, modifiers: []) + Button("Keep") { applyPhotoDecision(.keep) } + .keyboardShortcut(.rightArrow, modifiers: []) + Button("Skip Group") { skipGroup() } + .keyboardShortcut("s", modifiers: []) + if let asset = currentPhoto { + Button("Details") { metadataTarget = MetadataTarget(asset: asset, details: details(for: asset)) } + .keyboardShortcut(.space, modifiers: []) + } + } + .opacity(0) + .frame(width: 0, height: 0) + .accessibilityHidden(true) } + #endif // MARK: - Review private var reviewView: some View { + #if os(macOS) swipeReviewView + .background(similarKeyboardShortcuts) + #else + swipeReviewView + #endif } private var groupHeader: some View { @@ -1130,14 +1298,7 @@ struct SimilarReviewSessionView: View { .padding(.horizontal, 14) .padding(.vertical, 12) .frame(maxWidth: .infinity) - .background { - RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill(.ultraThinMaterial) - .overlay { - RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill(AppColor.card.opacity(0.72)) - } - } + .infoChipBackground() } .id(asset.localIdentifier) .transition(.opacity.combined(with: .scale(scale: 0.985))) @@ -1294,6 +1455,12 @@ struct SimilarReviewSessionView: View { .onTapGesture { metadataTarget = MetadataTarget(asset: asset, details: details(for: asset)) } + #if os(macOS) + // VoiceOver: label the photo and expose its tap (Details) as a rotor action. + .accessibilityElement(children: .ignore) + .accessibilityLabel(asset.reviewAccessibilityLabel) + .accessibilityAction(named: "Details") { metadataTarget = MetadataTarget(asset: asset, details: details(for: asset)) } + #endif .gesture( DragGesture(minimumDistance: 4) .updating($gestureOffset) { value, state, _ in @@ -1366,7 +1533,7 @@ struct SimilarReviewSessionView: View { loadGroups() } } - .buttonStyle(.borderedProminent) + .prominentActionButton() } } @@ -1397,7 +1564,7 @@ struct SimilarReviewSessionView: View { Label(reviewFoundTitle, systemImage: "square.stack.3d.up") .fontWeight(.semibold) } - .buttonStyle(.borderedProminent) + .prominentActionButton() .tint(AppColor.primary) .controlSize(.large) .padding(.top, 4) @@ -1535,10 +1702,13 @@ struct SimilarReviewSessionView: View { } .frame(maxWidth: .infinity) } - .buttonStyle(.borderedProminent) + .prominentActionButton() .tint(AppColor.delete) .controlSize(.large) .disabled(deleteInProgress) + #if os(macOS) + .keyboardShortcut(.defaultAction) + #endif } else { Button { dismiss() @@ -1547,8 +1717,11 @@ struct SimilarReviewSessionView: View { .fontWeight(.semibold) .frame(maxWidth: .infinity) } - .buttonStyle(.borderedProminent) + .prominentActionButton() .controlSize(.large) + #if os(macOS) + .keyboardShortcut(.defaultAction) + #endif } } .padding(.horizontal, 20) @@ -1734,6 +1907,14 @@ struct SimilarReviewSessionView: View { case .delete: appendUnique([asset], to: &deleteAssets) } + #if os(macOS) + if reduceMotion { + cardDepartureOffset = .zero + withAnimation(.easeInOut(duration: 0.15)) { advancePhoto() } + isAnimatingCard = false + return + } + #endif let direction: CGFloat = decision == .keep ? 1 : -1 let exitDistance = max(cardSize.width, 500) * 1.35 cardDepartureOffset = startingOffset @@ -1977,8 +2158,13 @@ private struct FullScreenPhotoView: View { .padding(.top, 12) .padding(.trailing, 16) .accessibilityLabel("Close") + #if os(macOS) + .keyboardShortcut(.cancelAction) + #endif } + #if os(iOS) .statusBarHidden(true) + #endif } private func magnification(in size: CGSize) -> some Gesture { @@ -2090,14 +2276,18 @@ private struct PhotoMetadataSheet: View { #endif } .navigationTitle(asset.mediaType == .video ? "Video Details" : "Photo Details") + #if os(iOS) .navigationBarTitleDisplayMode(.inline) + #endif .toolbar { - ToolbarItem(placement: .topBarTrailing) { + ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } } } + #if os(iOS) .presentationDetents([.medium, .large]) + #endif } } @@ -2123,7 +2313,7 @@ private struct AssetLocationMapView: View { .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) .overlay { RoundedRectangle(cornerRadius: 14, style: .continuous) - .strokeBorder(Color(.separator).opacity(0.35), lineWidth: 0.5) + .strokeBorder(AppColor.separator.opacity(0.35), lineWidth: 0.5) } .accessibilityLabel("Capture location map") } @@ -2178,11 +2368,11 @@ private struct PhotoCardView: View { } } .frame(width: fittedSize.width, height: fittedSize.height) - .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 28, style: .continuous)) + .background(AppColor.elevatedCard, in: RoundedRectangle(cornerRadius: 28, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: 28, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 28, style: .continuous) - .strokeBorder(Color.white.opacity(0.28), lineWidth: 0.5) + .strokeBorder(AppColor.cardEdge, lineWidth: 0.5) ) .shadow(color: AppColor.shadow.opacity(1.35), radius: 22, x: 0, y: 12) .frame(width: bounds.width, height: bounds.height) @@ -2312,10 +2502,10 @@ private struct CardBackdropView: View { var body: some View { RoundedRectangle(cornerRadius: 28, style: .continuous) - .fill(Color(.secondarySystemBackground)) + .fill(AppColor.elevatedCard) .overlay( RoundedRectangle(cornerRadius: 28, style: .continuous) - .strokeBorder(Color.white.opacity(0.24), lineWidth: 0.5) + .strokeBorder(AppColor.cardEdge, lineWidth: 0.5) ) .shadow(color: AppColor.shadow.opacity(1.2), radius: 18, x: 0, y: 10) .frame(width: bounds.width, height: bounds.height) @@ -2379,7 +2569,7 @@ private struct PhotoAssetImageView: View { .opacity(isLoaded ? 1 : 0) } else { Rectangle() - .fill(Color(.tertiarySystemFill)) + .fill(AppColor.fill) } #if canImport(PhotosUI) && canImport(UIKit) diff --git a/SnipSnaps/Views/Tabs/HomeView.swift b/SnipSnaps/Views/Tabs/HomeView.swift index 807cbe1..28dad57 100644 --- a/SnipSnaps/Views/Tabs/HomeView.swift +++ b/SnipSnaps/Views/Tabs/HomeView.swift @@ -171,12 +171,26 @@ struct HomeView: View { .padding(.bottom, 32) } .background(AppColor.background) + #if os(macOS) + .navigationTitle("Photos") + #else .navigationTitle("SnipSnaps") .navigationBarTitleDisplayMode(.large) + #endif .toolbar { - ToolbarItem(placement: .topBarTrailing) { + ToolbarItem(placement: .primaryAction) { if isRefreshingCounts { updatingCountsIndicator + } else { + #if os(macOS) + Button { + refreshForCurrentMemoryOption() + } label: { + Image(systemName: "arrow.clockwise") + } + .help("Refresh counts") + .disabled(!canAccessPhotos) + #endif } } } @@ -223,6 +237,7 @@ struct HomeView: View { ) } .buttonStyle(.plain) + .interactiveCardHover() .disabled(!canAccessPhotos) if mode.usesScreenshotSort { @@ -271,7 +286,7 @@ struct HomeView: View { .foregroundStyle(.secondary) .padding(.horizontal, 10) .padding(.vertical, 6) - .background(Color(.tertiarySystemGroupedBackground), in: Capsule(style: .continuous)) + .background(AppColor.chip, in: Capsule(style: .continuous)) } .accessibilityLabel("Sort screenshots by \(screenshotSortOption.title)") } @@ -299,7 +314,7 @@ struct HomeView: View { .foregroundStyle(.secondary) .padding(.horizontal, 10) .padding(.vertical, 6) - .background(Color(.tertiarySystemGroupedBackground), in: Capsule(style: .continuous)) + .background(AppColor.chip, in: Capsule(style: .continuous)) } .accessibilityLabel("Sort videos by \(videoSortOption.subtitle)") } @@ -327,7 +342,7 @@ struct HomeView: View { .foregroundStyle(.secondary) .padding(.horizontal, 10) .padding(.vertical, 6) - .background(Color(.tertiarySystemGroupedBackground), in: Capsule(style: .continuous)) + .background(AppColor.chip, in: Capsule(style: .continuous)) } .accessibilityLabel("Sort similar groups by \(similarSortOption.subtitle)") } @@ -354,7 +369,7 @@ struct HomeView: View { refreshForCurrentMemoryOption() } } - .buttonStyle(.borderedProminent) + .prominentActionButton() } .frame(maxWidth: .infinity, alignment: .leading) .padding(16) @@ -401,9 +416,11 @@ struct HomeView: View { next[mode] = PhotoLibrary.fetchCounts(for: mode, reviewMemory: reviewMemoryOption) } let refreshedCounts = next - HomeCountCache.store(refreshedCounts, memoryOptionRawValue: reviewMemoryOptionRawValue) await MainActor.run { guard countRefreshID == refreshID else { return } + // Cache only a result that still reflects the current generation, so a + // superseded recount can't write stale-but-fresh-stamped counts. + HomeCountCache.store(refreshedCounts, memoryOptionRawValue: reviewMemoryOptionRawValue) counts.merge(refreshedCounts) { _, new in new } isRefreshingCounts = false } @@ -478,9 +495,9 @@ struct HomeView: View { Task.detached(priority: .userInitiated) { if !modesToRefresh.isEmpty { let refreshedCounts = Self.fetchCounts(for: modesToRefresh, reviewMemoryOption: reviewMemoryOption) - HomeCountCache.store(refreshedCounts, memoryOptionRawValue: reviewMemoryOptionRawValue) await MainActor.run { guard countRefreshID == refreshID else { return } + HomeCountCache.store(refreshedCounts, memoryOptionRawValue: reviewMemoryOptionRawValue) counts.merge(refreshedCounts) { _, new in new } isRefreshingCounts = false } @@ -489,9 +506,9 @@ struct HomeView: View { guard !deferredModes.isEmpty, !Task.isCancelled else { return } try? await Task.sleep(for: .seconds(2)) let deferredCounts = Self.fetchCounts(for: deferredModes, reviewMemoryOption: reviewMemoryOption) - HomeCountCache.store(deferredCounts, memoryOptionRawValue: reviewMemoryOptionRawValue) await MainActor.run { guard countRefreshID == refreshID else { return } + HomeCountCache.store(deferredCounts, memoryOptionRawValue: reviewMemoryOptionRawValue) counts.merge(deferredCounts) { _, new in new } } } @@ -549,20 +566,30 @@ private struct ActionCard: View { let reviewMemory: ReviewMemoryOption var reservesAccessorySpace = false + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + var body: some View { ZStack { RoundedRectangle(cornerRadius: 16, style: .continuous) .fill(AppColor.card) - Text(displayCount) - .font(.system(size: 96, weight: .heavy, design: .rounded)) - .foregroundStyle(.quaternary) - .lineLimit(1) - .minimumScaleFactor(0.5) - .frame(maxWidth: .infinity, alignment: .trailing) - .padding(.trailing, 16) - .allowsHitTesting(false) - .clipped() + // Decorative oversized count; drop it at accessibility text sizes so the + // title/subtitle have room and don't collide with it. + if dynamicTypeSize < .accessibility1 { + Text(displayCount) + .font(.system(size: 96, weight: .heavy, design: .rounded)) + #if os(macOS) + .monospacedDigit() + #endif + .foregroundStyle(.quaternary) + .lineLimit(1) + .minimumScaleFactor(0.5) + .frame(maxWidth: .infinity, alignment: .trailing) + .padding(.trailing, 16) + .allowsHitTesting(false) + .clipped() + .accessibilityHidden(true) + } HStack(alignment: .center, spacing: 16) { VStack(alignment: .leading, spacing: 4) { @@ -585,8 +612,22 @@ private struct ActionCard: View { } .padding(.horizontal, 16) } - .frame(height: reservesAccessorySpace ? 112 : 96) + .frame(minHeight: reservesAccessorySpace ? 112 : 96) .opacity(isDisabled ? 0.5 : 1.0) + .accessibilityElement(children: .ignore) + .accessibilityLabel(mode.title) + .accessibilityValue(accessibilityValueText) + .accessibilityHint(mode == .similar ? "Scans for similar photos" : "Opens \(mode.title) review") + } + + private var accessibilityValueText: String { + var parts = [mode.subtitle] + if let countSummary { + parts.append(countSummary) + } else if mode == .similar { + parts.append("Tap to scan") + } + return parts.joined(separator: ", ") } private var displayCount: String { diff --git a/SnipSnaps/Views/Tabs/SettingsView.swift b/SnipSnaps/Views/Tabs/SettingsView.swift index 7fb90da..0f1fd3c 100644 --- a/SnipSnaps/Views/Tabs/SettingsView.swift +++ b/SnipSnaps/Views/Tabs/SettingsView.swift @@ -11,6 +11,7 @@ import SwiftUI struct SettingsView: View { private let defaultReviewLimit = 20 + @Environment(\.openURL) private var openURL @AppStorage("reviewLimit") private var reviewLimit: Int = 20 @AppStorage("screenshotSortOption") private var screenshotSortOptionRawValue: String = ScreenshotSortOption.recent.rawValue @AppStorage("videoSortOption") private var videoSortOptionRawValue: String = VideoSortOption.largest.rawValue @@ -21,8 +22,19 @@ struct SettingsView: View { @State private var showResetLocalSettingsAlert = false var body: some View { + #if os(macOS) + // Rendered inside the native ⌘, Settings window — no NavigationStack/title. + settingsForm + #else NavigationStack { - Form { + settingsForm + .navigationTitle("Settings") + } + #endif + } + + private var settingsForm: some View { + Form { Section { Stepper(value: $reviewLimit, in: 10...100, step: 5) { HStack { @@ -50,7 +62,7 @@ struct SettingsView: View { Text("No deletions yet.") .foregroundStyle(.secondary) } else { - LabeledContent("Deleted photos", value: "\(totalDeletedCount)") + LabeledContent("Deleted items", value: "\(totalDeletedCount)") LabeledContent("Space freed", value: totalDeletedBytesText) } } @@ -75,7 +87,7 @@ struct SettingsView: View { Section("Support") { Button { if let url = URL(string: "https://github.com/Kyter-com/SnipSnaps") { - UIApplication.shared.open(url) + openURL(url) } } label: { HStack { @@ -89,7 +101,7 @@ struct SettingsView: View { Spacer() Image(systemName: "arrow.up.forward") .font(.footnote) - .foregroundStyle(Color(UIColor.tertiaryLabel)) + .foregroundStyle(.tertiary) } } settingsLink( @@ -131,7 +143,7 @@ struct SettingsView: View { ) } } - .navigationTitle("Settings") + .formStyle(.grouped) .alert("Reset Local Settings?", isPresented: $showResetLocalSettingsAlert) { Button("Reset", role: .destructive) { resetLocalSettings() @@ -140,7 +152,6 @@ struct SettingsView: View { } message: { Text("This clears your review size preference, sorting, review memory, and lifetime deleted stats on this device. Your photo library will not be changed.") } - } } private var hasLocalSettingsToReset: Bool { @@ -167,7 +178,7 @@ struct SettingsView: View { ) -> some View { Button { if let url = URL(string: url) { - UIApplication.shared.open(url) + openURL(url) } } label: { settingsRow( @@ -198,7 +209,7 @@ struct SettingsView: View { Image(systemName: trailingSystemImage) .font(.footnote) .fontWeight(trailingSystemImage == "chevron.right" ? .semibold : .regular) - .foregroundStyle(Color(UIColor.tertiaryLabel)) + .foregroundStyle(.tertiary) } } @@ -209,6 +220,9 @@ struct SettingsView: View { similarSortOptionRawValue = SimilarSortOption.recent.rawValue reviewMemoryOptionRawValue = ReviewMemoryOption.thirtyDays.rawValue PhotoReviewHistory.clearAll() + #if os(macOS) + FileReviewHistory.clearAll() + #endif totalDeletedCount = 0 totalDeletedBytes = 0 } @@ -219,7 +233,7 @@ struct SettingsView: View { let encodedSubject = subject.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" if let url = URL(string: "mailto:\(email)?subject=\(encodedSubject)") { - UIApplication.shared.open(url) + openURL(url) } } } diff --git a/docs/macos-port-plan.md b/docs/macos-port-plan.md new file mode 100644 index 0000000..15035be --- /dev/null +++ b/docs/macos-port-plan.md @@ -0,0 +1,236 @@ +# SnipSnaps for macOS — Implementation Plan + +**Status:** in progress — Phases 0–2, 4, 4b + a full **native UI/UX & desktop pass** implemented on branch `macos-port-phase-0-1` (local, **not pushed**). macOS build green + launches. +**Target:** native macOS destination on the existing SwiftUI target (`MACOSX_DEPLOYMENT_TARGET` now **26.0**; iOS stays 18.5) +**Distribution:** Mac App Store + App Sandbox (decided) +**Author:** planning doc, 2026-06-22 · last updated 2026-06-24 (UI/UX pass) + +## Native macOS UI/UX & desktop pass (2026-06-24) + +A 70-finding audit (native shell / keyboard-menu / Liquid Glass / Finder / bugs / a11y), adversarially verified, then implemented in 6 phases and re-reviewed (3 issues caught + fixed). **Decisions:** bumped the macOS floor to 26.0 (Tahoe) so Liquid Glass APIs are used directly inside `#if os(macOS)` (no availability gating; iOS deployment target is independent and untouched); replaced the iOS bottom `TabView` with a native `NavigationSplitView` sidebar on macOS; moved Settings to a `Settings{}` scene (⌘,). Delivered: + +- **Shell:** sidebar (Photos/Files), ⌘, Settings window, default/min window size, Mac title-bar Refresh, `.accentColor`→`.tint`, `@SceneStorage` sidebar selection. +- **Keyboard + menu bar:** `Commands/AppCommands.swift` (Edit▸Undo ⌘Z, File▸Add Folder ⇧⌘O, Review menu, Help URLs) bridged via `focusedSceneValue`; ←/→/Delete/Space/Esc/Return shortcuts on all three review surfaces (rule: `.keyboardShortcut` on Buttons, not `.onKeyPress` on non-focusable containers). +- **Finder integration (Files):** Reveal in Finder, Quick Look (Space), Open (double-click/⌘O), right-click context menus, drag-a-folder-to-add, sort menu, Show-in-Trash. +- **Liquid Glass:** `Design/GlassStyle.swift` (glass buttons, glass info-chips, hover highlight + pointer cursor), adaptive `AppColor.cardEdge`, recessed Mac card-hierarchy colors (fixed near-invisible Light-mode cards), monospaced hero digits. +- **Bug fixes:** iCloud `.icloud`-placeholder skip + on-disk allocated size (E1); 50k-cap truncation now reported via `ScanResult` instead of silent-empty (E2); cancellable scans (E3); security-scoped-bookmark transient-preserve / removable-dead / re-grant-upgrade / inaccessible-folder warning (E4–E6); HomeView count-cache generation race (E7); trash/hash failure surfacing + memory-option snapshot (E8/E10); duplicate bucketing keyed on logical (not allocated) size. +- **Accessibility:** Reduce Motion, VoiceOver (decorative count hidden + merged cards + Files swipe actions), Dynamic Type growth, hit targets, `.help` tooltips, localized screenshot-name detection. +- **macOS app icon:** `mac_*.png` squircle set generated from the iOS artwork, wired into `AppIcon.appiconset/Contents.json`. + +**Still to verify on a real/populated Mac (couldn't here):** destructive Files trash flow, security-scoped-bookmark relaunch round-trips, Similar-matching accuracy, and an iOS regression build (shared-file edits are `#if os(macOS)`-gated/cross-platform-safe but unbuilt here). Deferred: AppKit video/Live-Photo playback on macOS (E11), Photos-side Quick Look + VoiceOver swipe actions, String Catalog pluralization. + +--- + +## Progress log (pause point) + +All work is on branch `macos-port-phase-0-1`. Commits, newest first: + +| Commit | What | +|---|---| +| `9f7c826` | Phase 4b — Files "Remember Reviewed" (`FileReviewHistory`) + duplicate detection (size-bucket + SHA-256) | +| `b902724` | Files↔Photos cohesion — shared `reviewLimit`, lifetime `totalDeletedCount`/`Bytes`, post-review count refresh | +| `2a684b7` | Phase 4 — on-disk Files cleanup surface (`SnipSnaps/Files/*`, `Views/Files/*`, macOS-only) | +| `5259bf4` | Phase 2 — Similar/duplicate scan on macOS (shared `CGImage` core) | +| `fd8ee7b` | docs — record the Form layout-cycle crash + fix | +| `7647f1a` | Crash fix — `.formStyle(.grouped)` in Settings | +| `45264df` | Phase 0–1 — native Mac target, sandbox entitlements, UIKit-only fixes | +| `1129e0e` | This plan doc | + +**Done:** Phase 0–1, Phase 2, Phase 4 (incl. Duplicates), and full Files↔Photos settings/stats cohesion (the original plan folded Phase 3's keyboard shortcuts into the Files review early). + +**Deviations from the original plan:** +- Built order was 0–1 → 2 → **4 → 4b**, skipping Phase 3 (polish) at the user's request to reach the Files feature sooner. +- Hit and fixed a macOS-only launch crash not anticipated in the plan: the default columnar `Form` style → AppKit "Update Constraints in Window pass" abort (see risk #10). Fix: `.formStyle(.grouped)`. +- A separate per-SDK entitlements file (`SnipSnaps-macOS.entitlements` via `CODE_SIGN_ENTITLEMENTS[sdk=macosx*]`) is used so iOS keeps its own; the project already had `ENABLE_APP_SANDBOX=YES` / `ENABLE_USER_SELECTED_FILES` build settings (flipped to `readwrite` for Phase 4). + +**Verification reality (this machine):** +- macOS builds verified green at every phase; app launches and the Files surface works live on a real Downloads folder (real scan counts). +- **iOS not buildable here** — the iOS 26.5 platform isn't installed in this Xcode, so iOS was verified only by construction (changes guarded/behavior-preserving). Build iOS on a normal setup before shipping. +- **Photos features not runtime-tested** — this Mac's Photos library is empty (0 assets), so Similar-matching accuracy on macOS is unverified. +- **Destructive Files paths not driven by Claude** — Trash, duplicate review, and remember-reviewed skip were left for the user to exercise (avoiding deletion of real files); they compile and the app launches. + +**Remaining:** +- Phase 3 — move Settings into a macOS `Settings`/⌘, window; Photos-side review keyboard shortcuts. +- Near-duplicate *image* matching for Files (reuse the Phase 2 dHash+Vision core fed `CGImageSource` from disk). +- Runtime-confirm the destructive Files flows + Similar matching on a Mac with a populated Photos library; iOS regression build. +- A macOS app-icon slot in the asset catalog before shipping. + +--- + +## Goal + +Bring SnipSnaps to macOS with **two cleanup surfaces**: + +1. **Photos** — the existing iCloud/Photos review-and-delete experience, ported to Mac. +2. **Files (new)** — review and clean up loose files on disk: Downloads, Desktop, Documents, on-disk screenshots, big/old/duplicate junk. + +Distribution is **Mac App Store, sandboxed** — which has one important consequence baked into the whole plan: the Files surface **cannot silently scan** `~/Downloads`, `~/Desktop`, `~/Documents`. The user grants each folder once via a system picker; we persist that grant with a security-scoped bookmark. This is the standard, App-Review-approved model (Gemini 2, CleanMyMac ship this way). + +--- + +## Target & distribution decision (settled) + +- **Native macOS destination** on the existing single SwiftUI target — add **"Mac"** to *Supported Destinations*. The repo is already set up for this (`SDKROOT = auto`, existing `#if canImport(UIKit) / #elseif canImport(AppKit)` seams, `typealias PlatformImage = NSImage`). +- **Not** Mac Catalyst (scaled-iPad feel, awkward for a Finder-style file UI) and **not** "Designed for iPad" (strict iOS sandbox → no real file access → Files feature impossible). +- **Mac App Store + App Sandbox.** App Review permits destructive cleanup utilities; the only constraint is the user-granted-folder access model above. + +--- + +## What ports vs. what's new (grounded in the current code) + +| Area | Verdict | +|---|---| +| `PhotoLibrary` fetch/count/sort/delete (`Utils/Photos.swift`) | **Runs on macOS as-is.** PhotoKit + `PHAssetChangeRequest.deleteAssets` via `performChanges` (`Photos.swift:688`) is first-class on Mac; the existing `PHPhotosError.userCancelled` handling already matches the Mac system confirmation. | +| `PhotoReviewHistory`, `ReviewMode`, value types, image cache (NSImage branch) | **Reusable as-is.** | +| Similar/duplicate scan (`fetchSimilarPhotoGroups`, `thumbnailImage`, `differenceHash`, `featurePrint`) | **Currently a no-op on macOS** — wrapped in `#if canImport(UIKit)`, AppKit branch returns `[]`. Needs a CGImage refactor (Phase 2). | +| Unguarded UIKit (haptics, `UIApplication.open`, UIKit colors) | **Won't compile on Mac.** Must guard/replace (Phase 1). | +| iOS-only SwiftUI modifiers (`navigationBarTitleDisplayMode`, `topBar*`, `.tabBar`, `fullScreenCover`, `presentationDetents`) | **Won't compile on Mac.** Must guard/swap (Phase 1). | +| Files cleanup surface | **Net-new.** ~60–70% of the *shape* clones from the Photos flow; ~0% ports by direct call (everything is bound to `PHAsset`). | + +--- + +## Phase 0 — Project config + entitlements *(prerequisite, ~1 day)* + +Nothing compiles or runs on Mac until this is done. + +- [ ] Add **"Mac"** to the target's *Supported Destinations*; set `MACOSX_DEPLOYMENT_TARGET` (current `SUPPORTED_PLATFORMS` is `iphoneos iphonesimulator` only; `SDKROOT = auto` already correct). +- [ ] Populate `SnipSnaps/SnipSnaps.entitlements` (currently empty ``): + - `com.apple.security.app-sandbox` = `true` *(mandatory for Mac App Store)* + - `com.apple.security.personal-information.photos-library` = `true` *(with the sandbox on, the Photos library is unreachable without this)* + - `com.apple.security.files.user-selected.read-write` = `true` *(read-write to user-picked Downloads/Desktop/Documents folders)* + - `com.apple.security.files.bookmarks.app-scope` = `true` *(persist folder grants across launches)* + - `com.apple.security.files.downloads.read-write` = `true` *(optional convenience: pre-grants Downloads with no picker; no Desktop/Documents equivalent exists)* +- [ ] Add `Info.plist` usage strings: `NSDesktopFolderUsageDescription`, `NSDocumentsFolderUsageDescription`, `NSDownloadsFolderUsageDescription` (keep existing `NSPhotoLibraryUsageDescription`). + +> ⚠️ With the sandbox on, any file access outside the container that isn't a granted/bookmarked URL **silently fails** — it is not a build error. Route all file access through granted URLs. + +**Payoff:** the project builds for a Mac destination; gates everything else. + +--- + +## Phase 1 — Make the Photos build compile + run on macOS *(cheap win, ~1–2 days)* + +Fix the unguarded platform code so the existing Photos features build and run on Mac. **Every mode except Similar works immediately** after this, because the engine and PhotoKit deletion are already cross-platform. + +**Unguarded UIKit (breaks the build):** +- [ ] `ContentView.swift:40` — wrap `UIImpactFeedbackGenerator(...)` in `#if canImport(UIKit)`. +- [ ] `SettingsView.swift:78`, `:170`, `:222` — replace `UIApplication.shared.open(url)` with SwiftUI `@Environment(\.openURL)` (cross-platform, cleanest). +- [ ] `SettingsView.swift:92`, `:201`, `ScreenshotDemoView.swift:371` — replace `Color(UIColor.tertiaryLabel)` with `.foregroundStyle(.tertiary)`. +- [ ] `Design/AppColors.swift` — give `background`/`card` platform-conditional definitions. `Color(.systemGroupedBackground)` / `.secondarySystemGroupedBackground` don't exist on AppKit. Map AppKit → `Color(nsColor: .windowBackgroundColor)` / `.controlBackgroundColor` (or define an asset-catalog color set so `AppColor` is one cross-platform source of truth). Same for inline `Color(.tertiarySystemGroupedBackground)` in `HomeView` (3×), `ScreenshotDemoView` (4×), and `Color(.secondarySystemBackground)` / `Color(.tertiarySystemFill)` in `ReviewSessionView`'s card views. + +**iOS-only SwiftUI modifiers (`#if os(iOS)` or swap):** +- [ ] `.navigationBarTitleDisplayMode(...)` — `ReviewSessionView` (`:303`, `:1063`, `:2093`) + `HomeView:175`. +- [ ] `ToolbarItem(placement: .topBarLeading/.topBarTrailing)` → cross-platform placements (`.navigation`/`.primaryAction`/`.cancellationAction`) — `ReviewSessionView` (`:313`, `:1067`, `:2095`) + `HomeView:177`. +- [ ] `.toolbar(.hidden, for: .tabBar)` — `ReviewSessionView:323`, `:1078` (omit on macOS). +- [ ] `.fullScreenCover(item:)` → `.sheet(item:)` on macOS — `ReviewSessionView:1079`. +- [ ] `statusBarHidden` (`ReviewSessionView:1981`) and `.presentationDetents([...])` (`:2100`) — wrap in `#if os(iOS)`. +- [ ] Optionally `#if os(iOS)` out the DEBUG-only `ScreenshotDemoView` for the first Mac build. + +**Payoff:** a running Mac app that fetches, counts, reviews, and deletes Photos across all modes except Similar. + +--- + +## Phase 2 — Restore Similar/duplicate detection on macOS *(~2–3 days)* + +The headline 1.0.4 "Similar" mode is a silent empty screen on Mac until this is done. + +- [ ] Refactor `thumbnailImage` (`Photos.swift:1296`), `featurePrint` (`:1342`), `differenceHash` (`:1373`) to operate on **`CGImage`** instead of `UIImage`. `differenceHash` is already pure CoreGraphics; Vision (`VNGenerateImageFeaturePrintRequest`) is fully cross-platform. +- [ ] Lift the `fetchSimilarPhotoGroups` body (`Photos.swift:515+`) out of `#if canImport(UIKit)`. The **only** platform-conditional step becomes `PHAsset → CGImage` (request a `CGImage` directly, or `NSImage.cgImage(forProposedRect:context:hints:)` on the AppKit side). +- [ ] Re-tune thresholds against Mac-rendered thumbnails (dHash hamming ≤ 14, Vision distance ≤ 0.35) — NSImage→CGImage color-space differences can shift values. + +**Payoff:** Photos surface reaches feature parity on Mac. + +> Video and Live Photo playback are already `#if`-gated and degrade to a still image — acceptable for v1. AppKit playback parity (`AVPlayerView`, `PHLivePhotoView` on macOS) is optional later work. + +--- + +## Phase 3 — Mac-native review UX polish *(~1–2 days)* + +The touch-tuned swipe loop feels broken with a mouse and gives no haptics on Mac. + +- [ ] Add keyboard shortcuts via `.keyboardShortcut` / `.onKeyPress`: ← or Delete = delete, → or Return = keep, Cmd-Z = undo, Space = preview. +- [ ] Make the decision-bar buttons the primary interaction; keep the `DragGesture` swipe for trackpad users. +- [ ] Optional: `NSHapticFeedbackManager` for trackpad feedback (haptics already no-op on macOS). + +**Payoff:** feels like a Mac app, not a stretched phone app. + +> **Milestone candidate:** Phases 0–3 are a shippable "SnipSnaps for Mac — Photos" release on their own. + +--- + +## Phase 4 — Net-new Files cleanup surface *(the harder half, ~8–15 days)* + +Build a parallel engine + UI, keyed on a `FileItem` value type, cloning the proven Photos patterns. Gate behind `#if os(macOS)` so iOS is untouched. + +### 4a. Folder access + persistence (the platform plumbing) +- [ ] New "Files" tab (SF Symbol `folder`) alongside Home + Settings, `#if os(macOS)`. +- [ ] Empty-state onboarding: "Choose a folder" with quick presets for Downloads / Desktop / Documents, each opening `.fileImporter(allowedContentTypes: [.folder])` / `NSOpenPanel(canChooseDirectories: true)`. +- [ ] On grant: create an **app-scoped security-scoped bookmark** (`url.bookmarkData(options: .withSecurityScope, …)`), persist it; list granted folders with a revoke control. +- [ ] On launch: resolve bookmarks (`URL(resolvingBookmarkData:options:.withSecurityScope, bookmarkDataIsStale:&stale)`), recreate if stale. +- [ ] Wrap **every** access in `startAccessingSecurityScopedResource()` / `stopAccessingSecurityScopedResource()` — use `defer` rigorously (leaks otherwise). + +### 4b. Scan engine (`FileLibrary`, the on-disk analog of `PhotoLibrary`) +- [ ] Enumerate granted folders via `FileManager.enumerator(at:includingPropertiesForKeys:options:)` prefetching `.fileSizeKey`, `.contentModificationDateKey`, `.creationDateKey`, `.isDirectoryKey`, `.contentTypeKey`. +- [ ] Build `[FileItem]` (url, size, created, modified, contentType). Run off the main actor with the existing `SimilarPhotoScanProgress`-style progress + partial-results streaming and cancellation (mirror the `scanTask` pattern). +- [ ] **Categories** (the file analog of `ReviewMode`): + - **Large Files** — size desc, configurable threshold (e.g. ≥ 50 MB). + - **Old Files** — `modificationDate` older than N months. + - **On-Disk Screenshots** — UTType `.png`/`.image` + filename match (`Screenshot`/`Screen Shot`, locale-aware) **OR** `kMDItemIsScreenCapture` Spotlight metadata. Combine signals; let the user confirm. + - **Downloads Clutter** — everything in Downloads by age/size. + - **Duplicate Files** — size-bucket pre-pass, then SHA-256 only within same-size buckets. For near-duplicate images, reuse the dHash + Vision pipeline from Phase 2, fed `CGImageSourceCreateWithURL` instead of `PHCachingImageManager`. + +### 4c. Review UI (`FileReviewSessionView`) +- [ ] Clone the swipe card stack + decision bar + deferred-delete summary + per-item Undo, keyed on `FileItem`. +- [ ] Thumbnails via `QLThumbnailGenerator` (any file type) or `CGImageSource` (images). Card shows name, size, path, dates. +- [ ] Keyboard shortcuts (same scheme as Phase 3). + +### 4d. Deletion (safety-critical) +- [ ] Mark in-memory during review (fully reversible — nothing touched on disk); only the final **"Move N to Trash"** button acts. +- [ ] Delete via `FileManager.trashItem(at:resultingItemURL:)` — **never** `removeItem` (permanent). Capture `resultingItemURL` (files are renamed on collision). +- [ ] Persist `[originalURL → resultingItemURL]` pairs for a post-trash Undo (`moveItem`); macOS Cmd-Z / Restore-from-Trash also work, so this is nice-to-have. +- [ ] **Do not** call `trashItem` inside an `NSFileCoordinator.coordinate` block (deadlock). +- [ ] Exclude `.app` bundles, packages, symlinks, dotfiles, and iCloud-evicted `.icloud` placeholders. + +### 4e. File-scoped review memory +- [ ] Extend the `PhotoReviewHistory` design, re-keyed from `PHAsset.localIdentifier` to a **stable file identity** (`.fileResourceIdentifierKey` / volume-id+inode, or bookmark data) since paths change. Same expiration windows. + +**Payoff:** delivers your distinctive second goal — cleaning loose Downloads/Desktop/Documents junk. + +--- + +## Recommended architectural refactor (optional but worth it) + +Before/while building Phase 4, factor the decision/swipe/summary/undo state machine into a generic **`ReviewEngine`** with a small protocol (`id`, thumbnail provider, `sizeBytes`, dates, delete action). Have both `PHAsset` and `FileItem` conform. Then Photos and Files share one engine + one card view, with platform-specific input (touch+haptics on iOS, keyboard+trackpad on macOS). Turns "copy-paste" into real reuse. + +--- + +## Effort summary (solo) + +| Scope | Effort | +|---|---| +| Phases 0–3: Photos on macOS | ~1.5–2.5 weeks | +| Phase 4: Files surface | ~2–3 weeks | +| **Both, shipped well** | **~4–6 weeks** | + +Suggested sequencing: ship **Phases 0–3 as "SnipSnaps for Mac (Photos)"** first, then follow with the Files surface as a feature update. + +--- + +## Top risks / gotchas + +1. **Similar mode silently empty on Mac** until the Phase 2 CGImage refactor — don't ship the Mac build with it visible-but-broken; port it or hide it. +2. **Empty entitlements file today** — with the sandbox on, missing `photos-library` / file entitlements = silent runtime denial, not a build error. +3. **Grant folders, not individual files** — a single-file sandbox extension doesn't grant write to the parent dir, and trash is a *move* out of it, so trashing a lone picked file fails with `NSFileWriteNoPermissionError`. +4. **No full-disk auto-scan on the App Store** — no Desktop/Documents entitlement exists (only Downloads), so the user grants each folder. Set product expectations: this is a per-folder model by design. +5. **Destructive safety on files > Photos** — Photos has Recently Deleted (30-day); files must use `trashItem` (recoverable), exclude bundles/symlinks/dotfiles/`.icloud` placeholders. +6. **Security-scoped bookmark hygiene** — persist them or the user re-picks every launch; handle `bookmarkDataIsStale`; balance every `start`/`stop` with `defer`. +7. **iCloud "Desktop & Documents Folders"** — enumerating/hashing dataless placeholders can pull gigabytes; detect `NSURLUbiquitousItemIsDownloadedKey` and skip/warn. +8. **Touch-tuned swipe UX** feels wrong with a mouse — Phase 3 keyboard shortcuts + button-first interaction are not optional polish, they're required for a usable desktop loop. +9. **App Store privacy labels (2026 rules)** — disclose local data handling accurately. +10. **macOS `Form` default style crashes at launch** *(found + fixed in Phase 1)* — SwiftUI's default columnar `Form` style on macOS can trip an AppKit "Update Constraints in Window pass" cycle (`NSGenericException`/SIGABRT) once a form has enough rows. `TabView` eagerly lays out all tabs on macOS, so a crash in any tab aborts the whole app at launch. Fix: `.formStyle(.grouped)` (no-op on iOS, where grouped is already the default). Watch for the same cycle in any future macOS `Form`/`List`-heavy screens (e.g. the Files-folder settings). + +--- + +## Verification notes + +The macOS platform facts above were web-verified against current Apple docs (entitlement key reference, "Requesting Changes to the Photo Library," App Review Guideline 2.4.5(i)) and four adversarial checks (sandbox file delete, PhotoKit delete UX, App Review approval, target choice) — all returned *confirmed-with-conditions*; the conditions are folded into the phases and risks above.