├── .gitignore ├── Clipchop.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── swiftpm │ │ └── Package.resolved └── xcshareddata │ └── xcschemes │ ├── Clipchop.xcscheme │ └── ClipchopTests.xcscheme ├── Clipchop ├── AppDelegate+UNNotifications.swift ├── AppDelegate.swift ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon │ │ ├── AppIcon-Aerugo.appiconset │ │ │ ├── Contents.json │ │ │ ├── icon_128x128.png │ │ │ ├── icon_128x128@2x@2x.png │ │ │ ├── icon_16x16.png │ │ │ ├── icon_16x16@2x@2x.png │ │ │ ├── icon_256x256.png │ │ │ ├── icon_256x256@2x@2x.png │ │ │ ├── icon_32x32.png │ │ │ ├── icon_32x32@2x@2x.png │ │ │ ├── icon_512x512.png │ │ │ └── icon_512x512@2x@2x.png │ │ ├── AppIcon-Beta.appiconset │ │ │ ├── Contents.json │ │ │ ├── icon_128x128.png │ │ │ ├── icon_128x128@2x@2x.png │ │ │ ├── icon_16x16.png │ │ │ ├── icon_16x16@2x@2x.png │ │ │ ├── icon_256x256.png │ │ │ ├── icon_256x256@2x@2x.png │ │ │ ├── icon_32x32.png │ │ │ ├── icon_32x32@2x@2x.png │ │ │ ├── icon_512x512.png │ │ │ └── icon_512x512@2x@2x.png │ │ ├── AppIcon-HoloGram.appiconset │ │ │ ├── Contents.json │ │ │ ├── icon_128x128.png │ │ │ ├── icon_128x128@2x@2x.png │ │ │ ├── icon_16x16.png │ │ │ ├── icon_16x16@2x@2x.png │ │ │ ├── icon_256x256.png │ │ │ ├── icon_256x256@2x@2x.png │ │ │ ├── icon_32x32.png │ │ │ ├── icon_32x32@2x@2x.png │ │ │ ├── icon_512x512.png │ │ │ └── icon_512x512@2x@2x.png │ │ ├── AppIcon-Stable.appiconset │ │ │ ├── Contents.json │ │ │ ├── icon_128x128.png │ │ │ ├── icon_128x128@2x.png │ │ │ ├── icon_16x16.png │ │ │ ├── icon_16x16@2x.png │ │ │ ├── icon_256x256.png │ │ │ ├── icon_256x256@2x.png │ │ │ ├── icon_32x32.png │ │ │ ├── icon_32x32@2x.png │ │ │ ├── icon_512x512.png │ │ │ └── icon_512x512@2x.png │ │ └── Contents.json │ ├── Contents.json │ ├── Empty.imageset │ │ ├── Contents.json │ │ └── empty.png │ └── clipchop.fill.symbolset │ │ ├── Contents.json │ │ └── clipchop.fill.svg ├── Assets │ ├── AppIcon.swift │ ├── InstalledApp.swift │ ├── Sound.swift │ ├── SoundPlayer.swift │ └── Sounds │ │ ├── happy-pop.mp3 │ │ ├── marimba-bloop.mp3 │ │ └── tap-notification.mp3 ├── Clipboard │ ├── ClipHistorySearch.swift │ ├── Clipboard Model │ │ ├── ClipboardDataProvider.swift │ │ ├── ClipboardModelEditor.swift │ │ └── ClipboardModelManager.swift │ ├── ClipboardController.swift │ ├── ClipboardManager.swift │ ├── Data Storage │ │ ├── ClipboardContent.swift │ │ ├── ClipboardHistory.swift │ │ ├── ClipboardModel.xcdatamodeld │ │ │ └── ClipboardModel.xcdatamodel │ │ │ │ └── contents │ │ └── MetadataCache.swift │ ├── FolderManager.swift │ └── Formatter.swift ├── Clipchop.entitlements ├── ClipchopApp.swift ├── Extensions │ ├── AppKit │ │ ├── NSImage+Extensions.swift │ │ ├── NSPasteboard.PasteboardType+Extensions.swift │ │ └── NSWindow+Extensions.swift │ ├── Color+Extensions.swift │ ├── Defaults+Extensions.swift │ ├── Defaults+Structures.swift │ ├── Foundation │ │ ├── Bundle+Extensions.swift │ │ ├── MutableCollection+Extensions.swift │ │ ├── Notification+Extensions.swift │ │ └── Set+Extensions.swift │ ├── KeyboardShortcuts+Extensions.swift │ ├── SwiftUI │ │ ├── EnvironmentValues+Extensions.swift │ │ ├── Transision+Extensions.swift │ │ ├── View+Extensions.swift │ │ └── View+Functions.swift │ └── UNNotification+Extensions.swift ├── Info.plist ├── Localizable.xcstrings ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json ├── Utilities │ ├── DefaultsStack.swift │ ├── LifecycleHandler.swift │ ├── Logger.swift │ ├── NotificationManager.swift │ ├── ObservationTrackingStream.swift │ ├── PerimissionsManager.swift │ ├── Throttler.swift │ ├── ViewController.swift │ └── getCursorPosition.swift └── Views │ ├── Beginning │ ├── BeginningView.swift │ ├── BeginningViewController.swift │ ├── Pages │ │ ├── BeginningAllSetPage.swift │ │ ├── BeginningCustomizationPage.swift │ │ ├── BeginningHelloPage.swift │ │ ├── BeginningPermissionsPage.swift │ │ ├── BeginningShortcutsPage.swift │ │ └── BeginningTutorialPage.swift │ └── RoamingButton.swift │ ├── Clip History │ ├── ClipHistoryPanel.swift │ ├── ClipHistoryPanelController.swift │ ├── ClipHistoryView.swift │ ├── CollapsedPages.swift │ ├── EmptyStateView.swift │ ├── Pages │ │ ├── CollapsedPages.swift │ │ ├── EmptyStatePages.swift │ │ ├── ExpandedPages.swift │ │ └── TabButton.swift │ └── Preview Cards │ │ ├── CardPreviewView 2.swift │ │ ├── CardPreviewView.swift │ │ ├── DragManager.swift │ │ ├── Page │ │ ├── ColorPreviewPage.swift │ │ ├── HTMLPreviewPags.swift │ │ ├── RTFPreviewPage.swift │ │ └── WebLinkPreviewPage.swift │ │ └── PreviewContentView.swift │ ├── Fundamentals │ ├── FormNavigationLinkLabel.swift │ ├── ListEmbeddedForm.swift │ ├── SearchFieldWrapper.swift │ ├── StaleView.swift │ ├── VisualEffectView.swift │ └── WrappingHStack.swift │ ├── Luminare Settings │ ├── Custom Luminare Component │ │ ├── CustomLuminareList.swift │ │ └── CustomLuminarePicker.swift │ ├── Luminare Settings Pages │ │ ├── LuminareAboutSettings.swift │ │ ├── LuminareCategorizationSettings.swift │ │ ├── LuminareClipboardSettings.swift │ │ ├── LuminareCustomizationSettings.swift │ │ ├── LuminareExcludedAppsSettings.swift │ │ ├── LuminareFolderSettings.swift │ │ ├── LuminareGeneralSettings.swift │ │ └── LuminareKeyboardShortcutsSettings.swift │ ├── LuminareAcknowledgementsView.swift │ ├── LuminareManager.swift │ └── LuminarePreferredColorSchemePicker.swift │ ├── Menu Bar │ ├── MenuBarIconView.swift │ └── MenuBarView.swift │ └── Settings │ ├── AcknowledgementsView.swift │ ├── AppVersionView.swift │ ├── CopyrightsView.swift │ ├── NavigationEntry.swift │ ├── Pages │ ├── AboutSettingsPage.swift │ ├── CategorizationPage.swift │ ├── ClipboardSettingsPage.swift │ ├── CustomizationSettingsPage.swift │ ├── ExcludedAppsSettingsPage.swift │ ├── GeneralSettingsPage.swift │ ├── SyncingSettingsPage.swift │ └── TestSettingsPage.swift │ ├── Sections │ ├── Categorization │ │ ├── CategorizationSection.swift │ │ └── RoundedTagView.swift │ ├── Clipboard │ │ ├── ClipboardBehaviorsSection.swift │ │ └── KeyboardShortcutsSection.swift │ ├── ColoredPickerRow.swift │ ├── Customization │ │ └── AppearanceSection.swift │ ├── Excluded Apps │ │ ├── ExcludedAppListSection.swift │ │ └── InstalledAppsMenu.swift │ ├── FormSectionListContainer.swift │ ├── General │ │ ├── GlobalBehaviorsSection.swift │ │ └── PermissionsSection.swift │ └── PreferredColorSchemePicker.swift │ └── SettingsView.swift ├── ClipchopTests └── ClipchopTests.swift ├── Docs ├── ADD_A_LOCALIZATION.md ├── Contents │ └── 简体中文 │ │ ├── Overview.png │ │ └── Overview2.png └── 简体中文.md ├── LICENSE └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | # macOS 2 | *.DS_Store 3 | 4 | # Xcode 5 | *.pbxuser 6 | *.mode1v3 7 | *.mode2v3 8 | *.perspectivev3 9 | *.xcuserstate 10 | xcuserdata 11 | Build/ 12 | -------------------------------------------------------------------------------- /Clipchop.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Clipchop.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Clipchop.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "4729f3c6cd2158907a5b1e656e405b44cd642707b3dd75aad824849595884dec", 3 | "pins" : [ 4 | { 5 | "identity" : "defaults", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/sindresorhus/Defaults", 8 | "state" : { 9 | "branch" : "main", 10 | "revision" : "d8a954e69ff13b0f7805f3757c8f8d0c8ef5a8cb" 11 | } 12 | }, 13 | { 14 | "identity" : "fulldiskaccess", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/inket/FullDiskAccess", 17 | "state" : { 18 | "revision" : "846e04ea2b84fce843f47d7e7f3421189221829c", 19 | "version" : "1.0.0" 20 | } 21 | }, 22 | { 23 | "identity" : "fuse-swift", 24 | "kind" : "remoteSourceControl", 25 | "location" : "https://github.com/krisk/fuse-swift", 26 | "state" : { 27 | "revision" : "26ba868691b2d8b7bf2b1322951eb591be70ccca", 28 | "version" : "1.4.0" 29 | } 30 | }, 31 | { 32 | "identity" : "keyboardshortcuts", 33 | "kind" : "remoteSourceControl", 34 | "location" : "https://github.com/sindresorhus/KeyboardShortcuts", 35 | "state" : { 36 | "revision" : "2e5f15581fefb821d4b366e57d817be8bf12aa58", 37 | "version" : "2.0.1" 38 | } 39 | }, 40 | { 41 | "identity" : "launchatlogin", 42 | "kind" : "remoteSourceControl", 43 | "location" : "https://github.com/sindresorhus/LaunchAtLogin", 44 | "state" : { 45 | "branch" : "main", 46 | "revision" : "9a894d799269cb591037f9f9cb0961510d4dca81" 47 | } 48 | }, 49 | { 50 | "identity" : "menubarextraaccess", 51 | "kind" : "remoteSourceControl", 52 | "location" : "https://github.com/orchetect/MenuBarExtraAccess", 53 | "state" : { 54 | "revision" : "f5896b47e15e114975897354c7e1082c51a2bffd", 55 | "version" : "1.0.5" 56 | } 57 | }, 58 | { 59 | "identity" : "sauce", 60 | "kind" : "remoteSourceControl", 61 | "location" : "https://github.com/Clipy/Sauce", 62 | "state" : { 63 | "branch" : "master", 64 | "revision" : "71ff8de6292653a27fa98e901e447e47041e6fad" 65 | } 66 | }, 67 | { 68 | "identity" : "settingsaccess", 69 | "kind" : "remoteSourceControl", 70 | "location" : "https://github.com/orchetect/SettingsAccess", 71 | "state" : { 72 | "revision" : "f0b57b4c4c0de66b9f4d6c41d39f1d6ede0c9891", 73 | "version" : "2.0.0" 74 | } 75 | }, 76 | { 77 | "identity" : "sfsafesymbols", 78 | "kind" : "remoteSourceControl", 79 | "location" : "https://github.com/SFSafeSymbols/SFSafeSymbols", 80 | "state" : { 81 | "revision" : "e2e28f4e56e1769c2ec3c61c9355fc64eb7a535a", 82 | "version" : "5.3.0" 83 | } 84 | }, 85 | { 86 | "identity" : "swift-algorithms", 87 | "kind" : "remoteSourceControl", 88 | "location" : "https://github.com/apple/swift-algorithms.git", 89 | "state" : { 90 | "revision" : "f6919dfc309e7f1b56224378b11e28bab5bccc42", 91 | "version" : "1.2.0" 92 | } 93 | }, 94 | { 95 | "identity" : "swift-numerics", 96 | "kind" : "remoteSourceControl", 97 | "location" : "https://github.com/apple/swift-numerics.git", 98 | "state" : { 99 | "revision" : "0a5bc04095a675662cf24757cc0640aa2204253b", 100 | "version" : "1.0.2" 101 | } 102 | }, 103 | { 104 | "identity" : "swifthexcolors", 105 | "kind" : "remoteSourceControl", 106 | "location" : "https://github.com/thii/SwiftHEXColors", 107 | "state" : { 108 | "revision" : "1f886cb20fedda14a5ac75efd09bc99c95b43a18", 109 | "version" : "1.4.1" 110 | } 111 | }, 112 | { 113 | "identity" : "swiftui-introspect", 114 | "kind" : "remoteSourceControl", 115 | "location" : "https://github.com/siteline/swiftui-introspect", 116 | "state" : { 117 | "revision" : "668a65735751432b640260c56dfa621cec568368", 118 | "version" : "1.2.0" 119 | } 120 | }, 121 | { 122 | "identity" : "swiftui-variadic-views", 123 | "kind" : "remoteSourceControl", 124 | "location" : "https://github.com/lorenzofiamingo/swiftui-variadic-views", 125 | "state" : { 126 | "revision" : "af67d0e85bd2b499fbfb4cda834117e7c52b12b0", 127 | "version" : "1.0.0" 128 | } 129 | }, 130 | { 131 | "identity" : "swiftui-windowmanagement", 132 | "kind" : "remoteSourceControl", 133 | "location" : "https://github.com/Wouter01/SwiftUI-WindowManagement", 134 | "state" : { 135 | "revision" : "adbebf5d7df325f3d7bf07dc832e5e162a9003f5", 136 | "version" : "2.1.1" 137 | } 138 | } 139 | ], 140 | "version" : 3 141 | } 142 | -------------------------------------------------------------------------------- /Clipchop.xcodeproj/xcshareddata/xcschemes/Clipchop.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 32 | 33 | 36 | 42 | 43 | 44 | 45 | 46 | 56 | 58 | 64 | 65 | 66 | 67 | 73 | 75 | 81 | 82 | 83 | 84 | 86 | 87 | 90 | 91 | 92 | -------------------------------------------------------------------------------- /Clipchop.xcodeproj/xcshareddata/xcschemes/ClipchopTests.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 17 | 20 | 26 | 27 | 28 | 29 | 30 | 40 | 41 | 47 | 48 | 50 | 51 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /Clipchop/AppDelegate+UNNotifications.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // Clipchop 4 | // 5 | // Created by Xinshao_Air on 2024/8/13. 6 | // 7 | 8 | import SwiftUI 9 | import UserNotifications 10 | 11 | extension AppDelegate: UNUserNotificationCenterDelegate { 12 | // Implementation is necessary to show notifications even when the app has focus! 13 | func userNotificationCenter( 14 | _: UNUserNotificationCenter, 15 | willPresent _: UNNotification, 16 | withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> () 17 | ) { 18 | completionHandler([.banner]) 19 | } 20 | 21 | static func requestNotificationAuthorization() { 22 | UNUserNotificationCenter.current().requestAuthorization( 23 | options: [.alert] 24 | ) { accepted, error in 25 | if !accepted { 26 | print("User Notification access denied.") 27 | } 28 | if let error { 29 | print(error.localizedDescription) 30 | } 31 | } 32 | } 33 | 34 | static func areNotificationsEnabled() -> Bool { 35 | let group = DispatchGroup() 36 | group.enter() 37 | 38 | var notificationsEnabled = false 39 | 40 | UNUserNotificationCenter.current().getNotificationSettings { notificationSettings in 41 | notificationsEnabled = notificationSettings.authorizationStatus != UNAuthorizationStatus.denied 42 | group.leave() 43 | } 44 | 45 | group.wait() 46 | return notificationsEnabled 47 | } 48 | 49 | static func sendNotification(_ content: UNMutableNotificationContent) { 50 | let uuidString = UUID().uuidString 51 | let request = UNNotificationRequest( 52 | identifier: uuidString, 53 | content: content, 54 | trigger: nil 55 | ) 56 | 57 | requestNotificationAuthorization() 58 | 59 | UNUserNotificationCenter.current().add(request) 60 | } 61 | 62 | static func sendNotification(_ title: String, _ body: String) { 63 | let content = UNMutableNotificationContent() 64 | 65 | content.title = title 66 | content.body = body 67 | content.categoryIdentifier = UUID().uuidString 68 | 69 | AppDelegate.sendNotification(content) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Clipchop/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // Clipchop 4 | // 5 | // Created by Xinshao_Air on 2024/7/5. 6 | // 7 | 8 | import AppKit 9 | import Defaults 10 | import UserNotifications 11 | 12 | class AppDelegate: NSObject, NSApplicationDelegate { 13 | 14 | static var isActive: Bool = false 15 | 16 | func applicationDidFinishLaunching(_ notification: Notification) { 17 | NSApp.setActivationPolicy(.accessory) 18 | UNUserNotificationCenter.current().delegate = self 19 | AppDelegate.requestNotificationAuthorization() 20 | applyColorScheme() 21 | } 22 | 23 | func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { 24 | NSApp.setActivationPolicy(.accessory) 25 | LuminareManager.fullyClose() 26 | return false 27 | } 28 | 29 | func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { 30 | LuminareManager.open() 31 | return true 32 | } 33 | 34 | private func applyColorScheme() { 35 | switch Defaults[.preferredColorScheme] { 36 | case .system: 37 | NSApplication.shared.appearance = nil 38 | case .light: 39 | NSApplication.shared.appearance = NSAppearance(named: .aqua) 40 | case .dark: 41 | NSApplication.shared.appearance = NSAppearance(named: .darkAqua) 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Clipchop/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "display-p3", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.596", 9 | "green" : "0.675", 10 | "red" : "0.043" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Clipchop/Assets.xcassets/AppIcon/AppIcon-Aerugo.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon_16x16.png", 5 | "idiom" : "mac", 6 | "scale" : "1x", 7 | "size" : "16x16" 8 | }, 9 | { 10 | "filename" : "icon_16x16@2x@2x.png", 11 | "idiom" : "mac", 12 | "scale" : "2x", 13 | "size" : "16x16" 14 | }, 15 | { 16 | "filename" : "icon_32x32.png", 17 | "idiom" : "mac", 18 | "scale" : "1x", 19 | "size" : "32x32" 20 | }, 21 | { 22 | "filename" : "icon_32x32@2x@2x.png", 23 | "idiom" : "mac", 24 | "scale" : "2x", 25 | "size" : "32x32" 26 | }, 27 | { 28 | "filename" : "icon_128x128.png", 29 | "idiom" : "mac", 30 | "scale" : "1x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "filename" : "icon_128x128@2x@2x.png", 35 | "idiom" : "mac", 36 | "scale" : "2x", 37 | "size" : "128x128" 38 | }, 39 | { 40 | "filename" : "icon_256x256.png", 41 | "idiom" : "mac", 42 | "scale" : "1x", 43 | "size" : "256x256" 44 | }, 45 | { 46 | "filename" : "icon_256x256@2x@2x.png", 47 | "idiom" : "mac", 48 | "scale" : "2x", 49 | "size" : "256x256" 50 | }, 51 | { 52 | "filename" : "icon_512x512.png", 53 | "idiom" : "mac", 54 | "scale" : "1x", 55 | "size" : "512x512" 56 | }, 57 | { 58 | "filename" : "icon_512x512@2x@2x.png", 59 | "idiom" : "mac", 60 | "scale" : "2x", 61 | "size" : "512x512" 62 | } 63 | ], 64 | "info" : { 65 | "author" : "xcode", 66 | "version" : 1 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Clipchop/Assets.xcassets/AppIcon/AppIcon-Aerugo.appiconset/icon_128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cement-Labs/Clipchop/c6010a8ba9421c3508f1ac7edb05aca33f81b5c6/Clipchop/Assets.xcassets/AppIcon/AppIcon-Aerugo.appiconset/icon_128x128.png -------------------------------------------------------------------------------- /Clipchop/Assets.xcassets/AppIcon/AppIcon-Aerugo.appiconset/icon_128x128@2x@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cement-Labs/Clipchop/c6010a8ba9421c3508f1ac7edb05aca33f81b5c6/Clipchop/Assets.xcassets/AppIcon/AppIcon-Aerugo.appiconset/icon_128x128@2x@2x.png -------------------------------------------------------------------------------- /Clipchop/Assets.xcassets/AppIcon/AppIcon-Aerugo.appiconset/icon_16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cement-Labs/Clipchop/c6010a8ba9421c3508f1ac7edb05aca33f81b5c6/Clipchop/Assets.xcassets/AppIcon/AppIcon-Aerugo.appiconset/icon_16x16.png -------------------------------------------------------------------------------- /Clipchop/Assets.xcassets/AppIcon/AppIcon-Aerugo.appiconset/icon_16x16@2x@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cement-Labs/Clipchop/c6010a8ba9421c3508f1ac7edb05aca33f81b5c6/Clipchop/Assets.xcassets/AppIcon/AppIcon-Aerugo.appiconset/icon_16x16@2x@2x.png -------------------------------------------------------------------------------- /Clipchop/Assets.xcassets/AppIcon/AppIcon-Aerugo.appiconset/icon_256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cement-Labs/Clipchop/c6010a8ba9421c3508f1ac7edb05aca33f81b5c6/Clipchop/Assets.xcassets/AppIcon/AppIcon-Aerugo.appiconset/icon_256x256.png -------------------------------------------------------------------------------- /Clipchop/Assets.xcassets/AppIcon/AppIcon-Aerugo.appiconset/icon_256x256@2x@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cement-Labs/Clipchop/c6010a8ba9421c3508f1ac7edb05aca33f81b5c6/Clipchop/Assets.xcassets/AppIcon/AppIcon-Aerugo.appiconset/icon_256x256@2x@2x.png -------------------------------------------------------------------------------- /Clipchop/Assets.xcassets/AppIcon/AppIcon-Aerugo.appiconset/icon_32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cement-Labs/Clipchop/c6010a8ba9421c3508f1ac7edb05aca33f81b5c6/Clipchop/Assets.xcassets/AppIcon/AppIcon-Aerugo.appiconset/icon_32x32.png -------------------------------------------------------------------------------- /Clipchop/Assets.xcassets/AppIcon/AppIcon-Aerugo.appiconset/icon_32x32@2x@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cement-Labs/Clipchop/c6010a8ba9421c3508f1ac7edb05aca33f81b5c6/Clipchop/Assets.xcassets/AppIcon/AppIcon-Aerugo.appiconset/icon_32x32@2x@2x.png -------------------------------------------------------------------------------- /Clipchop/Assets.xcassets/AppIcon/AppIcon-Aerugo.appiconset/icon_512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cement-Labs/Clipchop/c6010a8ba9421c3508f1ac7edb05aca33f81b5c6/Clipchop/Assets.xcassets/AppIcon/AppIcon-Aerugo.appiconset/icon_512x512.png -------------------------------------------------------------------------------- /Clipchop/Assets.xcassets/AppIcon/AppIcon-Aerugo.appiconset/icon_512x512@2x@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cement-Labs/Clipchop/c6010a8ba9421c3508f1ac7edb05aca33f81b5c6/Clipchop/Assets.xcassets/AppIcon/AppIcon-Aerugo.appiconset/icon_512x512@2x@2x.png -------------------------------------------------------------------------------- /Clipchop/Assets.xcassets/AppIcon/AppIcon-Beta.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon_16x16.png", 5 | "idiom" : "mac", 6 | "scale" : "1x", 7 | "size" : "16x16" 8 | }, 9 | { 10 | "filename" : "icon_16x16@2x@2x.png", 11 | "idiom" : "mac", 12 | "scale" : "2x", 13 | "size" : "16x16" 14 | }, 15 | { 16 | "filename" : "icon_32x32.png", 17 | "idiom" : "mac", 18 | "scale" : "1x", 19 | "size" : "32x32" 20 | }, 21 | { 22 | "filename" : "icon_32x32@2x@2x.png", 23 | "idiom" : "mac", 24 | "scale" : "2x", 25 | "size" : "32x32" 26 | }, 27 | { 28 | "filename" : "icon_128x128.png", 29 | "idiom" : "mac", 30 | "scale" : "1x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "filename" : "icon_128x128@2x@2x.png", 35 | "idiom" : "mac", 36 | "scale" : "2x", 37 | "size" : "128x128" 38 | }, 39 | { 40 | "filename" : "icon_256x256.png", 41 | "idiom" : "mac", 42 | "scale" : "1x", 43 | "size" : "256x256" 44 | }, 45 | { 46 | "filename" : "icon_256x256@2x@2x.png", 47 | "idiom" : "mac", 48 | "scale" : "2x", 49 | "size" : "256x256" 50 | }, 51 | { 52 | "filename" : "icon_512x512.png", 53 | "idiom" : "mac", 54 | "scale" : "1x", 55 | "size" : "512x512" 56 | }, 57 | { 58 | "filename" : "icon_512x512@2x@2x.png", 59 | "idiom" : "mac", 60 | "scale" : "2x", 61 | "size" : "512x512" 62 | } 63 | ], 64 | "info" : { 65 | "author" : "xcode", 66 | "version" : 1 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Clipchop/Assets.xcassets/AppIcon/AppIcon-Beta.appiconset/icon_128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cement-Labs/Clipchop/c6010a8ba9421c3508f1ac7edb05aca33f81b5c6/Clipchop/Assets.xcassets/AppIcon/AppIcon-Beta.appiconset/icon_128x128.png -------------------------------------------------------------------------------- /Clipchop/Assets.xcassets/AppIcon/AppIcon-Beta.appiconset/icon_128x128@2x@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cement-Labs/Clipchop/c6010a8ba9421c3508f1ac7edb05aca33f81b5c6/Clipchop/Assets.xcassets/AppIcon/AppIcon-Beta.appiconset/icon_128x128@2x@2x.png -------------------------------------------------------------------------------- /Clipchop/Assets.xcassets/AppIcon/AppIcon-Beta.appiconset/icon_16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cement-Labs/Clipchop/c6010a8ba9421c3508f1ac7edb05aca33f81b5c6/Clipchop/Assets.xcassets/AppIcon/AppIcon-Beta.appiconset/icon_16x16.png -------------------------------------------------------------------------------- /Clipchop/Assets.xcassets/AppIcon/AppIcon-Beta.appiconset/icon_16x16@2x@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cement-Labs/Clipchop/c6010a8ba9421c3508f1ac7edb05aca33f81b5c6/Clipchop/Assets.xcassets/AppIcon/AppIcon-Beta.appiconset/icon_16x16@2x@2x.png -------------------------------------------------------------------------------- /Clipchop/Assets.xcassets/AppIcon/AppIcon-Beta.appiconset/icon_256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cement-Labs/Clipchop/c6010a8ba9421c3508f1ac7edb05aca33f81b5c6/Clipchop/Assets.xcassets/AppIcon/AppIcon-Beta.appiconset/icon_256x256.png -------------------------------------------------------------------------------- /Clipchop/Assets.xcassets/AppIcon/AppIcon-Beta.appiconset/icon_256x256@2x@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cement-Labs/Clipchop/c6010a8ba9421c3508f1ac7edb05aca33f81b5c6/Clipchop/Assets.xcassets/AppIcon/AppIcon-Beta.appiconset/icon_256x256@2x@2x.png -------------------------------------------------------------------------------- /Clipchop/Assets.xcassets/AppIcon/AppIcon-Beta.appiconset/icon_32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cement-Labs/Clipchop/c6010a8ba9421c3508f1ac7edb05aca33f81b5c6/Clipchop/Assets.xcassets/AppIcon/AppIcon-Beta.appiconset/icon_32x32.png -------------------------------------------------------------------------------- /Clipchop/Assets.xcassets/AppIcon/AppIcon-Beta.appiconset/icon_32x32@2x@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cement-Labs/Clipchop/c6010a8ba9421c3508f1ac7edb05aca33f81b5c6/Clipchop/Assets.xcassets/AppIcon/AppIcon-Beta.appiconset/icon_32x32@2x@2x.png -------------------------------------------------------------------------------- /Clipchop/Assets.xcassets/AppIcon/AppIcon-Beta.appiconset/icon_512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cement-Labs/Clipchop/c6010a8ba9421c3508f1ac7edb05aca33f81b5c6/Clipchop/Assets.xcassets/AppIcon/AppIcon-Beta.appiconset/icon_512x512.png -------------------------------------------------------------------------------- /Clipchop/Assets.xcassets/AppIcon/AppIcon-Beta.appiconset/icon_512x512@2x@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cement-Labs/Clipchop/c6010a8ba9421c3508f1ac7edb05aca33f81b5c6/Clipchop/Assets.xcassets/AppIcon/AppIcon-Beta.appiconset/icon_512x512@2x@2x.png -------------------------------------------------------------------------------- /Clipchop/Assets.xcassets/AppIcon/AppIcon-HoloGram.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon_16x16.png", 5 | "idiom" : "mac", 6 | "scale" : "1x", 7 | "size" : "16x16" 8 | }, 9 | { 10 | "filename" : "icon_16x16@2x@2x.png", 11 | "idiom" : "mac", 12 | "scale" : "2x", 13 | "size" : "16x16" 14 | }, 15 | { 16 | "filename" : "icon_32x32.png", 17 | "idiom" : "mac", 18 | "scale" : "1x", 19 | "size" : "32x32" 20 | }, 21 | { 22 | "filename" : "icon_32x32@2x@2x.png", 23 | "idiom" : "mac", 24 | "scale" : "2x", 25 | "size" : "32x32" 26 | }, 27 | { 28 | "filename" : "icon_128x128.png", 29 | "idiom" : "mac", 30 | "scale" : "1x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "filename" : "icon_128x128@2x@2x.png", 35 | "idiom" : "mac", 36 | "scale" : "2x", 37 | "size" : "128x128" 38 | }, 39 | { 40 | "filename" : "icon_256x256.png", 41 | "idiom" : "mac", 42 | "scale" : "1x", 43 | "size" : "256x256" 44 | }, 45 | { 46 | "filename" : "icon_256x256@2x@2x.png", 47 | "idiom" : "mac", 48 | "scale" : "2x", 49 | "size" : "256x256" 50 | }, 51 | { 52 | "filename" : "icon_512x512.png", 53 | "idiom" : "mac", 54 | "scale" : "1x", 55 | "size" : "512x512" 56 | }, 57 | { 58 | "filename" : "icon_512x512@2x@2x.png", 59 | "idiom" : "mac", 60 | "scale" : "2x", 61 | "size" : "512x512" 62 | } 63 | ], 64 | "info" : { 65 | "author" : "xcode", 66 | "version" : 1 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Clipchop/Assets.xcassets/AppIcon/AppIcon-HoloGram.appiconset/icon_128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cement-Labs/Clipchop/c6010a8ba9421c3508f1ac7edb05aca33f81b5c6/Clipchop/Assets.xcassets/AppIcon/AppIcon-HoloGram.appiconset/icon_128x128.png -------------------------------------------------------------------------------- /Clipchop/Assets.xcassets/AppIcon/AppIcon-HoloGram.appiconset/icon_128x128@2x@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cement-Labs/Clipchop/c6010a8ba9421c3508f1ac7edb05aca33f81b5c6/Clipchop/Assets.xcassets/AppIcon/AppIcon-HoloGram.appiconset/icon_128x128@2x@2x.png -------------------------------------------------------------------------------- /Clipchop/Assets.xcassets/AppIcon/AppIcon-HoloGram.appiconset/icon_16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cement-Labs/Clipchop/c6010a8ba9421c3508f1ac7edb05aca33f81b5c6/Clipchop/Assets.xcassets/AppIcon/AppIcon-HoloGram.appiconset/icon_16x16.png -------------------------------------------------------------------------------- /Clipchop/Assets.xcassets/AppIcon/AppIcon-HoloGram.appiconset/icon_16x16@2x@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cement-Labs/Clipchop/c6010a8ba9421c3508f1ac7edb05aca33f81b5c6/Clipchop/Assets.xcassets/AppIcon/AppIcon-HoloGram.appiconset/icon_16x16@2x@2x.png -------------------------------------------------------------------------------- /Clipchop/Assets.xcassets/AppIcon/AppIcon-HoloGram.appiconset/icon_256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cement-Labs/Clipchop/c6010a8ba9421c3508f1ac7edb05aca33f81b5c6/Clipchop/Assets.xcassets/AppIcon/AppIcon-HoloGram.appiconset/icon_256x256.png -------------------------------------------------------------------------------- /Clipchop/Assets.xcassets/AppIcon/AppIcon-HoloGram.appiconset/icon_256x256@2x@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cement-Labs/Clipchop/c6010a8ba9421c3508f1ac7edb05aca33f81b5c6/Clipchop/Assets.xcassets/AppIcon/AppIcon-HoloGram.appiconset/icon_256x256@2x@2x.png -------------------------------------------------------------------------------- /Clipchop/Assets.xcassets/AppIcon/AppIcon-HoloGram.appiconset/icon_32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cement-Labs/Clipchop/c6010a8ba9421c3508f1ac7edb05aca33f81b5c6/Clipchop/Assets.xcassets/AppIcon/AppIcon-HoloGram.appiconset/icon_32x32.png -------------------------------------------------------------------------------- /Clipchop/Assets.xcassets/AppIcon/AppIcon-HoloGram.appiconset/icon_32x32@2x@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cement-Labs/Clipchop/c6010a8ba9421c3508f1ac7edb05aca33f81b5c6/Clipchop/Assets.xcassets/AppIcon/AppIcon-HoloGram.appiconset/icon_32x32@2x@2x.png -------------------------------------------------------------------------------- /Clipchop/Assets.xcassets/AppIcon/AppIcon-HoloGram.appiconset/icon_512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cement-Labs/Clipchop/c6010a8ba9421c3508f1ac7edb05aca33f81b5c6/Clipchop/Assets.xcassets/AppIcon/AppIcon-HoloGram.appiconset/icon_512x512.png -------------------------------------------------------------------------------- /Clipchop/Assets.xcassets/AppIcon/AppIcon-HoloGram.appiconset/icon_512x512@2x@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cement-Labs/Clipchop/c6010a8ba9421c3508f1ac7edb05aca33f81b5c6/Clipchop/Assets.xcassets/AppIcon/AppIcon-HoloGram.appiconset/icon_512x512@2x@2x.png -------------------------------------------------------------------------------- /Clipchop/Assets.xcassets/AppIcon/AppIcon-Stable.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon_16x16.png", 5 | "idiom" : "mac", 6 | "scale" : "1x", 7 | "size" : "16x16" 8 | }, 9 | { 10 | "filename" : "icon_16x16@2x.png", 11 | "idiom" : "mac", 12 | "scale" : "2x", 13 | "size" : "16x16" 14 | }, 15 | { 16 | "filename" : "icon_32x32.png", 17 | "idiom" : "mac", 18 | "scale" : "1x", 19 | "size" : "32x32" 20 | }, 21 | { 22 | "filename" : "icon_32x32@2x.png", 23 | "idiom" : "mac", 24 | "scale" : "2x", 25 | "size" : "32x32" 26 | }, 27 | { 28 | "filename" : "icon_128x128.png", 29 | "idiom" : "mac", 30 | "scale" : "1x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "filename" : "icon_128x128@2x.png", 35 | "idiom" : "mac", 36 | "scale" : "2x", 37 | "size" : "128x128" 38 | }, 39 | { 40 | "filename" : "icon_256x256.png", 41 | "idiom" : "mac", 42 | "scale" : "1x", 43 | "size" : "256x256" 44 | }, 45 | { 46 | "filename" : "icon_256x256@2x.png", 47 | "idiom" : "mac", 48 | "scale" : "2x", 49 | "size" : "256x256" 50 | }, 51 | { 52 | "filename" : "icon_512x512.png", 53 | "idiom" : "mac", 54 | "scale" : "1x", 55 | "size" : "512x512" 56 | }, 57 | { 58 | "filename" : "icon_512x512@2x.png", 59 | "idiom" : "mac", 60 | "scale" : "2x", 61 | "size" : "512x512" 62 | } 63 | ], 64 | "info" : { 65 | "author" : "xcode", 66 | "version" : 1 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Clipchop/Assets.xcassets/AppIcon/AppIcon-Stable.appiconset/icon_128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cement-Labs/Clipchop/c6010a8ba9421c3508f1ac7edb05aca33f81b5c6/Clipchop/Assets.xcassets/AppIcon/AppIcon-Stable.appiconset/icon_128x128.png -------------------------------------------------------------------------------- /Clipchop/Assets.xcassets/AppIcon/AppIcon-Stable.appiconset/icon_128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cement-Labs/Clipchop/c6010a8ba9421c3508f1ac7edb05aca33f81b5c6/Clipchop/Assets.xcassets/AppIcon/AppIcon-Stable.appiconset/icon_128x128@2x.png -------------------------------------------------------------------------------- /Clipchop/Assets.xcassets/AppIcon/AppIcon-Stable.appiconset/icon_16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cement-Labs/Clipchop/c6010a8ba9421c3508f1ac7edb05aca33f81b5c6/Clipchop/Assets.xcassets/AppIcon/AppIcon-Stable.appiconset/icon_16x16.png -------------------------------------------------------------------------------- /Clipchop/Assets.xcassets/AppIcon/AppIcon-Stable.appiconset/icon_16x16@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cement-Labs/Clipchop/c6010a8ba9421c3508f1ac7edb05aca33f81b5c6/Clipchop/Assets.xcassets/AppIcon/AppIcon-Stable.appiconset/icon_16x16@2x.png -------------------------------------------------------------------------------- /Clipchop/Assets.xcassets/AppIcon/AppIcon-Stable.appiconset/icon_256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cement-Labs/Clipchop/c6010a8ba9421c3508f1ac7edb05aca33f81b5c6/Clipchop/Assets.xcassets/AppIcon/AppIcon-Stable.appiconset/icon_256x256.png -------------------------------------------------------------------------------- /Clipchop/Assets.xcassets/AppIcon/AppIcon-Stable.appiconset/icon_256x256@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cement-Labs/Clipchop/c6010a8ba9421c3508f1ac7edb05aca33f81b5c6/Clipchop/Assets.xcassets/AppIcon/AppIcon-Stable.appiconset/icon_256x256@2x.png -------------------------------------------------------------------------------- /Clipchop/Assets.xcassets/AppIcon/AppIcon-Stable.appiconset/icon_32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cement-Labs/Clipchop/c6010a8ba9421c3508f1ac7edb05aca33f81b5c6/Clipchop/Assets.xcassets/AppIcon/AppIcon-Stable.appiconset/icon_32x32.png -------------------------------------------------------------------------------- /Clipchop/Assets.xcassets/AppIcon/AppIcon-Stable.appiconset/icon_32x32@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cement-Labs/Clipchop/c6010a8ba9421c3508f1ac7edb05aca33f81b5c6/Clipchop/Assets.xcassets/AppIcon/AppIcon-Stable.appiconset/icon_32x32@2x.png -------------------------------------------------------------------------------- /Clipchop/Assets.xcassets/AppIcon/AppIcon-Stable.appiconset/icon_512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cement-Labs/Clipchop/c6010a8ba9421c3508f1ac7edb05aca33f81b5c6/Clipchop/Assets.xcassets/AppIcon/AppIcon-Stable.appiconset/icon_512x512.png -------------------------------------------------------------------------------- /Clipchop/Assets.xcassets/AppIcon/AppIcon-Stable.appiconset/icon_512x512@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cement-Labs/Clipchop/c6010a8ba9421c3508f1ac7edb05aca33f81b5c6/Clipchop/Assets.xcassets/AppIcon/AppIcon-Stable.appiconset/icon_512x512@2x.png -------------------------------------------------------------------------------- /Clipchop/Assets.xcassets/AppIcon/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Clipchop/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Clipchop/Assets.xcassets/Empty.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "empty.png", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Clipchop/Assets.xcassets/Empty.imageset/empty.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cement-Labs/Clipchop/c6010a8ba9421c3508f1ac7edb05aca33f81b5c6/Clipchop/Assets.xcassets/Empty.imageset/empty.png -------------------------------------------------------------------------------- /Clipchop/Assets.xcassets/clipchop.fill.symbolset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | }, 6 | "symbols" : [ 7 | { 8 | "filename" : "clipchop.fill.svg", 9 | "idiom" : "universal" 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /Clipchop/Assets/AppIcon.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppIcon.swift 3 | // Clipchop 4 | // 5 | // Created by KrLite on 2024/5/12. 6 | // 7 | 8 | import SwiftUI 9 | import Defaults 10 | import UserNotifications 11 | import Intents 12 | 13 | struct AppIcon: Hashable, Defaults.Serializable { 14 | var name: String? 15 | var assetName: String 16 | var unlockThreshold: Int 17 | var unlockMessage: String? 18 | 19 | var image: NSImage { 20 | .init(named: assetName)! 21 | } 22 | 23 | func setAppIcon() { 24 | Self.setAppIcon(to: self) 25 | } 26 | 27 | struct Bridge: Defaults.Bridge { 28 | typealias Value = AppIcon 29 | typealias Serializable = String 30 | 31 | func serialize(_ value: AppIcon?) -> String? { 32 | value?.assetName 33 | } 34 | 35 | func deserialize(_ object: String?) -> AppIcon? { 36 | if let object { 37 | return .icons.first { $0.assetName == object } 38 | } else { 39 | return .defaultAppIcon 40 | } 41 | } 42 | } 43 | 44 | static let bridge = Bridge() 45 | } 46 | 47 | extension AppIcon { 48 | static let stable = AppIcon( 49 | name: .init(localized: "App Icon: Stable", defaultValue: "Clipchop"), 50 | assetName: "AppIcon-Stable", 51 | unlockThreshold: 0 52 | ) 53 | 54 | static let beta = AppIcon( 55 | name: .init(localized: "App Icon: Beta", defaultValue: "Clipchop Beta"), 56 | assetName: "AppIcon-Beta", 57 | unlockThreshold: 0 58 | ) 59 | 60 | static let aerugo = AppIcon( 61 | name: .init(localized: "App Icon: Aerugo", defaultValue: "Aerugo"), 62 | assetName: "AppIcon-Aerugo", 63 | unlockThreshold: 25, 64 | unlockMessage: .init(localized: "Aerugo", defaultValue: "\(Bundle.main.appName) will rust if you don't clip soon.") 65 | ) 66 | 67 | static let holoGram = AppIcon( 68 | name: .init(localized: "App Icon: HoloGram", defaultValue: "HoloGram"), 69 | assetName: "AppIcon-HoloGram", 70 | unlockThreshold: 50, 71 | unlockMessage: .init(localized: "HoloGram", defaultValue: "\(Bundle.main.appName) feels a lot stronger!") 72 | ) 73 | } 74 | 75 | extension AppIcon { 76 | static var defaultAppIcon: AppIcon { 77 | #if DEBUG 78 | return beta 79 | #else 80 | return stable 81 | #endif 82 | } 83 | 84 | static var currentAppIcon: AppIcon { 85 | Defaults[.appIcon] 86 | } 87 | 88 | static let icons: [AppIcon] = [ 89 | stable, 90 | beta, 91 | aerugo, 92 | holoGram 93 | ] 94 | } 95 | 96 | extension AppIcon { 97 | static var unlockedAppIcons: [AppIcon] { 98 | var returnValue: [AppIcon] = [] 99 | for icon in icons where icon.unlockThreshold <= Defaults[.timesClipped] { 100 | returnValue.append(icon) 101 | } 102 | return returnValue.reversed() 103 | } 104 | 105 | static func setAppIcon(to icon: AppIcon) { 106 | log(self, "App icon set to: \(icon.assetName)") 107 | Defaults[.appIcon] = icon 108 | refreshCurrentAppIcon() 109 | } 110 | 111 | static func refreshCurrentAppIcon() { 112 | let image = Defaults[.appIcon].image 113 | 114 | NSWorkspace.shared.setIcon( 115 | image, 116 | forFile: Bundle.main.bundlePath, 117 | options: [] 118 | ) 119 | 120 | NSApp.applicationIconImage = image 121 | } 122 | } 123 | 124 | extension AppIcon { 125 | static func checkIfUnlockedNewIcon() { 126 | guard Defaults[.sendNotification] else { return } 127 | 128 | for icon in icons where icon.unlockThreshold == Defaults[.timesClipped] { 129 | let title = Bundle.main.appName 130 | let body = icon.unlockMessage ?? "You've unlocked a new icon: \(icon.name ?? "Unknown")!" 131 | 132 | let content = UNMutableNotificationContent() 133 | content.title = title 134 | content.body = body 135 | 136 | if let imageData = NSImage(named: icon.assetName)?.tiffRepresentation, 137 | let attachment = UNNotificationAttachment.create(NSData(data: imageData)) { 138 | content.attachments = [attachment] 139 | content.userInfo = ["icon": icon.assetName] 140 | } 141 | 142 | content.categoryIdentifier = "icon_unlocked" 143 | 144 | AppDelegate.sendNotification(content) 145 | } 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /Clipchop/Assets/InstalledApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InstalledApp.swift 3 | // Clipchop 4 | // 5 | // Created by KrLite on 2024/5/12. 6 | // 7 | 8 | import SwiftUI 9 | import Algorithms 10 | import Defaults 11 | 12 | 13 | struct InstalledApp: Identifiable { 14 | var id: String { bundleID } 15 | var bundleID: String 16 | var icon: NSImage 17 | var displayName: String 18 | var installationFolder: String 19 | } 20 | 21 | class InstalledApps: ObservableObject { 22 | private var systemQuery = NSMetadataQuery() 23 | private var localQuery = NSMetadataQuery() 24 | 25 | @Published var systemApps = [InstalledApp]() 26 | @Published var installedApps = [InstalledApp]() 27 | 28 | init() { 29 | self.ensureFinderApp() 30 | 31 | self.startQuery(&systemQuery, in: .systemDomainMask) { 32 | self.queryDidFinishGathering(self.systemQuery, to: &self.systemApps, notification: $0) 33 | self.ensureFinderApp() 34 | } 35 | 36 | self.startQuery(&localQuery, in: .localDomainMask) { 37 | self.queryDidFinishGathering(self.localQuery, to: &self.installedApps, notification: $0) 38 | } 39 | } 40 | 41 | deinit { 42 | systemQuery.stop() 43 | localQuery.stop() 44 | } 45 | 46 | private func startQuery( 47 | _ query: inout NSMetadataQuery, 48 | `in`: FileManager.SearchPathDomainMask, 49 | completion: @escaping @Sendable (Notification) -> () 50 | ) { 51 | query.predicate = NSPredicate(format: "kMDItemContentType == 'com.apple.application-bundle'") 52 | if let appFolder = FileManager.default.urls( 53 | for: .allApplicationsDirectory, 54 | in: `in` 55 | ).first { 56 | query.searchScopes = [appFolder] 57 | } 58 | 59 | NotificationCenter.default.addObserver( 60 | forName: .NSMetadataQueryDidFinishGathering, 61 | object: nil, 62 | queue: nil, 63 | using: completion 64 | ) 65 | 66 | query.start() 67 | } 68 | 69 | private func queryDidFinishGathering( 70 | _ query: NSMetadataQuery, to: inout [InstalledApp], 71 | notification: Notification 72 | ) { 73 | if let items = query.results as? [NSMetadataItem] { 74 | to = items.compactMap { item in 75 | guard 76 | let bundleId = item.value(forAttribute: NSMetadataItemCFBundleIdentifierKey) as? String, 77 | let displayName = item.value(forAttribute: NSMetadataItemDisplayNameKey) as? String, 78 | let path = item.value(forAttribute: NSMetadataItemPathKey) as? String, 79 | let installationFolder = URL(string: path)?.deletingLastPathComponent().absoluteString.removingPercentEncoding 80 | else { 81 | return nil 82 | } 83 | 84 | let icon = NSWorkspace.shared.icon(forFile: path) 85 | return .init( 86 | bundleID: bundleId, 87 | icon: icon, 88 | displayName: displayName, 89 | installationFolder: installationFolder 90 | ) 91 | } 92 | } 93 | } 94 | 95 | private func ensureFinderApp() { 96 | let finderBundleID = "com.apple.finder" 97 | 98 | if !systemApps.contains(where: { $0.bundleID == finderBundleID }) { 99 | let finderPath = "/System/Library/CoreServices/Finder.app" 100 | let icon = NSWorkspace.shared.icon(forFile: finderPath) 101 | let displayName = FileManager.default.displayName(atPath: finderPath) 102 | let installationFolder = (finderPath as NSString).deletingLastPathComponent 103 | 104 | let finderApp = InstalledApp( 105 | bundleID: finderBundleID, 106 | icon: icon, 107 | displayName: displayName, 108 | installationFolder: installationFolder 109 | ) 110 | self.systemApps.append(finderApp) 111 | } 112 | } 113 | 114 | func displayName(for bundleID: String) -> String? { 115 | return (systemApps + installedApps).first { $0.bundleID == bundleID }?.displayName 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /Clipchop/Assets/Sound.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Sound.swift 3 | // Clipchop 4 | // 5 | // Created by KrLite on 2024/5/12. 6 | // 7 | 8 | import SwiftUI 9 | import Defaults 10 | 11 | struct Sound: Hashable, Defaults.Serializable { 12 | var hasSound: Bool = true 13 | var name: String? 14 | var assetName: String 15 | var unlockThreshold: Int 16 | 17 | func play() { 18 | Self.play(sound: self) 19 | } 20 | 21 | func setClipSound() { 22 | print("setClipSound") 23 | Self.setClipSound(to: self) 24 | } 25 | 26 | func setPasteSound() { 27 | print("setPasteSound") 28 | Self.setPasteSound(to: self) 29 | } 30 | 31 | struct Bridge: Defaults.Bridge { 32 | typealias Value = Sound 33 | typealias Serializable = String 34 | 35 | func serialize(_ value: Sound?) -> String? { 36 | value?.assetName 37 | } 38 | 39 | func deserialize(_ object: String?) -> Sound? { 40 | if let object { 41 | return .sounds.first { $0.assetName == object } 42 | } else { 43 | return .pop 44 | } 45 | } 46 | } 47 | 48 | static let bridge = Bridge() 49 | } 50 | 51 | extension Sound { 52 | static let none = Sound( 53 | hasSound: false, 54 | name: .init(localized: "Sound: None", defaultValue: "None"), 55 | assetName: "", 56 | unlockThreshold: 0 57 | ) 58 | 59 | static let pop = Sound( 60 | name: .init(localized: "Sound: Pop", defaultValue: "Pop"), 61 | assetName: "happy-pop", 62 | unlockThreshold: 0 63 | ) 64 | 65 | static let bloop = Sound( 66 | name: .init(localized: "Sound: Bloop", defaultValue: "Bloop"), 67 | assetName: "marimba-bloop", 68 | unlockThreshold: 0 69 | ) 70 | 71 | static let tap = Sound( 72 | name: .init(localized: "Sound: Tap", defaultValue: "Tap"), 73 | assetName: "tap-notification", 74 | unlockThreshold: 25 75 | ) 76 | } 77 | 78 | extension Sound { 79 | static var defaultClipSound: Sound { 80 | pop 81 | } 82 | static var defaultPasteSound: Sound { 83 | bloop 84 | } 85 | 86 | static var clipSound: Sound { 87 | Defaults[.clipSound] 88 | } 89 | static var pasteSound: Sound { 90 | Defaults[.pasteSound] 91 | } 92 | 93 | static let sounds: [Sound] = [ 94 | none, 95 | pop, 96 | bloop, 97 | tap 98 | ] 99 | } 100 | 101 | extension Sound { 102 | static var unlockedSounds: [Sound] { 103 | var returnValue: [Sound] = [] 104 | for sound in sounds where sound.unlockThreshold <= Defaults[.timesClipped] { 105 | returnValue.append(sound) 106 | } 107 | return returnValue.reversed() 108 | } 109 | 110 | static func play(sound: Sound) { 111 | if sound.hasSound { 112 | SoundPlayer.playSound(named: sound.assetName, volume: Defaults[.volume]) 113 | } 114 | } 115 | 116 | static func setClipSound(to sound: Sound) { 117 | log(self, "Clip sound set to: \(sound.assetName)") 118 | Defaults[.clipSound] = sound 119 | } 120 | 121 | static func setPasteSound(to sound: Sound) { 122 | log(self, "Paste sound set to: \(sound.assetName)") 123 | Defaults[.pasteSound] = sound 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /Clipchop/Assets/SoundPlayer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SoundPlayer.swift 3 | // Clipchop 4 | // 5 | // Created by KrLite on 2024/5/12. 6 | // 7 | 8 | import AVFAudio 9 | 10 | class SoundPlayer: Identifiable { 11 | private static var audioPlayer: AVAudioPlayer? 12 | static func playSound(named assetName: String, volume: Float = 1.0) { 13 | if let soundURL = Bundle.main.url(forResource: assetName, withExtension: "mp3") { 14 | do { 15 | audioPlayer = try AVAudioPlayer(contentsOf: soundURL) 16 | audioPlayer?.volume = volume 17 | audioPlayer?.play() 18 | 19 | log(self, "Audio played: \(assetName) at volume: \(volume)") 20 | } catch { 21 | log(self, "Unable to play audio: \(error.localizedDescription)") 22 | } 23 | } else { 24 | log(self, "Audio file not found for \(assetName)!") 25 | } 26 | } 27 | 28 | static func setVolume(_ volume: Float) { 29 | guard let player = audioPlayer else { 30 | log(self, "Audio player not initialized.") 31 | return 32 | } 33 | player.volume = volume 34 | log(self, "Volume set to \(volume)") 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Clipchop/Assets/Sounds/happy-pop.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cement-Labs/Clipchop/c6010a8ba9421c3508f1ac7edb05aca33f81b5c6/Clipchop/Assets/Sounds/happy-pop.mp3 -------------------------------------------------------------------------------- /Clipchop/Assets/Sounds/marimba-bloop.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cement-Labs/Clipchop/c6010a8ba9421c3508f1ac7edb05aca33f81b5c6/Clipchop/Assets/Sounds/marimba-bloop.mp3 -------------------------------------------------------------------------------- /Clipchop/Assets/Sounds/tap-notification.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cement-Labs/Clipchop/c6010a8ba9421c3508f1ac7edb05aca33f81b5c6/Clipchop/Assets/Sounds/tap-notification.mp3 -------------------------------------------------------------------------------- /Clipchop/Clipboard/Clipboard Model/ClipboardDataProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ClipboardDataProvider.swift 3 | // Clipchop 4 | // 5 | // Created by Xinshao_Air on 2024/7/5. 6 | // 7 | 8 | import CoreData 9 | 10 | final class ClipboardDataProvider { 11 | 12 | static let shared = ClipboardDataProvider() 13 | 14 | private let persistentContainer : NSPersistentContainer 15 | 16 | var viewContext: NSManagedObjectContext { 17 | persistentContainer.viewContext 18 | } 19 | 20 | var newContext: NSManagedObjectContext { 21 | persistentContainer.newBackgroundContext() 22 | } 23 | 24 | private init() { 25 | persistentContainer = NSPersistentContainer(name: "ClipboardModel") 26 | 27 | persistentContainer.viewContext.undoManager = UndoManager() 28 | 29 | persistentContainer.viewContext.automaticallyMergesChangesFromParent = true 30 | 31 | persistentContainer.loadPersistentStores { _, error in 32 | if let error { 33 | fatalError("Unale to load data! \(error)") 34 | } 35 | } 36 | } 37 | } 38 | 39 | -------------------------------------------------------------------------------- /Clipchop/Clipboard/Clipboard Model/ClipboardModelEditor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ClipboardModelEditor.swift 3 | // Clipchop 4 | // 5 | // Created by Xinshao_Air on 2024/7/5. 6 | // 7 | 8 | import CoreData 9 | 10 | final class ClipboardModelEditor { 11 | 12 | @Published var item: ClipboardHistory 13 | 14 | static let shared = ClipboardModelEditor(provider: .shared) 15 | private let context: NSManagedObjectContext 16 | 17 | init(provider: ClipboardDataProvider, item: ClipboardHistory? = nil) { 18 | self.context = provider.newContext 19 | if let item { 20 | self.item = item 21 | } else { 22 | self.item = ClipboardHistory(context: self.context) 23 | } 24 | } 25 | 26 | func deleteAll(ignoresPinned: Bool = true) throws { 27 | let historyFetchRequest = ClipboardHistory.fetchRequest() 28 | let contentFetchRequest = ClipboardContent.fetchRequest() 29 | do { 30 | let histories = try context.fetch(historyFetchRequest) 31 | histories.forEach(context.delete(_:)) 32 | 33 | let contents = try context.fetch(contentFetchRequest) 34 | contents.forEach(context.delete(_:)) 35 | 36 | try context.save() 37 | context.refreshAllObjects() 38 | log(self,"Deleted all clipboard data") 39 | } catch { 40 | let nsError = error as NSError 41 | log(self,"Error deleting all clipboard data! \(nsError), \(nsError.userInfo)") 42 | 43 | throw nsError 44 | } 45 | } 46 | 47 | func deleteAllExceptPinned() throws { 48 | let fetchRequest = NSFetchRequest(entityName: "ClipboardHistory") 49 | fetchRequest.predicate = NSPredicate(format: "pin == NO") 50 | do { 51 | let histories = try context.fetch(fetchRequest) 52 | for history in histories { 53 | context.delete(history) 54 | } 55 | try context.save() 56 | context.refreshAllObjects() 57 | print("All unpinned ClipboardHistory data deleted.") 58 | } catch { 59 | let nsError = error as NSError 60 | print("Error deleting data: \(nsError), \(nsError.userInfo)") 61 | throw nsError 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Clipchop/Clipboard/Clipboard Model/ClipboardModelManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ClipboardManager.swift 3 | // Clipchop 4 | // 5 | // Created by Xinshao_Air on 2024/7/5. 6 | // 7 | 8 | import SwiftUI 9 | import Combine 10 | import CoreData 11 | import Defaults 12 | 13 | class ClipboardModelManager: ObservableObject { 14 | @Published private(set) var items: [ClipboardHistory] = [] 15 | 16 | let manager = FolderManager() 17 | 18 | private var cancellables = Set() 19 | private let container: NSPersistentContainer 20 | private let context: NSManagedObjectContext 21 | private var timerCancellable: AnyCancellable? 22 | private var provider = ClipboardDataProvider.shared 23 | 24 | init() { 25 | container = NSPersistentContainer(name: "ClipboardModel") 26 | container.loadPersistentStores { description, error in 27 | if let error = error { 28 | fatalError("Failed to load Core Data stack: \(error)") 29 | } 30 | } 31 | context = container.viewContext 32 | context.automaticallyMergesChangesFromParent = true // Enable automatic merging of changes 33 | } 34 | 35 | func deleteOldHistory(preservationPeriod: HistoryPreservationPeriod, preservationTime: Int) { 36 | guard preservationPeriod != .forever else { return } 37 | 38 | let currentDate = Date() 39 | let cutoffDate: Date 40 | 41 | switch preservationPeriod { 42 | case .minute: 43 | cutoffDate = currentDate.addingTimeInterval(-Double(preservationTime * 60)) 44 | case .hour: 45 | cutoffDate = currentDate.addingTimeInterval(-Double(preservationTime * 60 * 60)) 46 | case .day: 47 | cutoffDate = currentDate.addingTimeInterval(-Double(preservationTime * 24 * 60 * 60)) 48 | case .month: 49 | cutoffDate = Calendar.current.date(byAdding: .month, value: -preservationTime, to: currentDate) ?? currentDate 50 | case .year: 51 | cutoffDate = Calendar.current.date(byAdding: .year, value: -preservationTime, to: currentDate) ?? currentDate 52 | default: 53 | return 54 | } 55 | 56 | let fetchRequest = NSFetchRequest(entityName: "ClipboardHistory") 57 | fetchRequest.predicate = NSPredicate(format: "pin == NO AND time < %@", cutoffDate as NSDate) 58 | fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \ClipboardHistory.time, ascending: false)] 59 | 60 | do { 61 | let items = try context.fetch(fetchRequest) 62 | for item in items { 63 | try deleteItem(item) 64 | } 65 | try context.save() 66 | log(self, "Deleted old history items successfully.") 67 | } catch { 68 | log(self, "Failed to fetch ClipboardHistory: \(error)") 69 | } 70 | } 71 | 72 | func startPeriodicCleanup() { 73 | log(self, "time start, Cached preservation period: \(Defaults[.historyPreservationPeriod]), Cached preservation time: \(Defaults[.historyPreservationTime])") 74 | timerCancellable = Timer.publish(every: 600, on: .main, in: .common) 75 | .autoconnect() 76 | .sink { [weak self] _ in 77 | guard let self = self else { return } 78 | self.deleteOldHistory( 79 | preservationPeriod: Defaults[.historyPreservationPeriod], 80 | preservationTime: Int(Defaults[.historyPreservationTime]) 81 | ) 82 | } 83 | } 84 | 85 | func stopPeriodicCleanup() { 86 | timerCancellable?.cancel() 87 | timerCancellable = nil 88 | log(self, "time stop, \(Defaults[.historyPreservationPeriod]), \(Defaults[.historyPreservationTime])") 89 | } 90 | 91 | func restartPeriodicCleanup() { 92 | stopPeriodicCleanup() 93 | startPeriodicCleanup() 94 | log(self, "\(Defaults[.historyPreservationPeriod]), \(Defaults[.historyPreservationTime])") 95 | } 96 | 97 | private func deleteItem(_ item: ClipboardHistory) throws { 98 | let context = provider.viewContext 99 | let existingItem = try context.existingObject(with: item.objectID) 100 | context.delete(existingItem) 101 | Task(priority: .background) { 102 | try await context.perform { 103 | try context.save() 104 | } 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /Clipchop/Clipboard/ClipboardManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ClipboardModelManager.swift 3 | // Clipchop 4 | // 5 | // Created by Xinshao_Air on 2024/7/5. 6 | // 7 | 8 | import AppKit 9 | import Defaults 10 | import KeyboardShortcuts 11 | 12 | class ClipboardManager { 13 | static var clipboardController: ClipboardController? 14 | 15 | private let context: NSManagedObjectContext 16 | 17 | let beginningViewController = BeginningViewController() 18 | let clipHistoryViewController = ClipHistoryPanelController() 19 | let clipboardModelManager = ClipboardModelManager() 20 | 21 | init(context: NSManagedObjectContext) { 22 | 23 | self.context = context 24 | 25 | Self.clipboardController = .init( 26 | context: context, 27 | clipHistoryViewController: clipHistoryViewController, 28 | clipboardModelManager: clipboardModelManager 29 | ) 30 | Self.clipboardController?.start() 31 | 32 | KeyboardShortcuts.onKeyDown(for: .window) { 33 | let cursorPosition = Defaults[.cursorPosition] 34 | 35 | let position: CGPoint 36 | 37 | switch cursorPosition { 38 | case .mouseLocation: 39 | position = NSEvent.mouseLocation 40 | 41 | case .adjustedPosition: 42 | if let screenCursorPosition = getIMECursorPosition() { 43 | let globalCursorPosition = convertScreenToGlobalCoordinates(screenPoint: screenCursorPosition) 44 | position = CGPoint(x: globalCursorPosition.x - 20, y: globalCursorPosition.y - 30) 45 | } else { 46 | position = NSEvent.mouseLocation 47 | } 48 | 49 | case .fixedPosition: 50 | if let screen = NSScreen.main { 51 | let screenFrame = screen.frame 52 | 53 | let x = screenFrame.midX - (Defaults[.displayMore] ? 350 : 250) 54 | 55 | let y = screenFrame.minY + (Defaults[.displayMore] ? 250 : 200) 56 | 57 | let fixedPosition = CGPoint(x: x, y: y) 58 | 59 | position = fixedPosition 60 | } else { 61 | position = NSEvent.mouseLocation 62 | } 63 | case .custom: 64 | if let windowPosition = UserDefaults.standard.dictionary(forKey: "windowPosition") as? [String: CGFloat] { 65 | let x = windowPosition["x"] ?? 0 66 | let y = windowPosition["y"] ?? 0 67 | let customPosition = CGPoint(x: x, y: y) 68 | position = customPosition 69 | } else { 70 | position = NSEvent.mouseLocation 71 | } 72 | } 73 | self.clipHistoryViewController.toggle(position: position) 74 | } 75 | 76 | KeyboardShortcuts.onKeyDown(for: .start) { 77 | Self.clipboardController?.toggle() 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /Clipchop/Clipboard/Data Storage/ClipboardContent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ClipboardContent.swift 3 | // Clipchop 4 | // 5 | // Created by Xinshao_Air on 2024/7/5. 6 | // 7 | 8 | import Foundation 9 | import CoreData 10 | 11 | final class ClipboardContent: NSManagedObject { 12 | enum Managed: String { 13 | case type = "type" 14 | case value = "value" 15 | } 16 | 17 | static let entityName = "ClipboardContent" 18 | 19 | @nonobjc public class func fetchRequest() -> NSFetchRequest { 20 | .init(entityName: ClipboardContent.entityName) 21 | } 22 | 23 | @NSManaged public var type: String? 24 | @NSManaged public var value: Data? 25 | @NSManaged public var item: ClipboardHistory? 26 | 27 | convenience init(type: String, value: Data?) { 28 | let entity = NSEntityDescription.entity( 29 | forEntityName: ClipboardContent.entityName, 30 | in: ClipboardDataProvider.shared.viewContext 31 | )! 32 | self.init(entity: entity, insertInto: ClipboardDataProvider.shared.viewContext) 33 | 34 | self.type = type 35 | self.value = value 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Clipchop/Clipboard/Data Storage/ClipboardModel.xcdatamodeld/ClipboardModel.xcdatamodel/contents: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /Clipchop/Clipboard/FolderManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FolderManager.swift 3 | // Clipchop 4 | // 5 | // Created by Xinshao_Air on 2024/8/24. 6 | // 7 | 8 | import Foundation 9 | import Defaults 10 | 11 | class FolderManager { 12 | func addFolder(named folderName: String) { 13 | var folders = Defaults[.folders] 14 | if !folders.contains(where: { $0.name == folderName }) { 15 | let newFolder = Folder(id: UUID(), name: folderName, itemIDs: []) 16 | folders.append(newFolder) 17 | Defaults[.folders] = folders 18 | } 19 | } 20 | 21 | func removeFolder(named folderName: String) { 22 | var folders = Defaults[.folders] 23 | folders.removeAll { $0.name == folderName } 24 | Defaults[.folders] = folders 25 | } 26 | 27 | func renameFolder(from oldName: String, to newName: String) { 28 | var folders = Defaults[.folders] 29 | if let index = folders.firstIndex(where: { $0.name == oldName }) { 30 | folders[index].name = newName 31 | Defaults[.folders] = folders 32 | } 33 | } 34 | 35 | func addItem(_ items: [ClipboardHistory], toFolder named: String) { 36 | var folders = Defaults[.folders] 37 | if let index = folders.firstIndex(where: { $0.name == named }) { 38 | let itemIDs = items.compactMap { $0.id } 39 | folders[index].itemIDs.append(contentsOf: itemIDs) 40 | Defaults[.folders] = folders 41 | } 42 | } 43 | 44 | func removeItem(_ items: [ClipboardHistory], fromFolder named: String) { 45 | var folders = Defaults[.folders] 46 | if let index = folders.firstIndex(where: { $0.name == named }) { 47 | let itemIDs = items.compactMap { $0.id } 48 | folders[index].itemIDs.removeAll { itemIDs.contains($0) } 49 | Defaults[.folders] = folders 50 | } 51 | } 52 | 53 | func items(inFolder named: String) -> [UUID]? { 54 | return Defaults[.folders].first(where: { $0.name == named })?.itemIDs 55 | } 56 | 57 | func allFolders() -> [String] { 58 | return Defaults[.folders] 59 | .map { $0.name } 60 | .sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending } 61 | } 62 | 63 | func refreshFolders(with existingItems: [ClipboardHistory]) { 64 | var folders = Defaults[.folders] 65 | let existingItemIDs = Set(existingItems.compactMap { $0.id }) 66 | 67 | for index in folders.indices { 68 | folders[index].itemIDs.removeAll { !existingItemIDs.contains($0) } 69 | } 70 | 71 | Defaults[.folders] = folders 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Clipchop/Clipchop.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-only 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /Clipchop/ClipchopApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ClipchopApp.swift 3 | // Clipchop 4 | // 5 | // Created by Xinshao_Air on 2024/7/5. 6 | // 7 | 8 | import SwiftUI 9 | import Defaults 10 | import KeyboardShortcuts 11 | import MenuBarExtraAccess 12 | 13 | let onStreamTime = try! Date("2024-05-13T00:00:00Z", strategy: .iso8601) 14 | 15 | @main 16 | struct ClipchopApp: App { 17 | 18 | @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate 19 | 20 | @State var isMenuBarPresented: Bool = true 21 | 22 | @Default(.menuBarItemEnabled) private var menuBarItemEnabled 23 | 24 | private let clipboardModelEditor = ClipboardModelEditor(provider: .shared) 25 | private let clipHistoryViewController = ClipHistoryPanelController() 26 | private let clipboardManager: ClipboardManager 27 | 28 | init() { 29 | 30 | self.clipboardManager = .init(context: ClipboardDataProvider.shared.viewContext) 31 | 32 | #if DEBUG 33 | // Resets Defaults 34 | Defaults[.menuBarItemEnabled] = Defaults.Keys.menuBarItemEnabled.defaultValue 35 | 36 | // Defaults[.beginningViewShown] = Defaults.Keys.beginningViewShown.defaultValue 37 | 38 | // Times Clipped: 60 39 | // Defaults[.timesClipped] =/* Defaults.Keys.timesClipped.defaultValue*/ 184 40 | // Defaults[.clipSound] = Defaults.Keys.clipSound.defaultValue 41 | // Defaults[.pasteSound] = Defaults.Keys.pasteSound.defaultValue 42 | 43 | // Defaults[.categories] = Defaults.Keys.categories.defaultValue 44 | // Defaults[.allTypes] = Defaults.Keys.allTypes.defaultValue 45 | // Defaults[.folders] = Defaults.Keys.folders.defaultValue 46 | 47 | // Defaults[.excludeAppsEnabled] = Defaults.Keys.excludeAppsEnabled.defaultValue 48 | // Defaults[.excludedApplications] = Defaults.Keys.excludedApplications.defaultValue 49 | 50 | // Defaults[.historyPreservationPeriod] = Defaults.Keys.historyPreservationPeriod.defaultValue 51 | // Defaults[.historyPreservationTime] = Defaults.Keys.historyPreservationTime.defaultValue 52 | 53 | // Resets clipboard history 54 | // try? clipboardModelEditor.deleteAll() 55 | #endif 56 | if !Defaults[.beginningViewShown] { 57 | clipboardManager.beginningViewController.open() 58 | Defaults[.beginningViewShown] = true 59 | } else { 60 | PermissionsManager.requestAccess() 61 | } 62 | } 63 | 64 | var body: some Scene { 65 | 66 | MenuBarExtra("Clipchop", image: "Empty", isInserted: $menuBarItemEnabled) { 67 | MenuBarView(clipboardController: ClipboardManager.clipboardController!) 68 | .environment(\.managedObjectContext, ClipboardDataProvider.shared.viewContext) 69 | } 70 | .menuBarExtraStyle(.menu) 71 | .menuBarExtraAccess(isPresented: $isMenuBarPresented) { menuBarItem in 72 | guard 73 | // Init once 74 | let button = menuBarItem.button, 75 | button.subviews.count == 0 76 | else { 77 | return 78 | } 79 | 80 | menuBarItem.length = 24 81 | 82 | let view = NSHostingView(rootView: MenuBarIconView()) 83 | view.frame.size = .init(width: 24, height: NSStatusBar.system.thickness) 84 | button.addSubview(view) 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /Clipchop/Extensions/AppKit/NSImage+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSImage+Extensions.swift 3 | // Clipchop 4 | // 5 | // Created by KrLite on 2024/5/12. 6 | // 7 | 8 | import AppKit 9 | import SwiftUICore 10 | 11 | extension NSImage { 12 | func resized(to newSize: NSSize) -> NSImage { 13 | return NSImage(size: newSize, flipped: false) { rect in 14 | self.draw(in: rect, 15 | from: NSRect(origin: CGPoint.zero, size: self.size), 16 | operation: NSCompositingOperation.copy, 17 | fraction: 1.0) 18 | return true 19 | } 20 | } 21 | } 22 | 23 | extension NSImage { 24 | var averageColor: Color? { 25 | guard let cgImage = self.cgImage(forProposedRect: nil, context: nil, hints: nil) else { 26 | return nil 27 | } 28 | 29 | let context = CIContext() 30 | let ciImage = CIImage(cgImage: cgImage) 31 | let extent = ciImage.extent 32 | let inputExtent = CIVector(x: extent.origin.x, y: extent.origin.y, z: extent.size.width, w: extent.size.height) 33 | guard let filter = CIFilter(name: "CIAreaAverage", parameters: [kCIInputImageKey: ciImage, kCIInputExtentKey: inputExtent]) else { 34 | return nil 35 | } 36 | 37 | guard let outputImage = filter.outputImage else { 38 | return nil 39 | } 40 | 41 | var bitmap = [UInt8](repeating: 0, count: 4) 42 | context.render(outputImage, toBitmap: &bitmap, rowBytes: 4, bounds: CGRect(x: 0, y: 0, width: 1, height: 1), format: .RGBA8, colorSpace: CGColorSpaceCreateDeviceRGB()) 43 | 44 | return Color(red: Double(bitmap[0]) / 255.0, green: Double(bitmap[1]) / 255.0, blue: Double(bitmap[2]) / 255.0, opacity: Double(bitmap[3]) / 255.0) 45 | } 46 | } 47 | 48 | extension NSImage { 49 | func pngData() -> Data? { 50 | guard let tiffRepresentation = self.tiffRepresentation else { return nil } 51 | guard let bitmap = NSBitmapImageRep(data: tiffRepresentation) else { return nil } 52 | return bitmap.representation(using: .png, properties: [:]) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Clipchop/Extensions/AppKit/NSPasteboard.PasteboardType+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSPasteboard.PasteboardType+Extensions.swift 3 | // Clipchop 4 | // 5 | // Created by KrLite on 2024/4/28. 6 | // 7 | 8 | import AppKit 9 | 10 | // Code from https://github.com/p0deje/Maccy 11 | extension NSPasteboard.PasteboardType { 12 | static let jpeg = NSPasteboard.PasteboardType(rawValue: "public.jpeg") 13 | static let universalClipboard = NSPasteboard.PasteboardType(rawValue: "com.apple.is-remote-clipboard") 14 | 15 | // See http://nspasteboard.org for more details. 16 | static let autoGenerated = NSPasteboard.PasteboardType(rawValue: "org.nspasteboard.AutoGeneratedType") 17 | static let concealed = NSPasteboard.PasteboardType(rawValue: "org.nspasteboard.ConcealedType") 18 | static let transient = NSPasteboard.PasteboardType(rawValue: "org.nspasteboard.TransientType") 19 | 20 | // https://github.com/p0deje/Maccy/issues/429#issuecomment-1182575226 21 | static let modified = NSPasteboard.PasteboardType(rawValue: "x.nspasteboard.ModifiedType") 22 | 23 | // Types that indicate Microsoft Word bookmarks (links). 24 | // https://github.com/p0deje/Maccy/issues/613 25 | static let microsoftObjectLink = NSPasteboard.PasteboardType(rawValue: "com.microsoft.ObjectLink") 26 | static let microsoftLinkSource = NSPasteboard.PasteboardType(rawValue: "com.microsoft.Link-Source") 27 | 28 | } 29 | 30 | extension NSPasteboard.PasteboardType { 31 | static let fromClipchop = NSPasteboard.PasteboardType(rawValue: "labs.cement.Clipchop") 32 | static let avif = NSPasteboard.PasteboardType(rawValue: "public.avif") 33 | static let appleFinalCutPro = NSPasteboard.PasteboardType(rawValue: "com.apple.flexo.proFFPasteboardUTI") 34 | static let appleReminders = NSPasteboard.PasteboardType(rawValue: "com.apple.reminders.reminderCopyPaste") 35 | } 36 | -------------------------------------------------------------------------------- /Clipchop/Extensions/AppKit/NSWindow+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSWindow+Extensions.swift 3 | // Clipchop 4 | // 5 | // Created by KrLite on 2024/5/25. 6 | // 7 | 8 | import AppKit 9 | 10 | extension NSWindow { 11 | var standardWindowButtons: [ButtonType: NSButton?] { 12 | [ 13 | .closeButton: standardWindowButton(.closeButton), 14 | .documentIconButton: standardWindowButton(.documentIconButton), 15 | .documentVersionsButton: standardWindowButton(.documentVersionsButton), 16 | .miniaturizeButton: standardWindowButton(.miniaturizeButton), 17 | .toolbarButton: standardWindowButton(.toolbarButton), 18 | .zoomButton: standardWindowButton(.zoomButton) 19 | ] 20 | } 21 | 22 | var availableStandardWindowButtons: [ButtonType: NSButton] { 23 | standardWindowButtons 24 | .compactMapValues { $0 } 25 | } 26 | 27 | var visibleWindowButtonTypes: [ButtonType] { 28 | get { 29 | availableStandardWindowButtons 30 | .filter { !$0.value.isHidden } 31 | .keys 32 | .map { $0 } 33 | } 34 | 35 | set { 36 | availableStandardWindowButtons.forEach { entry in 37 | entry.value.isHidden = !newValue.contains([entry.key]) 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Clipchop/Extensions/Color+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Color+Extensions.swift 3 | // Clipchop 4 | // 5 | // Created by Xinshao_Air on 2024/7/6. 6 | // 7 | 8 | import SwiftUI 9 | import Defaults 10 | import AppKit 11 | import CoreImage 12 | 13 | extension NSImage { 14 | func dominantColor() -> NSColor? { 15 | guard let inputImage = self.cgImage(forProposedRect: nil, context: nil, hints: nil).flatMap({ CIImage(cgImage: $0) }) else { return nil } 16 | 17 | let extentVector = CIVector(x: inputImage.extent.origin.x, 18 | y: inputImage.extent.origin.y, 19 | z: inputImage.extent.size.width, 20 | w: inputImage.extent.size.height) 21 | 22 | guard let filter = CIFilter(name: "CIAreaAverage", parameters: [ 23 | kCIInputImageKey: inputImage, 24 | kCIInputExtentKey: extentVector 25 | ]) else { return nil } 26 | 27 | guard let outputImage = filter.outputImage else { return nil } 28 | 29 | var bitmap = [UInt8](repeating: 0, count: 4) 30 | let context = CIContext(options: [.workingColorSpace: kCFNull!]) 31 | context.render(outputImage, 32 | toBitmap: &bitmap, 33 | rowBytes: 4, 34 | bounds: CGRect(x: 0, y: 0, width: 1, height: 1), 35 | format: .RGBA8, 36 | colorSpace: nil) 37 | 38 | return NSColor(red: CGFloat(bitmap[0]) / 255.0, 39 | green: CGFloat(bitmap[1]) / 255.0, 40 | blue: CGFloat(bitmap[2]) / 255.0, 41 | alpha: CGFloat(bitmap[3]) / 255.0) 42 | } 43 | } 44 | 45 | extension Color { 46 | static func getAccent() -> Color { 47 | return inlineAccentColor(style: Defaults[.colorStyle], customColor: Defaults[.customAccentColor]) 48 | } 49 | 50 | static func inlineAccentColor(style: ColorStyle, customColor: Color) -> Color { 51 | switch style { 52 | case .app: 53 | return .accent 54 | case .system: 55 | return .blue 56 | case .custom: 57 | return customColor 58 | } 59 | } 60 | } 61 | 62 | extension Color { 63 | var brightness: Double { 64 | guard let components = self.cgColor?.components, components.count >= 3 else { 65 | return 0 66 | } 67 | let red = Double(components[0] * 255) 68 | let green = Double(components[1] * 255) 69 | let blue = Double(components[2] * 255) 70 | return (red * 299 + green * 587 + blue * 114) / 1000 71 | } 72 | 73 | var isLight: Bool { 74 | return brightness > 128 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /Clipchop/Extensions/Foundation/Bundle+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Bundle+Extensions.swift 3 | // Clipchop 4 | // 5 | // Created by KrLite on 2024/4/27. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Bundle { 11 | var appName: String { getInfo("CFBundleName") } 12 | var displayName: String { getInfo("CFBundleDisplayName") } 13 | var bundleID: String { getInfo("CFBundleIdentifier") } 14 | var copyright: String { getInfo("NSHumanReadableCopyright") } 15 | 16 | var appBuild: String { getInfo("CFBundleVersion") } 17 | var appVersion: String { getInfo("CFBundleShortVersionString") } 18 | 19 | func getInfo(_ str: String) -> String { 20 | infoDictionary?[str] as? String ?? "⚠️" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Clipchop/Extensions/Foundation/MutableCollection+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MutableCollection+Extensions.swift 3 | // Clipchop 4 | // 5 | // Created by KrLite on 2024/5/26. 6 | // 7 | 8 | import Foundation 9 | 10 | extension MutableCollection { 11 | mutating func updateEach(_ update: (inout Element) -> Void) { 12 | for i in indices { 13 | update(&self[i]) 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Clipchop/Extensions/Foundation/Notification+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Notification+Extensions.swift 3 | // Clipchop 4 | // 5 | // Created by KrLite on 2024/5/25. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Notification.Name { 11 | static let didClip = Self("didClip") 12 | static let didPaste = Self("didPaste") 13 | // ClipHistory View expansion notification 14 | } 15 | 16 | extension Notification.Name { 17 | @discardableResult 18 | func onReceive(object: Any? = nil, using: @escaping (Notification) -> Void) -> NSObjectProtocol { 19 | return NotificationCenter.default.addObserver( 20 | forName: self, 21 | object: object, 22 | queue: .main, 23 | using: using 24 | ) 25 | } 26 | 27 | func post(object: Any? = nil, userInfo: [AnyHashable: Any]? = nil) { 28 | NotificationCenter.default.post(name: self, object: object, userInfo: userInfo) 29 | } 30 | } 31 | 32 | extension Notification.Name { 33 | static let panelDidClose = Notification.Name("panelDidClose") 34 | static let panelDidOpen = Notification.Name("panelDidOpen") 35 | static let panelDidLogout = Notification.Name("panelDidLogout") 36 | } 37 | 38 | -------------------------------------------------------------------------------- /Clipchop/Extensions/Foundation/Set+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Set+Extensions.swift 3 | // Clipchop 4 | // 5 | // Created by KrLite on 2024/5/28. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Set { 11 | mutating func updateEach(_ update: (inout Element) -> Void) { 12 | for i in indices { 13 | let original = self[i] 14 | var element = original 15 | update(&element) 16 | 17 | guard original != element else { continue } 18 | self.remove(original) 19 | self.insert(element) 20 | } 21 | } 22 | } 23 | 24 | extension Set where Element == FileType { 25 | func sorted() -> [FileType] { 26 | sorted { 27 | $0.ext < $1.ext 28 | } 29 | } 30 | } 31 | 32 | extension Set where Element == FileType.Category { 33 | func sorted() -> [FileType.Category] { 34 | sorted { 35 | $0.name < $1.name 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Clipchop/Extensions/KeyboardShortcuts+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KeyboardShortcuts+Extensions.swift 3 | // Clipchop 4 | // 5 | // Created by KrLite on 2024/4/28. 6 | // 7 | 8 | import KeyboardShortcuts 9 | import AppKit 10 | 11 | extension KeyboardShortcuts.Name { 12 | 13 | static let window = Self("window", default: .init(.w, modifiers: .option)) 14 | static let start = Self("start", default: .init(.q, modifiers: .control)) 15 | 16 | static let settings = Self("settings", default: .init(.comma, modifiers: .command)) 17 | // static let close = Self("close", default: .init(.w, modifiers: .command)) 18 | static let expand = Self("expand", default: .init(.rightBracket)) 19 | static let collapse = Self("collapse", default: .init(.leftBracket)) 20 | } 21 | 22 | extension KeyboardShortcuts.Key { 23 | var shortcut: KeyboardShortcuts.Shortcut { 24 | .init(self) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Clipchop/Extensions/SwiftUI/EnvironmentValues+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EnvironmentValues+Extensions.swift 3 | // Clipchop 4 | // 5 | // Created by KrLite on 2024/5/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension EnvironmentValues { 11 | var hasTitle: Bool { 12 | get { self[HasTitleEnvironmentKey.self] } 13 | set { self[HasTitleEnvironmentKey.self] = newValue } 14 | } 15 | 16 | var isSearchable: Bool { 17 | get { self[IsSearchableEnvironmentKey.self] } 18 | set { self[IsSearchableEnvironmentKey.self] = newValue } 19 | } 20 | 21 | var alternatingLayout: Bool { 22 | get { self[AlternatingLayourEnvironmentKey.self] } 23 | set { self[AlternatingLayourEnvironmentKey.self] = newValue } 24 | } 25 | 26 | var canContinue: (Bool) -> Void { 27 | get { self[CanContinueEnvironmentKey.self] } 28 | set { self[CanContinueEnvironmentKey.self] = newValue } 29 | } 30 | 31 | var viewController: ViewController? { 32 | get { self[ViewControllerEnvironmentKey.self] } 33 | set { self[ViewControllerEnvironmentKey.self] = newValue } 34 | } 35 | 36 | var namespace: Namespace.ID? { 37 | get { self[NamespaceEnvironmentKey.self] } 38 | set { self[NamespaceEnvironmentKey.self] = newValue } 39 | } 40 | 41 | var isVisible: Bool { 42 | get { self[IsVisibleEnvironmentKey.self] } 43 | set { self[IsVisibleEnvironmentKey.self] = newValue } 44 | } 45 | } 46 | 47 | struct HasTitleEnvironmentKey: EnvironmentKey { 48 | static var defaultValue: Bool = true 49 | } 50 | 51 | struct IsSearchableEnvironmentKey: EnvironmentKey { 52 | static var defaultValue: Bool = true 53 | } 54 | 55 | struct AlternatingLayourEnvironmentKey: EnvironmentKey { 56 | static var defaultValue: Bool = false 57 | } 58 | 59 | struct CanContinueEnvironmentKey: EnvironmentKey { 60 | static var defaultValue: (Bool) -> Void = { _ in } 61 | } 62 | 63 | struct ViewControllerEnvironmentKey: EnvironmentKey { 64 | static var defaultValue: ViewController? 65 | } 66 | 67 | struct NamespaceEnvironmentKey: EnvironmentKey { 68 | static var defaultValue: Namespace.ID? 69 | } 70 | 71 | struct IsVisibleEnvironmentKey: EnvironmentKey { 72 | static var defaultValue: Bool = true 73 | } 74 | -------------------------------------------------------------------------------- /Clipchop/Extensions/SwiftUI/Transision+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Transision+Extensions.swift 3 | // Clipchop 4 | // 5 | // Created by KrLite on 2024/5/25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | // https://stackoverflow.com/a/71599594/23452915 11 | extension AnyTransition { 12 | static func rotate3D(angle: Angle) -> AnyTransition { 13 | AnyTransition.modifier( 14 | active: Rotate3DModifier(value: 1, angle: angle), 15 | identity: Rotate3DModifier(value: 0, angle: angle)) 16 | } 17 | } 18 | 19 | struct Rotate3DModifier: ViewModifier { 20 | let value: Double 21 | let angle: Angle 22 | 23 | func body(content: Content) -> some View { 24 | content 25 | .scaleEffect(1 - value * 0.25) 26 | .rotation3DEffect(.radians(angle.radians * value), axis: (x: 0, y: 1, z: 0)) 27 | .opacity(1 - value) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Clipchop/Extensions/SwiftUI/View+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // View+Extensions.swift 3 | // Dial 4 | // 5 | // Created by KrLite on 2024/3/23. 6 | // 7 | 8 | import SwiftUI 9 | import SwiftUIIntrospect 10 | 11 | extension View { 12 | func or(_ condition: Bool, _ another: () -> Self) -> Self { 13 | condition ? another() : self 14 | } 15 | 16 | @ViewBuilder 17 | func orSomeView(_ condition: Bool, _ another: () -> some View) -> some View { 18 | if condition { 19 | another() 20 | } else { 21 | self 22 | } 23 | } 24 | 25 | @ViewBuilder 26 | func `if`( 27 | _ condition: Bool, 28 | trueExpression: (Self) -> some View, 29 | falseExpression: (Self) -> some View 30 | ) -> some View { 31 | if condition { 32 | trueExpression(self) 33 | } else { 34 | falseExpression(self) 35 | } 36 | } 37 | 38 | @ViewBuilder 39 | func `if`( 40 | _ condition: Bool, 41 | expression: (Self) -> some View 42 | ) -> some View { 43 | `if`(condition, trueExpression: expression) { view in 44 | view 45 | } 46 | } 47 | 48 | @ViewBuilder 49 | func possibleKeyboardShortcut( 50 | _ key: KeyEquivalent?, 51 | modifiers: EventModifiers = .command, 52 | localization: KeyboardShortcut.Localization = .automatic 53 | ) -> some View { 54 | if let key { 55 | self.keyboardShortcut(key, modifiers: modifiers, localization: localization) 56 | } else { 57 | self 58 | } 59 | } 60 | 61 | // https://github.com/MrKai77/Loop 62 | func onReceive( 63 | _ name: Notification.Name, 64 | center: NotificationCenter = .default, 65 | object: AnyObject? = nil, 66 | perform action: @escaping (Notification) -> Void 67 | ) -> some View { 68 | self.onReceive( 69 | center.publisher(for: name, object: object), 70 | perform: action 71 | ) 72 | } 73 | 74 | @ViewBuilder 75 | func navigationSplitViewCollapsingDisabled() -> some View { 76 | // Completely prevents the sidebar from collapsing 77 | self.introspect(.navigationSplitView, on: .macOS(.v14), scope: .ancestor) { splitView in 78 | (splitView.delegate as? NSSplitViewController)?.splitViewItems.forEach { $0.canCollapse = false } 79 | } 80 | } 81 | } 82 | 83 | extension View { 84 | func log(_ items: Any..., separator: String = " ", terminator: String = "\n") { 85 | Clipchop.log(self, items, separator: separator, terminator: terminator) 86 | } 87 | } 88 | 89 | extension View { 90 | @ViewBuilder 91 | func applyMatchedGeometryEffect(if condition: Bool, id: AnyHashable, namespace: Namespace.ID) -> some View { 92 | if condition { 93 | self.matchedGeometryEffect(id: id, in: namespace, properties: .frame, anchor: .center, isSource: true) 94 | } else { 95 | self 96 | } 97 | } 98 | } 99 | 100 | 101 | extension View { 102 | func applyKeyboardShortcut(_ keyboardShortcut: String, modifier: EventModifiers) -> some View { 103 | if keyboardShortcut != "none" && !modifier.isEmpty { 104 | return AnyView(self.keyboardShortcut(KeyEquivalent(keyboardShortcut.first!), modifiers: modifier)) 105 | } else { 106 | return AnyView(self) 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /Clipchop/Extensions/SwiftUI/View+Functions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // View+Functions.swift 3 | // Clipchop 4 | // 5 | // Created by KrLite on 2024/5/25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @ViewBuilder 11 | func description(@ViewBuilder label: () -> some View) -> some View { 12 | label() 13 | .font(.caption) 14 | .foregroundStyle(.secondary) 15 | } 16 | 17 | @ViewBuilder 18 | func withCaption( 19 | condition: Bool = true, 20 | spacing: CGFloat? = nil, 21 | @ViewBuilder content: () -> some View, 22 | @ViewBuilder caption: () -> some View 23 | ) -> some View { 24 | VStack(alignment: .leading, spacing: spacing) { 25 | content() 26 | 27 | if condition { 28 | description { 29 | caption() 30 | } 31 | } 32 | } 33 | } 34 | 35 | @ViewBuilder 36 | func withCaption( 37 | _ descriptionKey: LocalizedStringKey, 38 | condition: Bool = true, 39 | spacing: CGFloat? = nil, 40 | @ViewBuilder content: () -> some View 41 | ) -> some View { 42 | withCaption(condition: condition, spacing: spacing) { 43 | content() 44 | } caption: { 45 | Text(descriptionKey) 46 | } 47 | } 48 | 49 | @ViewBuilder 50 | func previewSection(content: () -> some View) -> some View { 51 | previewPage { 52 | Form { 53 | content() 54 | } 55 | } 56 | } 57 | 58 | @ViewBuilder 59 | func previewPage(content: () -> some View) -> some View { 60 | content() 61 | .formStyle(.grouped) 62 | } 63 | -------------------------------------------------------------------------------- /Clipchop/Extensions/UNNotification+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UNNotification+Extensions.swift 3 | // Clipchop 4 | // 5 | // Created by Xinshao_Air on 2024/8/13. 6 | // 7 | 8 | 9 | import SwiftUI 10 | import UserNotifications 11 | 12 | // Thanks https://stackoverflow.com/questions/45226847/unnotificationattachment-failing-to-attach-image 13 | extension UNNotificationAttachment { 14 | static func create(_ imgData: NSData) -> UNNotificationAttachment? { 15 | let imageFileIdentifier = UUID().uuidString + ".jpeg" 16 | 17 | let fileManager = FileManager.default 18 | let tmpSubFolderName = ProcessInfo.processInfo.globallyUniqueString 19 | let tmpSubFolderURL = NSURL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent( 20 | tmpSubFolderName, 21 | isDirectory: true 22 | ) 23 | 24 | do { 25 | try fileManager.createDirectory(at: tmpSubFolderURL!, withIntermediateDirectories: true, attributes: nil) 26 | let fileURL = tmpSubFolderURL?.appendingPathComponent(imageFileIdentifier) 27 | try imgData.write(to: fileURL!, options: []) 28 | let imageAttachment = try UNNotificationAttachment( 29 | identifier: imageFileIdentifier, 30 | url: fileURL!, 31 | options: nil 32 | ) 33 | return imageAttachment 34 | } catch { 35 | print("error \(error.localizedDescription)") 36 | } 37 | 38 | return nil 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Clipchop/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /Clipchop/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Clipchop/Utilities/DefaultsStack.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DefaultsStack.swift 3 | // Clipchop 4 | // 5 | // Created by KrLite on 2024/5/18. 6 | // 7 | 8 | import Foundation 9 | import Defaults 10 | 11 | extension [AnyHashable] { 12 | var combinedHashValue: Int { 13 | var hasher = Hasher() 14 | self.forEach { 15 | hasher.combine($0) 16 | } 17 | return hasher.finalize() 18 | } 19 | } 20 | 21 | struct DefaultsStack { 22 | enum Group: String, CaseIterable { 23 | case accentColor = "accentColor" 24 | case historyPreservation = "historyPreservation" 25 | 26 | var relationships: [AnyHashable] { 27 | switch self { 28 | case .accentColor: 29 | [Defaults[.colorStyle], Defaults[.customAccentColor]] 30 | case .historyPreservation: 31 | [Defaults[.historyPreservationPeriod], Defaults[.historyPreservationTime]] 32 | } 33 | } 34 | 35 | var hashValue: Int { 36 | relationships.combinedHashValue 37 | } 38 | 39 | var isUnchanged: Bool { 40 | DefaultsStack.shared.isUnchanged(self) 41 | } 42 | 43 | func isIdentical(comparedTo: [AnyHashable]) -> Bool { 44 | DefaultsStack.shared.isIdentical(self, comparedTo: comparedTo) 45 | } 46 | 47 | func markDirty() { 48 | DefaultsStack.shared.markDirty(self) 49 | } 50 | } 51 | 52 | static var shared = DefaultsStack() 53 | 54 | var hash: [Group: Int] 55 | 56 | init() { 57 | hash = DefaultsStack.hashAll() 58 | } 59 | 60 | static func hashAll(_ groups: [Group] = Group.allCases) -> [Group: Int] { 61 | groups.reduce(into: [Group: Int]()) { result, group in 62 | result[group] = group.hashValue 63 | } 64 | } 65 | 66 | func isUnchanged(_ group: Group) -> Bool { 67 | guard let hashed = hash[group] else { return false } 68 | return hashed == group.hashValue 69 | } 70 | 71 | func isIdentical(_ group: Group, comparedTo: [AnyHashable]) -> Bool { 72 | group.hashValue == comparedTo.combinedHashValue 73 | } 74 | 75 | mutating func markDirty(_ group: Group) { 76 | hash[group] = group.hashValue 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /Clipchop/Utilities/LifecycleHandler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LifecycleHandler.swift 3 | // Clipchop 4 | // 5 | // Created by KrLite on 2024/5/25. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | func quit() { 12 | NSApp.terminate(nil) 13 | } 14 | 15 | // https://stackoverflow.com/questions/29847611/restarting-osx-app-programmatically 16 | func relaunch() { 17 | let url = URL(fileURLWithPath: Bundle.main.resourcePath!) 18 | let path = url.deletingLastPathComponent().deletingLastPathComponent().absoluteString 19 | let task = Process() 20 | 21 | task.launchPath = "/usr/bin/open" 22 | task.arguments = [path] 23 | task.launch() 24 | 25 | quit() 26 | } 27 | -------------------------------------------------------------------------------- /Clipchop/Utilities/Logger.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Logger.swift 3 | // Clipchop 4 | // 5 | // Created by KrLite on 2024/5/25. 6 | // 7 | 8 | func log( 9 | _ subject: Subject? = nil, 10 | _ items: Any..., separator: String = " ", terminator: String = "\n" 11 | ) { 12 | if let subject { 13 | print(String(describing: type(of: subject)), terminator: " - ") 14 | } 15 | print(items, separator: separator, terminator: terminator) 16 | } 17 | -------------------------------------------------------------------------------- /Clipchop/Utilities/NotificationManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NotificationManager.swift 3 | // Clipchop 4 | // 5 | // Created by KrLite on 2024/4/28. 6 | // 7 | 8 | import Foundation 9 | import AppKit 10 | import UserNotifications 11 | 12 | class NotificationManager { 13 | private static var center: UNUserNotificationCenter { UNUserNotificationCenter.current() } 14 | 15 | static func authorize() { 16 | center.requestAuthorization(options: [.alert, .sound]) { _, error in 17 | if error != nil { 18 | NSLog("Failed to authorize notifications: \(String(describing: error))") 19 | } 20 | } 21 | } 22 | 23 | static func notify(body: String?, sound: NSSound?) { 24 | guard let body else { return } 25 | 26 | authorize() 27 | 28 | center.getNotificationSettings { settings in 29 | guard (settings.authorizationStatus == .authorized) || 30 | (settings.authorizationStatus == .provisional) else { return } 31 | 32 | let content = UNMutableNotificationContent() 33 | if settings.alertSetting == .enabled { 34 | content.body = body 35 | } 36 | 37 | let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) 38 | center.add(request) { error in 39 | if error != nil { 40 | NSLog("Failed to deliver notification: \(String(describing: error))") 41 | } else { 42 | if settings.soundSetting == .enabled { 43 | sound?.play() 44 | } 45 | } 46 | } 47 | } 48 | } 49 | } 50 | 51 | -------------------------------------------------------------------------------- /Clipchop/Utilities/ObservationTrackingStream.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ObservationTrackingStream.swift 3 | // Clipchop 4 | // 5 | // Created by KrLite on 2024/5/18. 6 | // 7 | 8 | import Foundation 9 | 10 | func observationTrackingStream( 11 | _ apply: @escaping () -> T 12 | ) -> AsyncStream { 13 | .init { continuation in 14 | @Sendable func observe() { 15 | let result = withObservationTracking { 16 | apply() 17 | } onChange: { 18 | DispatchQueue.main.async { 19 | observe() 20 | } 21 | } 22 | 23 | continuation.yield(result) 24 | } 25 | 26 | observe() 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Clipchop/Utilities/PerimissionsManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PerimissionsManager.swift 3 | // Clipchop 4 | // 5 | // Created by KrLite on 2024/4/27. 6 | // 7 | 8 | import Foundation 9 | import AppKit 10 | import FullDiskAccess 11 | 12 | class PermissionsManager { 13 | static var remaining: Int { 14 | [Accessibility.getStatus(), FullDisk.getStatus()] 15 | .filter { !$0 } 16 | .count 17 | } 18 | 19 | static func requestAccess() { 20 | PermissionsManager.Accessibility.requestAccess() 21 | PermissionsManager.FullDisk.requestAccess() 22 | } 23 | 24 | class Accessibility { 25 | static func getStatus() -> Bool { 26 | // Get current status for Accessibility Access 27 | let options: NSDictionary = [kAXTrustedCheckOptionPrompt.takeRetainedValue() as NSString: false] 28 | let status = AXIsProcessTrustedWithOptions(options) 29 | 30 | return status 31 | } 32 | 33 | @discardableResult 34 | static func requestAccess() -> Bool { 35 | // More information on this behaviour: https://stackoverflow.com/questions/29006379/accessibility-permissions-reset-after-application-update 36 | guard !Accessibility.getStatus() else { return true } 37 | 38 | let alert = NSAlert() 39 | alert.alertStyle = NSAlert.Style.informational 40 | alert.messageText = String( 41 | format: String( 42 | localized: "Accessibility Access Alert: Title", 43 | defaultValue: "%@ Needs Accessibility Access" 44 | ), 45 | Bundle.main.appName 46 | ) 47 | alert.informativeText = String( 48 | format: String( 49 | localized: "Accessibility Access Alert: Content", 50 | defaultValue: """ 51 | Accessibility Access is required for %@ to take over your clipboard. 52 | """ 53 | ), 54 | Bundle.main.appName 55 | ) 56 | 57 | alert.runModal() 58 | 59 | let options: NSDictionary = [kAXTrustedCheckOptionPrompt.takeRetainedValue() as NSString: true] 60 | let status = AXIsProcessTrustedWithOptions(options) 61 | 62 | return status 63 | } 64 | } 65 | 66 | class FullDisk { 67 | static func getStatus() -> Bool { 68 | // Get current status for Full Disk Access 69 | return FullDiskAccess.isGranted 70 | } 71 | 72 | @discardableResult 73 | static func requestAccess() -> Bool { 74 | guard !FullDisk.getStatus() else { return true } 75 | 76 | FullDiskAccess.promptIfNotGranted( 77 | title: String( 78 | format: String( 79 | localized: "Full Disk Access Alert: Title", 80 | defaultValue: "%@ Needs Full Disk Access" 81 | ), 82 | Bundle.main.appName 83 | ), 84 | message: String( 85 | format: String( 86 | localized: "Full Disk Access Alert: Content", 87 | defaultValue: """ 88 | Full Disk Access is required for %@ to generate file previews. 89 | """ 90 | ), 91 | Bundle.main.appName 92 | ), 93 | settingsButtonTitle: String(localized: .init("Open in System Settings", defaultValue: "Open in System Settings")), 94 | skipButtonTitle: String(localized: .init("Later", defaultValue: "Later")), 95 | canBeSuppressed: false, 96 | icon: nil 97 | ) 98 | 99 | return getStatus() 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /Clipchop/Utilities/Throttler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Throttler.swift 3 | // Clipchop 4 | // 5 | // Created by Xinshao_Air on 2024/7/20. 6 | // 7 | 8 | import Foundation 9 | 10 | class Throttler { 11 | var minimumDelay: TimeInterval 12 | 13 | private var workItem: DispatchWorkItem = DispatchWorkItem(block: {}) 14 | private var previousRun: Date = Date.distantPast 15 | private let queue: DispatchQueue 16 | 17 | init(minimumDelay: TimeInterval, queue: DispatchQueue = DispatchQueue.main) { 18 | self.minimumDelay = minimumDelay 19 | self.queue = queue 20 | } 21 | 22 | func throttle(_ block: @escaping () -> Void) { 23 | cancel() 24 | workItem = DispatchWorkItem { [weak self] in 25 | self?.previousRun = Date() 26 | block() 27 | } 28 | 29 | let delay = previousRun.timeIntervalSinceNow > minimumDelay ? 0 : minimumDelay 30 | queue.asyncAfter(deadline: .now() + Double(delay), execute: workItem) 31 | } 32 | 33 | func cancel() { 34 | workItem.cancel() 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Clipchop/Utilities/ViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewController.swift 3 | // Clipchop 4 | // 5 | // Created by KrLite on 2024/5/25. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol ViewController { 11 | func close() 12 | } 13 | -------------------------------------------------------------------------------- /Clipchop/Utilities/getCursorPosition.swift: -------------------------------------------------------------------------------- 1 | // 2 | // getCursorPosition.swift 3 | // Clipchop 4 | // 5 | // Created by Xinshao_Air on 2024/7/19. 6 | // 7 | 8 | import Foundation 9 | import ApplicationServices 10 | import Accessibility 11 | import Carbon 12 | import AppKit 13 | 14 | func getFrontmostAppPID() -> pid_t? { 15 | guard let frontmostApp = NSWorkspace.shared.frontmostApplication else { 16 | return nil 17 | } 18 | return frontmostApp.processIdentifier 19 | } 20 | 21 | func getIMECursorPosition() -> CGPoint? { 22 | guard let frontmostAppPID = getFrontmostAppPID() else { 23 | print("Failed to get frontmost app PID") 24 | return nil 25 | } 26 | 27 | let app = AXUIElementCreateApplication(frontmostAppPID) 28 | var focusedElement: CFTypeRef? 29 | let result = AXUIElementCopyAttributeValue(app, kAXFocusedUIElementAttribute as CFString, &focusedElement) 30 | 31 | if result != .success { 32 | print("Failed to get focused element, result: \(result)") 33 | return nil 34 | } 35 | 36 | guard let focusedElement = focusedElement else { 37 | print("Focused element is nil") 38 | return nil 39 | } 40 | 41 | var rangeValue: CFTypeRef? 42 | let rangeResult = AXUIElementCopyAttributeValue(focusedElement as! AXUIElement, kAXSelectedTextRangeAttribute as CFString, &rangeValue) 43 | 44 | if rangeResult != .success { 45 | print("Failed to get selected text range, result: \(rangeResult)") 46 | return nil 47 | } 48 | 49 | guard let rangeValue = rangeValue else { 50 | print("Selected text range is nil") 51 | return nil 52 | } 53 | 54 | var range = CFRange() 55 | if !AXValueGetValue(rangeValue as! AXValue, .cfRange, &range) { 56 | print("Failed to convert AXValue to CFRange") 57 | return nil 58 | } 59 | 60 | var boundsValue: CFTypeRef? 61 | let boundsResult = AXUIElementCopyParameterizedAttributeValue(focusedElement as! AXUIElement, kAXBoundsForRangeParameterizedAttribute as CFString, rangeValue, &boundsValue) 62 | 63 | if boundsResult != .success { 64 | print("Failed to get bounds for range, result: \(boundsResult)") 65 | return nil 66 | } 67 | 68 | guard let boundsValue = boundsValue else { 69 | print("Bounds value is nil") 70 | return nil 71 | } 72 | 73 | var rect = CGRect() 74 | if !AXValueGetValue(boundsValue as! AXValue, .cgRect, &rect) { 75 | print("Failed to convert AXValue to CGRect") 76 | return nil 77 | } 78 | 79 | return rect.origin 80 | } 81 | 82 | func convertScreenToGlobalCoordinates(screenPoint: CGPoint) -> CGPoint { 83 | let screenHeight = NSScreen.main?.frame.height ?? 0 84 | let convertedY = screenHeight - screenPoint.y 85 | return CGPoint(x: screenPoint.x, y: convertedY) 86 | } 87 | 88 | func getGlobalCursorPosition() -> CGPoint { 89 | if let screenCursorPosition = getIMECursorPosition() { 90 | return convertScreenToGlobalCoordinates(screenPoint: screenCursorPosition) 91 | } else { 92 | return NSEvent.mouseLocation 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /Clipchop/Views/Beginning/BeginningViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BeginningViewController.swift 3 | // Clipchop 4 | // 5 | // Created by KrLite on 2024/5/22. 6 | // 7 | 8 | import SwiftUI 9 | import Defaults 10 | import KeyboardShortcuts 11 | 12 | @Observable class BeginningViewController: ViewController { 13 | static let size = NSSize(width: 450, height: 500) 14 | 15 | private var windowController: NSWindowController? 16 | 17 | // MARK: - Open / Close 18 | 19 | var isOpened: Bool { 20 | windowController != nil 21 | } 22 | 23 | func open() { 24 | if let windowController { 25 | windowController.window?.orderFrontRegardless() 26 | return 27 | } 28 | 29 | let window = NSWindow( 30 | contentRect: .zero, 31 | styleMask: [.closable, .titled, .fullSizeContentView], 32 | backing: .buffered, 33 | defer: true 34 | ) 35 | 36 | let screenRect = NSScreen.main?.frame ?? .null 37 | 38 | window.titlebarAppearsTransparent = true 39 | window.titleVisibility = .hidden 40 | window.collectionBehavior = .canJoinAllSpaces 41 | window.level = .normal 42 | window.isMovableByWindowBackground = true 43 | 44 | window.visibleWindowButtonTypes = [.closeButton] 45 | 46 | window.contentView = NSHostingView( 47 | rootView: BeginningView() 48 | .environment(\.viewController, self) 49 | ) 50 | window.setFrame(CGRect( 51 | x: screenRect.midX - Self.size.width / 2, y: screenRect.midY - Self.size.height / 2, 52 | width: Self.size.width, height: Self.size.height 53 | ), display: false) 54 | window.orderFrontRegardless() 55 | 56 | windowController = .init(window: window) 57 | } 58 | 59 | func close() { 60 | guard let windowController else { return } 61 | self.windowController = nil 62 | windowController.close() 63 | } 64 | 65 | func toggle() { 66 | if isOpened { 67 | close() 68 | } else { 69 | open() 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Clipchop/Views/Beginning/Pages/BeginningAllSetPage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BeginningAllSetPage.swift 3 | // Clipchop 4 | // 5 | // Created by KrLite on 2024/5/25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct BeginningAllSetPage: View { 11 | @Environment(\.namespace) private var namespace 12 | @Environment(\.isVisible) private var isVisible 13 | 14 | var body: some View { 15 | VStack { 16 | if isVisible { 17 | Image(nsImage: AppIcon.currentAppIcon.image) 18 | .matchedGeometryEffect(id: "flip", in: namespace!) 19 | .transition(.blurReplace.combined(with: .scale(0.75))) 20 | } 21 | 22 | Text("You're All Set!").foregroundStyle(.accent) 23 | } 24 | .font(.title) 25 | .bold() 26 | .frame(width: BeginningViewController.size.width) 27 | .frame(maxHeight: .infinity) 28 | } 29 | } 30 | 31 | #Preview { 32 | BeginningAllSetPage() 33 | } 34 | -------------------------------------------------------------------------------- /Clipchop/Views/Beginning/Pages/BeginningCustomizationPage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BeginningCustomizationPage.swift 3 | // Clipchop 4 | // 5 | // Created by KrLite on 2024/5/22. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct BeginningCustomizationPage: View { 11 | @Environment(\.namespace) private var namespace 12 | @Environment(\.isVisible) private var isVisible 13 | 14 | var body: some View { 15 | VStack { 16 | VStack { 17 | if isVisible { 18 | Image(systemSymbol: .pencilAndOutline) 19 | .imageScale(.large) 20 | .padding() 21 | .matchedGeometryEffect(id: "flip", in: namespace!) 22 | .transition(.rotate3D(angle: .degrees(65)).combined(with: .scale).combined(with: .opacity)) 23 | } 24 | 25 | Text("Customization") 26 | } 27 | .font(.title) 28 | .bold() 29 | .frame(height: 200) 30 | 31 | Form { 32 | AppearanceSection() 33 | } 34 | .formStyle(.grouped) 35 | .scrollDisabled(true) 36 | } 37 | .padding() 38 | .frame(width: BeginningViewController.size.width) 39 | .frame(maxHeight: .infinity) 40 | } 41 | } 42 | 43 | #Preview { 44 | BeginningCustomizationPage() 45 | } 46 | -------------------------------------------------------------------------------- /Clipchop/Views/Beginning/Pages/BeginningHelloPage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BeginningHelloPage.swift 3 | // Clipchop 4 | // 5 | // Created by KrLite on 2024/5/22. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct BeginningHelloPage: View { 11 | @Environment(\.namespace) private var namespace 12 | @Environment(\.isVisible) private var isVisible 13 | 14 | var body: some View { 15 | VStack { 16 | if isVisible { 17 | Image(nsImage: AppIcon.currentAppIcon.image) 18 | .matchedGeometryEffect(id: "flip", in: namespace!) 19 | .transition(.blurReplace.combined(with: .scale(0.75))) 20 | } 21 | 22 | Text("Welcome to \(Text(Bundle.main.appName).foregroundStyle(.accent))") 23 | } 24 | .font(.title) 25 | .bold() 26 | .frame(width: BeginningViewController.size.width) 27 | .frame(maxHeight: .infinity) 28 | } 29 | } 30 | 31 | #Preview { 32 | BeginningHelloPage() 33 | } 34 | -------------------------------------------------------------------------------- /Clipchop/Views/Beginning/Pages/BeginningPermissionsPage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BeginningPermissionsPage.swift 3 | // Clipchop 4 | // 5 | // Created by KrLite on 2024/5/22. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct BeginningPermissionsPage: View { 11 | @Environment(\.canContinue) private var canContinue 12 | @Environment(\.namespace) private var namespace 13 | @Environment(\.isVisible) private var isVisible 14 | 15 | var body: some View { 16 | VStack { 17 | VStack { 18 | if isVisible { 19 | Image(systemSymbol: .lock) 20 | .imageScale(.large) 21 | .padding() 22 | .matchedGeometryEffect(id: "flip", in: namespace!) 23 | .transition(.rotate3D(angle: .degrees(65)).combined(with: .scale).combined(with: .opacity)) 24 | } 25 | 26 | Text("Permissions") 27 | } 28 | .font(.title) 29 | .bold() 30 | .frame(height: 200) 31 | 32 | Form { 33 | PermissionsSection() 34 | } 35 | .formStyle(.grouped) 36 | .scrollDisabled(true) 37 | } 38 | .padding() 39 | .frame(width: BeginningViewController.size.width) 40 | .frame(maxHeight: .infinity) 41 | 42 | #if !DEBUG 43 | // .onChange(of: PermissionsManager.remaining, initial: true) { old, new in 44 | // canContinue(new == 0) 45 | // } 46 | #endif 47 | } 48 | } 49 | 50 | #Preview { 51 | BeginningPermissionsPage() 52 | } 53 | -------------------------------------------------------------------------------- /Clipchop/Views/Beginning/Pages/BeginningShortcutsPage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BeginningShortcutsPage.swift 3 | // Clipchop 4 | // 5 | // Created by KrLite on 2024/5/22. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct BeginningShortcutsPage: View { 11 | @Environment(\.namespace) private var namespace 12 | @Environment(\.isVisible) private var isVisible 13 | 14 | var body: some View { 15 | VStack { 16 | VStack { 17 | if isVisible { 18 | Image(systemSymbol: .keyboard) 19 | .imageScale(.large) 20 | .padding() 21 | .matchedGeometryEffect(id: "flip", in: namespace!) 22 | .transition(.rotate3D(angle: .degrees(65)).combined(with: .scale).combined(with: .opacity)) 23 | } 24 | 25 | Text("Shortcuts") 26 | } 27 | .font(.title) 28 | .bold() 29 | .frame(height: 200) 30 | 31 | Form { 32 | KeyboardShortcutsSection() 33 | .controlSize(.large) 34 | } 35 | .formStyle(.grouped) 36 | .scrollDisabled(true) 37 | } 38 | .padding() 39 | .frame(width: BeginningViewController.size.width) 40 | .frame(maxHeight: .infinity) 41 | } 42 | } 43 | 44 | #Preview { 45 | BeginningShortcutsPage() 46 | } 47 | -------------------------------------------------------------------------------- /Clipchop/Views/Beginning/Pages/BeginningTutorialPage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BeginningTutorialPage.swift 3 | // Clipchop 4 | // 5 | // Created by KrLite on 2024/5/22. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct BeginningTutorialPage: View { 11 | @Environment(\.namespace) private var namespace 12 | @Environment(\.isVisible) private var isVisible 13 | 14 | var body: some View { 15 | Group { 16 | VStack { 17 | if isVisible { 18 | Image(systemSymbol: .lightbulb) 19 | .imageScale(.large) 20 | .padding() 21 | .matchedGeometryEffect(id: "flip", in: namespace!) 22 | .transition(.rotate3D(angle: .degrees(65)).combined(with: .scale).combined(with: .opacity)) 23 | } 24 | 25 | Text("Tutorial") 26 | } 27 | .font(.title) 28 | .bold() 29 | .frame(maxHeight: .infinity) 30 | } 31 | .frame(width: BeginningViewController.size.width) 32 | .frame(maxHeight: .infinity) 33 | } 34 | } 35 | 36 | #Preview { 37 | BeginningTutorialPage() 38 | } 39 | -------------------------------------------------------------------------------- /Clipchop/Views/Beginning/RoamingButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RoamingButton.swift 3 | // Clipchop 4 | // 5 | // Created by KrLite on 2024/5/25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct RoamingButton: View where Label: View, Background: View { 11 | var canHover = true 12 | var action: () -> Void 13 | @ViewBuilder var label: () -> Label 14 | @ViewBuilder var background: () -> Background 15 | 16 | @State private var isHovering = false 17 | 18 | init( 19 | canHover: Bool = true, 20 | action: @escaping () -> Void, 21 | @ViewBuilder label: @escaping () -> Label, 22 | @ViewBuilder background: @escaping () -> Background 23 | ) { 24 | self.canHover = canHover 25 | self.action = action 26 | self.label = label 27 | self.background = background 28 | } 29 | 30 | init( 31 | canHover: Bool = true, 32 | action: @escaping () -> Void, 33 | label: @escaping () -> Label 34 | ) where Background == Color { 35 | self.init(canHover: canHover, action: action, label: label) { 36 | Color.clear 37 | } 38 | } 39 | 40 | var body: some View { 41 | Button { 42 | action() 43 | } label: { 44 | label() 45 | .padding() 46 | } 47 | .background { 48 | background() 49 | } 50 | .padding() 51 | .scaleEffect(isHovering && canHover ? 1.05 : 1) 52 | 53 | .controlSize(.extraLarge) 54 | .buttonStyle(.borderless) 55 | .buttonBorderShape(.roundedRectangle(radius: 15)) 56 | 57 | .onHover { isHovering in 58 | withAnimation { 59 | self.isHovering = isHovering 60 | } 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Clipchop/Views/Clip History/ClipHistoryPanel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ClipHistoryPanel.swift 3 | // ClipChop 4 | // 5 | // Created by Xinshao_Air on 2024/5/31. 6 | // 7 | 8 | import SwiftUI 9 | import AppKit 10 | import Defaults 11 | import KeyboardShortcuts 12 | 13 | class ClipHistoryPanel: NSPanel { 14 | 15 | private let controller: ClipHistoryPanelController 16 | 17 | init(_ controller: ClipHistoryPanelController) { 18 | self.controller = controller 19 | super.init( 20 | contentRect: .zero, 21 | styleMask: [.nonactivatingPanel, .borderless, .closable], 22 | backing: .buffered, 23 | defer: true 24 | ) 25 | 26 | animationBehavior = .documentWindow 27 | collectionBehavior = .canJoinAllSpaces 28 | isFloatingPanel = true 29 | isMovable = true 30 | level = .popUpMenu 31 | 32 | backgroundColor = NSColor.clear 33 | hasShadow = true 34 | 35 | let clipHistoryView = ClipHistoryView(controller: controller) 36 | .environment(\.managedObjectContext, ClipboardDataProvider.shared.viewContext) 37 | 38 | let hostingView = NSHostingView(rootView: clipHistoryView) 39 | 40 | let backgroundView = DraggableBackgroundView(frame: self.frame) 41 | backgroundView.autoresizingMask = [.width, .height] 42 | 43 | self.contentView?.addSubview(backgroundView) 44 | self.contentView?.addSubview(hostingView) 45 | 46 | hostingView.translatesAutoresizingMaskIntoConstraints = false 47 | NSLayoutConstraint.activate([ 48 | hostingView.leadingAnchor.constraint(equalTo: self.contentView!.leadingAnchor), 49 | hostingView.trailingAnchor.constraint(equalTo: self.contentView!.trailingAnchor), 50 | hostingView.topAnchor.constraint(equalTo: self.contentView!.topAnchor), 51 | hostingView.bottomAnchor.constraint(equalTo: self.contentView!.bottomAnchor) 52 | ]) 53 | } 54 | 55 | override func resignMain() { 56 | super.resignMain() 57 | close() 58 | } 59 | 60 | override func close() { 61 | controller.close() 62 | } 63 | 64 | override var canBecomeKey: Bool { 65 | return true 66 | } 67 | 68 | override var canBecomeMain: Bool { 69 | return true 70 | } 71 | 72 | override func mouseDown(with event: NSEvent) { 73 | if Defaults[.autoClose] { 74 | controller.resetCloseTimer() 75 | } 76 | super.mouseDown(with: event) 77 | } 78 | 79 | override func mouseMoved(with event: NSEvent) { 80 | if Defaults[.autoClose] { 81 | controller.resetCloseTimer() 82 | } 83 | super.mouseMoved(with: event) 84 | } 85 | 86 | override func scrollWheel(with event: NSEvent) { 87 | if Defaults[.autoClose] { 88 | controller.resetCloseTimer() 89 | } 90 | super.scrollWheel(with: event) 91 | } 92 | 93 | // MARK: - Shortcuts 94 | 95 | override func keyDown(with event: NSEvent) { 96 | switch KeyboardShortcuts.Shortcut(event: event) { 97 | case KeyboardShortcuts.Name.settings.shortcut: 98 | LuminareManager.open() 99 | case KeyboardShortcuts.Key.escape.shortcut: 100 | close() 101 | case KeyboardShortcuts.Name.expand.shortcut: 102 | controller.expand() 103 | case KeyboardShortcuts.Name.collapse.shortcut: 104 | controller.collapse() 105 | default: 106 | super.keyDown(with: event) 107 | } 108 | } 109 | } 110 | 111 | class DraggableBackgroundView: NSView { 112 | override func mouseDown(with event: NSEvent) { 113 | self.window?.performDrag(with: event) 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /Clipchop/Views/Clip History/ClipHistoryView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ClipHistoryView.swift 3 | // Clipchop 4 | // 5 | // Created by Xinshao_Air on 2024/7/5. 6 | // 7 | 8 | import SwiftUI 9 | import CoreData 10 | import Combine 11 | import Defaults 12 | import SFSafeSymbols 13 | 14 | struct ClipHistoryView: View { 15 | 16 | @FetchRequest(fetchRequest: ClipboardHistory.all(), animation: .snappy(duration: 0.75)) private var items 17 | 18 | @Environment(\.managedObjectContext) private var context 19 | 20 | @StateObject private var apps = InstalledApps() 21 | 22 | @Namespace private var animationNamespace 23 | 24 | // CollapsedPages 25 | @State private var scrollPadding: CGFloat = Defaults[.displayMore] ? 16 : 12 26 | @State private var initialScrollPadding: CGFloat = Defaults[.displayMore] ? 16 : 12 27 | @State private var movethebutton = false 28 | 29 | // ExpandedPages 30 | @State var searchText: String = "" 31 | @State var isSearchVisible: Bool = false 32 | @State var selectedTab: String = "All Types" 33 | @State var searchResults: [ClipHistorySearch.SearchResult] = [] 34 | 35 | private let search = ClipHistorySearch() 36 | private let clipboardModelEditor = ClipboardModelEditor(provider: .shared) 37 | 38 | let controller: ClipHistoryPanelController 39 | 40 | var body: some View { 41 | clip { 42 | ZStack(alignment: .top) { 43 | Button(action: undo) { } 44 | .opacity(0) 45 | .allowsHitTesting(false) 46 | .buttonStyle(.borderless) 47 | .frame(width: 0, height: 0) 48 | .keyboardShortcut("z", modifiers: .command) 49 | 50 | Button(action: redo) { } 51 | .opacity(0) 52 | .allowsHitTesting(false) 53 | .buttonStyle(.borderless) 54 | .frame(width: 0, height: 0) 55 | .keyboardShortcut("z", modifiers: [.command, .shift]) 56 | 57 | clip { 58 | VisualEffectView(material: .popover, blendingMode: .behindWindow) 59 | } 60 | VStack { 61 | if items.isEmpty { 62 | EmptyStatePages() 63 | } else { 64 | if controller.isExpandedforView { 65 | ExpandedPages( 66 | animationNamespace: animationNamespace, 67 | apps: apps, 68 | undo: undo, 69 | redo: redo, 70 | searchText: $searchText, 71 | selectedTab: $selectedTab, 72 | isSearchVisible: $isSearchVisible 73 | ) 74 | } else { 75 | CollapsedPages( 76 | animationNamespace: animationNamespace, 77 | scrollPadding: $scrollPadding, 78 | initialScrollPadding: $initialScrollPadding, 79 | movethebutton: $movethebutton, 80 | clipboardModelEditor: clipboardModelEditor, 81 | apps: apps, 82 | undo: undo, 83 | redo: redo 84 | ) 85 | } 86 | } 87 | } 88 | } 89 | } 90 | .frame(maxWidth: .infinity, maxHeight: .infinity) 91 | .onChange(of: searchText) { oldValue, newValue in 92 | if Defaults[.autoClose] { 93 | controller.resetCloseTimer() 94 | } 95 | } 96 | .onChange(of: controller.isExpandedforView) { isExpanded, _ in 97 | handleExpansionStateChange(isExpanded: isExpanded) 98 | } 99 | } 100 | 101 | private func handleExpansionStateChange(isExpanded: Bool) { 102 | withAnimation(.default) { 103 | isSearchVisible = false 104 | selectedTab = NSLocalizedString("All Types", comment: "All Types") 105 | } 106 | searchText = "" 107 | } 108 | 109 | // MARK: - ModelManager 110 | 111 | private func undo() { 112 | context.undoManager?.undo() 113 | } 114 | 115 | private func redo() { 116 | context.undoManager?.redo() 117 | } 118 | 119 | private func performSearch() { 120 | if searchText.isEmpty { 121 | searchResults = [] 122 | } else { 123 | searchResults = search.search(string: searchText, within: Array(items)) 124 | } 125 | } 126 | } 127 | 128 | @ViewBuilder 129 | func clip(@ViewBuilder content: @escaping () -> some View) -> some View { 130 | content() 131 | .clipShape(RoundedRectangle(cornerRadius: 25, style: .continuous)) 132 | } 133 | -------------------------------------------------------------------------------- /Clipchop/Views/Clip History/CollapsedPages.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CollapsedPages.swift 3 | // Clipchop 4 | // 5 | // Created by Xinshao_Air on 2024/7/7. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct CollapsedPages: View { 11 | var body: some View { 12 | Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Clipchop/Views/Clip History/EmptyStateView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EmptyStateView.swift 3 | // Clipchop 4 | // 5 | // Created by 屈志健 on 2024/7/8. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct EmptyStateView: View { 11 | var body: some View { 12 | VStack(alignment: .center) { 13 | Image(.clipchopFill) 14 | .resizable() 15 | .aspectRatio(contentMode: .fit) 16 | .frame(height: 24) 17 | Text("No Clipboard History Available") 18 | } 19 | .foregroundStyle(.blendMode(.overlay)) 20 | .frame(width: 476, height: 130) 21 | .padding(.all, 12) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Clipchop/Views/Clip History/Pages/EmptyStatePages.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EmptyStateView.swift 3 | // Clipchop 4 | // 5 | // Created by Xinshao_Air on 2024/7/8. 6 | // 7 | 8 | import SwiftUI 9 | import Defaults 10 | 11 | struct EmptyStatePages: View { 12 | @Default(.preferredColorScheme) private var preferredColorScheme 13 | 14 | var body: some View { 15 | VStack(alignment: .center) { 16 | Image(.clipchopFill) 17 | .resizable() 18 | .aspectRatio(contentMode: .fit) 19 | .frame(height: 24) 20 | Text("No Clipboard History Available") 21 | } 22 | .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) 23 | .foregroundStyle(.blendMode(.overlay)) 24 | .preferredColorScheme(preferredColorScheme.colorScheme) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Clipchop/Views/Clip History/Pages/TabButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TabButton.swift 3 | // Clipchop 4 | // 5 | // Created by Xinshao_Air on 2024/7/8. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct TabButton: View { 11 | let title: String 12 | @Binding var selectedTab: String 13 | 14 | var body: some View { 15 | Button(action: { 16 | withAnimation(.default) { 17 | selectedTab = title 18 | } 19 | }) { 20 | Text(title) 21 | .padding() 22 | .background(selectedTab == title ? Color.getAccent() : Color.clear) 23 | .foregroundColor(selectedTab == title ? Color.white : Color.primary) 24 | .cornerRadius(8) 25 | } 26 | .frame(maxWidth: 250, maxHeight: 25) 27 | .buttonStyle(.borderless) 28 | .cornerRadius(25) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Clipchop/Views/Clip History/Preview Cards/Page/ColorPreviewPage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ColorPreviewPage.swift 3 | // Clipchop 4 | // 5 | // Created by Xinshao_Air on 2024/5/21. 6 | // 7 | 8 | import SwiftUI 9 | import Defaults 10 | import SwiftHEXColors 11 | 12 | class ColorPreviewPage { 13 | static func from(_ colorHex: String) -> NSImage? { 14 | guard let color = NSColor(hexString: colorHex) else { return nil } 15 | 16 | let image = NSImage(size: NSSize(width: Defaults[.displayMore] ? 80 : 112, height: Defaults[.displayMore] ? 80 : 112)) 17 | image.lockFocus() 18 | color.drawSwatch(in: NSRect(x: 0, y: 0, width: Defaults[.displayMore] ? 80 : 112, height: Defaults[.displayMore] ? 80 : 112)) 19 | image.unlockFocus() 20 | 21 | return image 22 | } 23 | } 24 | 25 | -------------------------------------------------------------------------------- /Clipchop/Views/Clip History/Preview Cards/Page/HTMLPreviewPags.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HtmlPreviewPags.swift 3 | // Clipchop 4 | // 5 | // Created by Xinshao_Air on 2024/7/8. 6 | // 7 | 8 | import SwiftUI 9 | import Defaults 10 | import AppKit 11 | import WebKit 12 | 13 | struct WebView: NSViewRepresentable { 14 | let htmlContent: String 15 | 16 | func makeNSView(context: Context) -> WKWebView { 17 | return WKWebView() 18 | } 19 | 20 | func updateNSView(_ nsView: WKWebView, context: Context) { 21 | 22 | let htmlString = """ 23 | 24 | 25 | 26 | 27 | 60 | 61 | 62 |
63 | \(htmlContent) 64 |
65 | 66 | 67 | """ 68 | 69 | nsView.loadHTMLString(htmlString, baseURL: nil) 70 | } 71 | } 72 | 73 | struct HTMLPreviewPage: View { 74 | let htmlContent: String 75 | let backgroundColor: Color 76 | 77 | init(htmlData: Data?, colorScheme: ColorScheme) { 78 | if let htmlData = htmlData, 79 | let htmlString = String(data: htmlData, encoding: .utf8) { 80 | self.htmlContent = htmlString 81 | self.backgroundColor = HTMLPreviewPage.extractBackgroundColor(from: htmlString, colorScheme: colorScheme) 82 | } else { 83 | self.htmlContent = "" 84 | self.backgroundColor = .white 85 | } 86 | } 87 | 88 | static func extractBackgroundColor(from htmlString: String, colorScheme: ColorScheme) -> Color { 89 | // Basic background color handling based on color scheme 90 | return colorScheme == .dark ? .black : .white 91 | } 92 | 93 | var body: some View { 94 | ZStack { 95 | WebView(htmlContent: htmlContent) 96 | .allowsHitTesting(false) 97 | .background(backgroundColor) 98 | } 99 | .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /Clipchop/Views/Clip History/Preview Cards/Page/RTFPreviewPage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RTFPreviewPage.swift 3 | // Clipchop 4 | // 5 | // Created by Xinshao_Air on 2024/5/21. 6 | // 7 | 8 | import SwiftUI 9 | import Defaults 10 | import AppKit 11 | 12 | struct RTFPreviewPage: View { 13 | 14 | let attributedText: AttributedString 15 | let backgroundColor: Color 16 | 17 | static func dynamicColor(for colorScheme: ColorScheme) -> NSColor { 18 | return colorScheme == .dark ? .black : .white 19 | } 20 | 21 | init(rtfData: Data?, colorScheme: ColorScheme) { 22 | if 23 | let rtfData = rtfData, 24 | let contents = NSAttributedString(rtf: rtfData, documentAttributes: nil) 25 | { 26 | attributedText = .init(contents) 27 | backgroundColor = RTFPreviewPage.extractBackgroundColor(from: contents, colorScheme: colorScheme) 28 | } else { 29 | attributedText = .init() 30 | backgroundColor = .white 31 | } 32 | } 33 | 34 | static func extractBackgroundColor(from attributedString: NSAttributedString, colorScheme: ColorScheme) -> Color { 35 | var backgroundColor: NSColor = dynamicColor(for: colorScheme) 36 | attributedString.enumerateAttribute(.backgroundColor, in: NSRange(location: 0, length: attributedString.length)) { value, _, _ in 37 | if let color = value as? NSColor { 38 | backgroundColor = color 39 | return 40 | } 41 | } 42 | return Color(backgroundColor) 43 | } 44 | 45 | var body: some View { 46 | VStack(alignment: .center){ 47 | Text(attributedText) 48 | .minimumScaleFactor(0.8) 49 | .lineLimit(10) 50 | .fixedSize(horizontal: false, vertical: false) 51 | .background(Color.clear) 52 | .padding(.all, 4) 53 | } 54 | .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) 55 | .background(backgroundColor) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Clipchop/Views/Fundamentals/FormNavigationLinkLabel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FormNavigationLinkLabel.swift 3 | // Clipchop 4 | // 5 | // Created by KrLite on 2024/6/1. 6 | // 7 | /* 8 | import SwiftUI 9 | 10 | struct FormNavigationLinkLabel: View where Content: View { 11 | var hasSpacer: Bool = true 12 | var alignment: VerticalAlignment = .center 13 | var spacing: CGFloat? 14 | var imagePadding: CGFloat = 6 15 | @ViewBuilder var content: () -> Content 16 | 17 | init( 18 | hasSpacer: Bool = true, 19 | alignment: VerticalAlignment = .center, 20 | spacing: CGFloat? = nil, 21 | imagePadding: CGFloat = 6, 22 | @ViewBuilder content: @escaping () -> Content 23 | ) { 24 | self.hasSpacer = hasSpacer 25 | self.alignment = alignment 26 | self.spacing = spacing 27 | self.content = content 28 | } 29 | 30 | init( 31 | _ titleKey: LocalizedStringKey, 32 | hasSpacer: Bool = true, 33 | alignment: VerticalAlignment = .center, 34 | spacing: CGFloat? = nil, 35 | imagePadding: CGFloat = 6 36 | ) where Content == Text { 37 | self.init(hasSpacer: hasSpacer, alignment: alignment, spacing: spacing, imagePadding: imagePadding) { 38 | Text(titleKey) 39 | } 40 | } 41 | 42 | var body: some View { 43 | HStack(alignment: alignment, spacing: spacing) { 44 | content() 45 | 46 | if hasSpacer { 47 | Spacer() 48 | } 49 | 50 | Image(systemSymbol: .chevronForward) 51 | .foregroundStyle(.placeholder) 52 | .imageScale(.small) 53 | .fontWeight(.semibold) 54 | .padding(.vertical, imagePadding) 55 | } 56 | } 57 | } 58 | */ 59 | -------------------------------------------------------------------------------- /Clipchop/Views/Fundamentals/ListEmbeddedForm.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ListEmbeddedForm.swift 3 | // Clipchop 4 | // 5 | // Created by KrLite on 2024/5/28. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ListEmbeddedForm: View where Style: FormStyle, Content: View { 11 | var formStyle: Style 12 | @ViewBuilder var content: () -> Content 13 | 14 | init( 15 | formStyle: Style, 16 | @ViewBuilder content: @escaping () -> Content 17 | ) { 18 | self.formStyle = formStyle 19 | self.content = content 20 | } 21 | 22 | init( 23 | @ViewBuilder content: @escaping () -> Content 24 | ) where Style == GroupedFormStyle { 25 | self.init(formStyle: GroupedFormStyle(), content: content) 26 | } 27 | 28 | var body: some View { 29 | List { 30 | Form { 31 | content() 32 | } 33 | .formStyle(formStyle) 34 | 35 | .scrollDisabled(true) 36 | .scrollContentBackground(.hidden) 37 | .ignoresSafeArea() 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Clipchop/Views/Fundamentals/SearchFieldWrapper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchFieldWrapper.swift 3 | // Clipchop 4 | // 5 | // Created by Xinshao_Air on 2024/7/21. 6 | // 7 | 8 | import SwiftUI 9 | import AppKit 10 | 11 | struct SearchFieldWrapper: View { 12 | @Binding var searchText: String 13 | var placeholder: String 14 | var onSearch: (String) -> Void 15 | 16 | private let searchThrottler = Throttler(minimumDelay: 0.7) 17 | 18 | var body: some View { 19 | TextField(placeholder, text: Binding( 20 | get: { 21 | searchText 22 | }, 23 | set: { newValue in 24 | searchThrottler.throttle { 25 | searchText = newValue 26 | onSearch(newValue) 27 | } 28 | } 29 | )) 30 | .textFieldStyle(PlainTextFieldStyle()) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Clipchop/Views/Fundamentals/StaleView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StaleView.swift 3 | // Clipchop 4 | // 5 | // Created by KrLite on 2024/5/15. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct StaleView: View { 11 | var body: some View { 12 | Image(.clipchopFill) 13 | .resizable() 14 | .aspectRatio(contentMode: .fit) 15 | .foregroundStyle(.placeholder) 16 | 17 | .frame(height: 64) 18 | .frame(maxWidth: .infinity, maxHeight: .infinity) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Clipchop/Views/Fundamentals/VisualEffectView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VisualEffectView.swift 3 | // Clipchop 4 | // 5 | // Created by KrLite on 2024/5/17. 6 | // 7 | 8 | import SwiftUI 9 | 10 | // SwiftUI view for NSVisualEffect 11 | struct VisualEffectView: NSViewRepresentable { 12 | let material: NSVisualEffectView.Material 13 | let blendingMode: NSVisualEffectView.BlendingMode 14 | 15 | func makeNSView(context: Context) -> NSVisualEffectView { 16 | let visualEffectView = NSVisualEffectView() 17 | 18 | visualEffectView.material = material 19 | visualEffectView.blendingMode = blendingMode 20 | visualEffectView.state = .active 21 | visualEffectView.isEmphasized = true 22 | 23 | return visualEffectView 24 | } 25 | 26 | func updateNSView(_ visualEffectView: NSVisualEffectView, context: Context) { 27 | visualEffectView.material = material 28 | visualEffectView.blendingMode = blendingMode 29 | } 30 | } 31 | 32 | -------------------------------------------------------------------------------- /Clipchop/Views/Luminare Settings/Custom Luminare Component/CustomLuminarePicker.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CustomLuminarePicker.swift 3 | // Clipchop 4 | // 5 | // Created by Xinshao_Air on 2024/7/28. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct CustomLuminarePicker: View { 11 | @Binding var selection: Item 12 | let items: [Item] 13 | let displayText: (Item) -> String 14 | 15 | init(selection: Binding, items: [Item], displayText: @escaping (Item) -> String = { String(describing: $0) }) { 16 | self._selection = selection 17 | self.items = items 18 | self.displayText = displayText 19 | } 20 | 21 | var body: some View { 22 | Menu { 23 | ForEach(items, id: \.self) { item in 24 | Button(action: { 25 | selection = item 26 | }) { 27 | Text(displayText(item)) 28 | } 29 | .buttonStyle(.borderless) 30 | } 31 | } label: { 32 | Text(displayText(selection)) 33 | .foregroundColor(.primary) 34 | } 35 | .menuStyle(.borderlessButton) 36 | .frame(maxWidth: 150) 37 | .clipShape(Capsule()) 38 | .monospaced() 39 | .fixedSize() 40 | .padding(4) 41 | .padding(.horizontal, 4) 42 | .background { 43 | ZStack { 44 | Capsule() 45 | .strokeBorder(.quaternary, lineWidth: 1) 46 | 47 | Capsule() 48 | .foregroundStyle(.quinary.opacity(0.5)) 49 | } 50 | } 51 | } 52 | } 53 | 54 | 55 | -------------------------------------------------------------------------------- /Clipchop/Views/Luminare Settings/Luminare Settings Pages/LuminareAboutSettings.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LuminareAboutSettings.swift 3 | // Clipchop 4 | // 5 | // Created by Xinshao_Air on 2024/7/28. 6 | // 7 | 8 | import SwiftUI 9 | import Defaults 10 | import Luminare 11 | import SFSafeSymbols 12 | 13 | struct LuminareAboutSettings: View { 14 | @State private var isHoverAppVersion = false 15 | 16 | var body: some View { 17 | LuminareSection { 18 | Button { 19 | let pasteboard = NSPasteboard.general 20 | pasteboard.clearContents() 21 | pasteboard.setString(rawVersion, forType: .string) 22 | } label: { 23 | HStack { 24 | Image(nsImage: AppIcon.currentAppIcon.image) 25 | .resizable() 26 | .aspectRatio(contentMode: .fit) 27 | .frame(height: 60) 28 | 29 | VStack(alignment: .leading, spacing: 2) { 30 | Text(Bundle.main.appName) 31 | .fontWeight(.medium) 32 | 33 | VStack(alignment: .leading, spacing: 2) { 34 | CopyrightsView() 35 | HStack { 36 | Image(systemSymbol: isHoverAppVersion ? .infoCircle : .paperclipCircle) 37 | Text(isHoverAppVersion ? 38 | semanticVersion : Defaults[.timesClipped] >= 1_000_000 ? 39 | .init(localized: "note", defaultValue: "You've clipped… uhh… I… lost count…") : 40 | .init(localized: "note2", defaultValue: "You've already clipped \(Defaults[.timesClipped]) times!") 41 | ) 42 | .monospaced() 43 | .foregroundStyle(.secondary) 44 | .contentTransition(.numericText(countsDown: !isHoverAppVersion)) 45 | .animation(LuminareSettingsWindow.animation, value: isHoverAppVersion) 46 | .animation(LuminareSettingsWindow.animation, value: Defaults[.timesClipped]) 47 | } 48 | } 49 | .font(.caption) 50 | .foregroundStyle(.secondary) 51 | } 52 | Spacer() 53 | } 54 | .padding(4) 55 | } 56 | .buttonStyle(LuminareCosmeticButtonStyle(Image(systemSymbol: .clipboard))) 57 | .onHover { isOnHover in 58 | isHoverAppVersion = isOnHover 59 | } 60 | } 61 | 62 | LuminareSection { 63 | Text( 64 | "Clipchop is currently still a very early version, and many things are not yet mature. Therefore, we really need you to share your feedback on GitHub, including feature suggestions, interaction recommendations, and bug reports." 65 | ) 66 | .foregroundStyle(.secondary) 67 | .font(.callout) 68 | .padding(8) 69 | 70 | HStack(spacing: 2) { 71 | Button("FeedBack") { 72 | if let url = URL(string: "https://github.com/Cement-Labs/Clipchop/issues") { 73 | NSWorkspace.shared.open(url) 74 | } 75 | } 76 | Button("Discuss") { 77 | if let url = URL(string: "https://qm.qq.com/q/CacoMcCblI") { 78 | NSWorkspace.shared.open(url) 79 | } 80 | } 81 | Button("Source Code") { 82 | if let url = URL(string: "https://github.com/Cement-Labs/Clipchop") { 83 | NSWorkspace.shared.open(url) 84 | } 85 | } 86 | } 87 | } 88 | LuminareSection("Acknowledgements") { 89 | LuminareAcknowledgementsView() 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /Clipchop/Views/Luminare Settings/Luminare Settings Pages/LuminareCategorizationSettings.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LuminareCategorizationSettings.swift 3 | // Clipchop 4 | // 5 | // Created by Xinshao_Air on 2024/7/27. 6 | // 7 | 8 | import SwiftUI 9 | import Luminare 10 | import Defaults 11 | import UniformTypeIdentifiers 12 | 13 | struct LuminareCategorizationSettings: View { 14 | 15 | @Default(.categories) var categories 16 | @Default(.allTypes) var allTypes 17 | 18 | @State private var showCategorizationSheet = false 19 | @State private var input: String = "" 20 | @State private var isRenaming: FileCategory? 21 | @State private var newName = "" 22 | @State private var eventMonitor: Any? 23 | @State private var showingAlert = false 24 | 25 | var filteredCategories: [FileCategory] { 26 | return Defaults[.categories].sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } 27 | } 28 | 29 | var body: some View { 30 | LuminareSection("You have collected \(allTypes.count) types of files") { 31 | VStack { 32 | Button("Edit") { 33 | showCategorizationSheet.toggle() 34 | } 35 | .sheet(isPresented: $showCategorizationSheet) { 36 | CategorizationSection() 37 | .frame(width: 600, height: 500) 38 | } 39 | } 40 | ForEach(filteredCategories, id: \.name) { category in 41 | HStack { 42 | if isRenaming?.id == category.id { 43 | TextField("",text: $newName, onCommit: { 44 | renameCategory(category, newName: newName) 45 | isRenaming = nil 46 | }) 47 | .textFieldStyle(.plain) 48 | } else { 49 | Text(category.name) 50 | } 51 | Spacer() 52 | HStack { 53 | Button(action: { 54 | isRenaming = category 55 | newName = category.name 56 | }) { 57 | Image(systemName: "pencil") 58 | .foregroundColor(.black) 59 | } 60 | .padding(.trailing, 10) 61 | .buttonStyle(.borderless) 62 | Button(action: { 63 | removeCategory(category) 64 | }) { 65 | Image(systemName: "trash") 66 | .foregroundColor(.black) 67 | } 68 | .buttonStyle(.borderless) 69 | } 70 | .clipShape(.capsule) 71 | .monospaced() 72 | .fixedSize() 73 | .padding(4) 74 | .padding(.horizontal, 4) 75 | .background { 76 | ZStack { 77 | Capsule() 78 | .strokeBorder(.quaternary, lineWidth: 1) 79 | 80 | Capsule() 81 | .foregroundStyle(.quinary.opacity(0.5)) 82 | } 83 | } 84 | } 85 | .padding(.horizontal, 8) 86 | .padding(.trailing, 2) 87 | .frame(minHeight: 34,alignment: .leading) 88 | } 89 | } 90 | } 91 | 92 | private func removeCategory(_ category: FileCategory) { 93 | categories.removeAll { $0 == category } 94 | } 95 | 96 | private func renameCategory(_ category: FileCategory, newName: String) { 97 | if let index = categories.firstIndex(of: category) { 98 | categories[index].name = newName 99 | Defaults[.categories] = categories 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /Clipchop/Views/Luminare Settings/Luminare Settings Pages/LuminareExcludedAppsSettings.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LuminareExcludedAppsSettings.swift 3 | // Clipchop 4 | // 5 | // Created by Xinshao_Air on 2024/7/28. 6 | // 7 | 8 | import SwiftUI 9 | import Luminare 10 | import Defaults 11 | import SFSafeSymbols 12 | 13 | struct LuminareExcludedAppsSettings: View { 14 | @EnvironmentObject private var apps: InstalledApps 15 | @Default(.excludeAppsEnabled) private var excludeAppsEnabled 16 | @Default(.excludedApplications) private var excluded 17 | @State private var selection: Set = .init() 18 | 19 | var body: some View { 20 | LuminareSection { 21 | HStack { 22 | withCaption { 23 | Text("App Excluding") 24 | } caption: { 25 | Text(""" 26 | Limit \(Bundle.main.appName)'s functions in the specified apps. 27 | """) 28 | } 29 | Spacer() 30 | 31 | Toggle("", isOn: $excludeAppsEnabled) 32 | .labelsHidden() 33 | .controlSize(.small) 34 | .toggleStyle(.switch) 35 | } 36 | .padding(.horizontal, 8) 37 | .padding(.trailing, 2) 38 | .frame(minHeight: 42) 39 | } 40 | 41 | CustomLuminareList( 42 | items: $excluded, 43 | selection: $selection, 44 | addActionView: { 45 | InstalledAppsMenu() 46 | .environmentObject(apps) 47 | }, 48 | removeAction: { 49 | excluded.removeAll(where: { selection.contains($0) }) 50 | }, 51 | content: { entry in 52 | HStack { 53 | Group { 54 | if let app = (apps.installedApps + apps.systemApps).first(where: { 55 | $0.bundleID == entry 56 | }) { 57 | HStack { 58 | Image(nsImage: app.icon) 59 | .resizable() 60 | .aspectRatio(1, contentMode: .fit) 61 | .frame(width: 20) 62 | 63 | Text(app.displayName) 64 | .padding(.leading, 2) 65 | } 66 | .padding(.leading, 8) 67 | 68 | } else { 69 | Text(entry) 70 | .padding(.leading, 22) 71 | .monospaced() 72 | } 73 | } 74 | .padding(.vertical, 5) 75 | .tag(entry) 76 | 77 | Spacer() 78 | } 79 | .padding(.horizontal, 8) 80 | .padding(.trailing, 2) 81 | .frame(minHeight: 34) 82 | }, 83 | emptyView: { 84 | HStack { 85 | Spacer() 86 | VStack { 87 | Text("No Application Excluded") 88 | .font(.title3) 89 | 90 | description { 91 | Text("Exclude apps to prevent \(Bundle.main.appName) copying their contents.") 92 | } 93 | } 94 | Spacer() 95 | } 96 | .foregroundStyle(.secondary) 97 | .padding() 98 | }, 99 | id: \.self, 100 | addText: "Add", 101 | removeText: "Remove" 102 | ) 103 | } 104 | private func removeSelected() { 105 | excluded.removeAll { selection.contains($0) } 106 | selection.removeAll() 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /Clipchop/Views/Luminare Settings/LuminareAcknowledgementsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LuminareAcknowledgementsView.swift 3 | // Clipchop 4 | // 5 | // Created by Xinshao_Air on 2024/7/28. 6 | // 7 | 8 | import SwiftUI 9 | import Luminare 10 | 11 | struct LuminareAcknowledgementsView: View { 12 | @Environment(\.openURL) private var openURL 13 | 14 | @State private var presentedReasonPackage: String? 15 | 16 | struct Package { 17 | var name: String 18 | var author: Author 19 | var link: URL 20 | var reason: String? 21 | 22 | struct Author { 23 | var name: String 24 | var slug: String? 25 | var link: URL? 26 | } 27 | } 28 | 29 | // TODO: Complete this 30 | static let packages: [Package] = [ 31 | .init( 32 | name: "Defaults", 33 | author: .init(name: "Sindre Sorhus", slug: "sindresorhus", link: URL(string: "https://sindresorhus.com/apps")), 34 | link: URL(string: "https://github.com/sindresorhus/Defaults")!, 35 | reason: "This is a reason." 36 | ), 37 | .init( 38 | name: "FullDiskAccess", 39 | author: .init(name: "Mahdi Bchatnia", slug: "inket", link: URL(string: "https://github.com/inket")), 40 | link: URL(string: "https://github.com/inket/FullDiskAccess")! 41 | ), 42 | .init( 43 | name: "Luminare", 44 | author: .init(name: "MrKai77 ", slug: "Kai", link: URL(string: "https://github.com/MrKai77")), 45 | link: URL(string: "https://github.com/MrKai77/Luminare")! 46 | ) 47 | ] 48 | 49 | @ViewBuilder 50 | func name(author: Package.Author) -> some View { 51 | Text(author.name) 52 | 53 | if let slug = author.slug { 54 | Text(slug) 55 | .foregroundStyle(.placeholder) 56 | .monospaced() 57 | } 58 | } 59 | 60 | var body: some View { 61 | ForEach(LuminareAcknowledgementsView.packages, id: \.name) { package in 62 | VStack { 63 | HStack { 64 | @State var isReasonPresented = false 65 | 66 | VStack { 67 | withCaption(spacing: 0) { 68 | Text(package.name) 69 | .font(.title3) 70 | } caption: { 71 | if let link = package.author.link { 72 | Button { 73 | openURL(link) 74 | } label: { 75 | name(author: package.author) 76 | Image(systemSymbol: .arrowUpRight) 77 | .foregroundStyle(.placeholder) 78 | } 79 | .buttonStyle(.plain) 80 | } else { 81 | name(author: package.author) 82 | } 83 | } 84 | } 85 | 86 | Spacer() 87 | 88 | Group { 89 | if let reason = package.reason { 90 | Button { 91 | presentedReasonPackage = package.name 92 | } label: { 93 | Image(systemSymbol: .infoCircleFill) 94 | } 95 | .aspectRatio(1, contentMode: .fit) 96 | .popover(isPresented: .init { 97 | presentedReasonPackage == package.name 98 | } set: { _ in 99 | presentedReasonPackage = nil 100 | }) { 101 | Text(reason) 102 | .padding() 103 | } 104 | } 105 | 106 | Button { 107 | openURL(package.link) 108 | } label: { 109 | Image(systemSymbol: .safariFill) 110 | } 111 | .aspectRatio(1, contentMode: .fit) 112 | } 113 | .imageScale(.large) 114 | .buttonStyle(.plain) 115 | .foregroundStyle(.secondary) 116 | } 117 | .padding(.horizontal, 8) 118 | .padding(.trailing, 2) 119 | .frame(minHeight: 42) 120 | } 121 | } 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /Clipchop/Views/Luminare Settings/LuminareManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LuminareManager.swift 3 | // Clipchop 4 | // 5 | // Created by Xinshao_Air on 2024/7/26. 6 | // 7 | 8 | import Luminare 9 | import SFSafeSymbols 10 | import SwiftUI 11 | 12 | class LuminareManager: ObservableObject { 13 | 14 | @Environment(\.colorScheme) var colorScheme 15 | @EnvironmentObject var apps: InstalledApps 16 | 17 | @Published var appExcluding: SettingsTab 18 | 19 | static let shared = LuminareManager() 20 | 21 | private init() { 22 | // Initialize appExcluding with a placeholder; will be updated later 23 | appExcluding = SettingsTab("App Excluding", Image(systemSymbol: .xmarkSeal), LuminareExcludedAppsSettings()) 24 | } 25 | 26 | static let generalSettingsPage = SettingsTab("General", Image(systemSymbol: .gearshape), LuminareGeneralSettings()) 27 | static let customizationSettingsPage = SettingsTab("Customization", Image(systemSymbol: .pencilAndOutline), LuminareCustomizationSettings()) 28 | static let clipboardSettingsPage = SettingsTab("Clipboard", Image(systemSymbol: .clipboard), LuminareClipboardSettings(clipboardController: ClipboardManager.clipboardController!)) 29 | static let keyboardShortcutsSettingsPage = SettingsTab("Keyboard Shortcuts", Image(systemSymbol: .keyboard),LuminareKeyboardShortcutsSettings()) 30 | static let categorizationSettingsPage = SettingsTab("Categorization", Image(systemSymbol: .tray2), LuminareCategorizationSettings()) 31 | static let folderSettingsPage = SettingsTab("folder", Image(systemSymbol: .folder), LuminareFolderSettings()) 32 | static let aboutSettingsPage = SettingsTab("About", Image(systemSymbol: .infoBubble), LuminareAboutSettings()) 33 | 34 | static var luminare: LuminareSettingsWindow? 35 | 36 | static func open() { 37 | if luminare == nil { 38 | shared.appExcluding = SettingsTab( 39 | "App Excluding", 40 | Image(systemSymbol: .xmarkSeal), 41 | LuminareExcludedAppsSettings().environmentObject(InstalledApps()) 42 | ) 43 | 44 | luminare = LuminareSettingsWindow( 45 | [ 46 | .init("App Settings", [ 47 | generalSettingsPage, 48 | customizationSettingsPage 49 | ]), 50 | .init("Clipboard Settings", [ 51 | clipboardSettingsPage, 52 | keyboardShortcutsSettingsPage, 53 | categorizationSettingsPage, 54 | folderSettingsPage, 55 | shared.appExcluding 56 | ]), 57 | .init("\(Bundle.main.appName)", [ 58 | aboutSettingsPage 59 | ]), 60 | ], 61 | tint: { 62 | Color.getAccent() 63 | }, 64 | didTabChange: { _ in }, 65 | showPreviewIcon: Image(nsImage: NSImage(size: .zero)), 66 | hidePreviewIcon: Image(nsImage: NSImage(size: .zero)) 67 | ) 68 | 69 | } 70 | luminare?.show() 71 | AppDelegate.isActive = true 72 | NSApp.setActivationPolicy(.regular) 73 | } 74 | 75 | static func fullyClose() { 76 | luminare?.close() 77 | luminare = nil 78 | NSApp.setActivationPolicy(.accessory) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /Clipchop/Views/Luminare Settings/LuminarePreferredColorSchemePicker.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LuminarePreferredColorSchemePicker.swift 3 | // Clipchop 4 | // 5 | // Created by Xinshao_Air on 2024/7/28. 6 | // 7 | 8 | import SwiftUI 9 | import Defaults 10 | 11 | struct LuminarePreferredColorSchemePicker: View { 12 | @Default(.preferredColorScheme) private var preferredColorScheme 13 | 14 | var body: some View { 15 | CustomLuminarePicker( 16 | selection: $preferredColorScheme, 17 | items: PreferredColorScheme.allCases 18 | ) { scheme in 19 | scheme.displayName 20 | } 21 | .frame(width: 80, height: 24, alignment: .trailing) 22 | .controlSize(.regular) 23 | .onChange(of: preferredColorScheme) { newValue, _ in 24 | applyColorScheme() 25 | } 26 | .onAppear { 27 | applyColorScheme() 28 | } 29 | } 30 | 31 | private func applyColorScheme() { 32 | switch preferredColorScheme { 33 | case .system: 34 | NSApplication.shared.appearance = nil 35 | case .light: 36 | NSApplication.shared.appearance = NSAppearance(named: .aqua) 37 | case .dark: 38 | NSApplication.shared.appearance = NSAppearance(named: .darkAqua) 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Clipchop/Views/Menu Bar/MenuBarIconView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MenuBarIconView.swift 3 | // Clipchop 4 | // 5 | // Created by KrLite on 2024/4/27. 6 | // 7 | 8 | import SwiftUI 9 | import SFSafeSymbols 10 | import Defaults 11 | 12 | struct MenuBarIconView: View { 13 | @Default(.timesClipped) private var timesClipped 14 | 15 | var body: some View { 16 | Image(.clipchopFill) 17 | .symbolRenderingMode(.hierarchical) 18 | .resizable() 19 | .aspectRatio(contentMode: .fit) 20 | .frame(height: 18) 21 | 22 | .symbolEffect(.bounce, value: timesClipped) 23 | .onReceive(.didClip) { _ in 24 | if !Defaults[.dnd] { 25 | Sound.clipSound.play() 26 | } 27 | timesClipped += 1 28 | AppIcon.checkIfUnlockedNewIcon() 29 | } 30 | .onReceive(.didPaste) { _ in 31 | if !Defaults[.dnd] { 32 | Sound.pasteSound.play() 33 | } 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Clipchop/Views/Menu Bar/MenuBarView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MenuBarView.swift 3 | // Clipchop 4 | // 5 | // Created by KrLite on 2024/4/27. 6 | // 7 | 8 | import SwiftUI 9 | import Defaults 10 | import Luminare 11 | 12 | struct MenuBarView: View { 13 | 14 | @FetchRequest(fetchRequest: ClipboardHistory.all()) private var items 15 | 16 | @ObservedObject var clipboardController: ClipboardController 17 | 18 | @Default(.dnd) private var dnd 19 | 20 | @Default(.copyShortcut) var copyShortcut 21 | 22 | var body: some View { 23 | Text("\(Defaults[.timesClipped]) Clips, \(items.count) Items") 24 | 25 | if !items.isEmpty { 26 | Menu("Recent Clips") { 27 | ForEach(Array(items.prefix(9).enumerated()), id: \.element) { index, item in 28 | Button { 29 | ClipboardManager.clipboardController?.copy(item) 30 | } label: { 31 | Group { 32 | if let title = item.formatter.title { 33 | let fileExtensions = title.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } 34 | let categorizedTitle = categorizeFileExtensions(fileExtensions) 35 | Text(categorizedTitle) 36 | } else { 37 | Text("Other") 38 | } 39 | } 40 | } 41 | .keyboardShortcut(.init(String(index + 1).first!), modifiers: copyShortcut.eventModifier) 42 | } 43 | } 44 | .keyboardShortcut("r", modifiers: .option) 45 | } 46 | 47 | Divider() 48 | 49 | Toggle("Clipboard Monitoring", isOn: $clipboardController.started) 50 | 51 | Toggle("Do Not Disturb", isOn: $dnd) 52 | 53 | Divider() 54 | 55 | Button("Settings…") { 56 | LuminareManager.open() 57 | } 58 | .keyboardShortcut(",", modifiers: .command) 59 | 60 | Button("Quit \(Bundle.main.appName)") { 61 | quit() 62 | } 63 | .keyboardShortcut("q", modifiers: .command) 64 | } 65 | 66 | func categorizeFileExtensions(_ fileExtensions: [String]) -> String { 67 | let categories = Defaults[.categories] 68 | for fileExtension in fileExtensions { 69 | if let category = categories.first(where: { $0.types.contains(fileExtension) }) { 70 | return category.name 71 | } 72 | } 73 | return fileExtensions.joined(separator: ", ") 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Clipchop/Views/Settings/AcknowledgementsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AcknowledgementsView.swift 3 | // Clipchop 4 | // 5 | // Created by KrLite on 2024/5/15. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct AcknowledgementsView: View { 11 | @Environment(\.openURL) private var openURL 12 | 13 | @State private var presentedReasonPackage: String? 14 | 15 | struct Package { 16 | var name: String 17 | var author: Author 18 | var link: URL 19 | var reason: String? 20 | 21 | struct Author { 22 | var name: String 23 | var slug: String? 24 | var link: URL? 25 | } 26 | } 27 | 28 | // TODO: Complete this 29 | static let packages: [Package] = [ 30 | .init( 31 | name: "Defaults", 32 | author: .init(name: "Sindre Sorhus", slug: "sindresorhus", link: URL(string: "https://sindresorhus.com/apps")), 33 | link: URL(string: "https://github.com/sindresorhus/Defaults")!, 34 | reason: "This is a reason." 35 | ), 36 | .init( 37 | name: "FullDiskAccess", 38 | author: .init(name: "Mahdi Bchatnia", slug: "inket", link: URL(string: "https://github.com/inket")), 39 | link: URL(string: "https://github.com/inket/FullDiskAccess")! 40 | ) 41 | ] 42 | 43 | @ViewBuilder 44 | func name(author: Package.Author) -> some View { 45 | Text(author.name) 46 | 47 | if let slug = author.slug { 48 | Text(slug) 49 | .foregroundStyle(.placeholder) 50 | .monospaced() 51 | } 52 | } 53 | 54 | var body: some View { 55 | VStack(spacing: 10) { 56 | ForEach(AcknowledgementsView.packages, id: \.name) { package in 57 | HStack { 58 | @State var isReasonPresented = false 59 | 60 | VStack { 61 | withCaption(spacing: 0) { 62 | Text(package.name) 63 | .font(.title3) 64 | } caption: { 65 | if let link = package.author.link { 66 | Button { 67 | openURL(link) 68 | } label: { 69 | name(author: package.author) 70 | Image(systemSymbol: .arrowUpRight) 71 | .foregroundStyle(.placeholder) 72 | } 73 | .buttonStyle(.plain) 74 | } else { 75 | name(author: package.author) 76 | } 77 | } 78 | } 79 | 80 | Spacer() 81 | 82 | Group { 83 | if let reason = package.reason { 84 | Button { 85 | presentedReasonPackage = package.name 86 | } label: { 87 | Image(systemSymbol: .infoCircleFill) 88 | } 89 | .aspectRatio(1, contentMode: .fit) 90 | .popover(isPresented: .init { 91 | presentedReasonPackage == package.name 92 | } set: { _ in 93 | presentedReasonPackage = nil 94 | }) { 95 | Text(reason) 96 | .padding() 97 | } 98 | } 99 | 100 | Button { 101 | openURL(package.link) 102 | } label: { 103 | Image(systemSymbol: .safariFill) 104 | } 105 | .aspectRatio(1, contentMode: .fit) 106 | } 107 | .imageScale(.large) 108 | .buttonStyle(.plain) 109 | .foregroundStyle(.secondary) 110 | } 111 | } 112 | } 113 | .padding() 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /Clipchop/Views/Settings/AppVersionView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppVersionView.swift 3 | // Clipchop 4 | // 5 | // Created by KrLite on 2024/5/13. 6 | // 7 | 8 | import SwiftUI 9 | import SFSafeSymbols 10 | 11 | var semanticVersion: String { 12 | .init( 13 | format: String(localized: "App Version: Semantic", defaultValue: "Version %@"), 14 | rawVersion 15 | ) 16 | } 17 | 18 | var rawVersion: String { 19 | .init( 20 | format: String(localized: "App Version: Raw", defaultValue: "%1$@ Build %2$@"), 21 | Bundle.main.appVersion, Bundle.main.appBuild 22 | ) 23 | } 24 | 25 | struct AppVersionView: View { 26 | var body: some View { 27 | Button { 28 | let pasteboard = NSPasteboard.general 29 | 30 | pasteboard.clearContents() 31 | pasteboard.setString(rawVersion, forType: .string) 32 | } label: { 33 | Image(systemSymbol: .infoCircle) 34 | Text(semanticVersion) 35 | } 36 | .monospaced() 37 | .buttonStyle(.plain) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Clipchop/Views/Settings/CopyrightsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CopyrightsView.swift 3 | // Clipchop 4 | // 5 | // Created by KrLite on 2024/5/13. 6 | // 7 | 8 | import SwiftUI 9 | 10 | var copyrights: String { 11 | .init( 12 | format: .init(localized: "Copyrights", defaultValue: "%@ Cement Labs"), 13 | appDevelopmentTime 14 | ) 15 | } 16 | 17 | var appDevelopmentTime: String { 18 | let onStreamYear = Calendar.current.component(.year, from: onStreamTime) 19 | let currentYear = Calendar.current.component(.year, from: .now) 20 | 21 | if onStreamYear == currentYear { 22 | return String(onStreamYear) 23 | } else { 24 | return .init( 25 | format: .init(localized: "App Development Time", defaultValue: "%1$@-%2$@"), 26 | onStreamYear, currentYear 27 | ) 28 | } 29 | } 30 | 31 | struct CopyrightsView: View { 32 | var body: some View { 33 | Label { 34 | Text(copyrights) 35 | } icon: { 36 | Image(systemSymbol: .cCircle) 37 | } 38 | .monospaced() 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Clipchop/Views/Settings/NavigationEntry.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NavigationEntry.swift 3 | // Clipchop 4 | // 5 | // Created by KrLite on 2024/5/26. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct NavigationEntry: View where TextLabel: View, ImageLabel: View { 11 | @ViewBuilder var title: () -> TextLabel 12 | @ViewBuilder var icon: () -> ImageLabel 13 | 14 | init( 15 | @ViewBuilder title: @escaping () -> TextLabel, 16 | @ViewBuilder icon: @escaping () -> ImageLabel 17 | ) { 18 | self.title = title 19 | self.icon = icon 20 | } 21 | 22 | init( 23 | _ titleKey: LocalizedStringKey, 24 | @ViewBuilder icon: @escaping () -> ImageLabel 25 | ) where TextLabel == Text { 26 | self.init { 27 | Text(titleKey) 28 | } icon: { 29 | icon() 30 | } 31 | } 32 | 33 | var body: some View { 34 | HStack { 35 | icon() 36 | .imageScale(.large) 37 | .frame(width: 24) 38 | .bold() 39 | 40 | title() 41 | .font(.title3) 42 | .foregroundStyle(.secondary) 43 | } 44 | .padding(.horizontal, 6) 45 | .padding(.vertical, 7.5) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Clipchop/Views/Settings/Pages/CategorizationPage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CategoriesPage.swift 3 | // Clipchop 4 | // 5 | // Created by Xinshao_Air on 2024/5/26. 6 | // 7 | 8 | import SwiftUI 9 | import Defaults 10 | import SFSafeSymbols 11 | import UniformTypeIdentifiers 12 | 13 | struct CategorizationPage: View { 14 | var body: some View { 15 | ZStack { 16 | List { } 17 | .allowsHitTesting(false) 18 | .scrollDisabled(true) 19 | .ignoresSafeArea() 20 | 21 | CategorizationSection() 22 | .background(.clear) 23 | .scrollContentBackground(.hidden) 24 | } 25 | } 26 | } 27 | 28 | -------------------------------------------------------------------------------- /Clipchop/Views/Settings/Pages/ClipboardSettingsPage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ClipboardSettingsPage.swift 3 | // Clipchop 4 | // 5 | // Created by KrLite on 2024/4/27. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ClipboardSettingsPage: View { 11 | var body: some View { 12 | ListEmbeddedForm { 13 | KeyboardShortcutsSection() 14 | .controlSize(.large) 15 | 16 | ClipboardBehaviorsSection(clipboardController: ClipboardManager.clipboardController!) 17 | } 18 | } 19 | } 20 | 21 | #Preview { 22 | previewPage { 23 | ClipboardSettingsPage() 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Clipchop/Views/Settings/Pages/CustomizationSettingsPage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CustomizationSettingsPage.swift 3 | // Clipchop 4 | // 5 | // Created by KrLite on 2024/5/12. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct CustomizationSettingsPage: View { 11 | var body: some View { 12 | ListEmbeddedForm { 13 | AppearanceSection() 14 | } 15 | } 16 | } 17 | 18 | #Preview { 19 | previewPage { 20 | CustomizationSettingsPage() 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Clipchop/Views/Settings/Pages/ExcludedAppsSettingsPage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ExcludedAppsSettingsPage.swift 3 | // Clipchop 4 | // 5 | // Created by KrLite on 2024/5/12. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ExcludedAppsSettingsPage: View { 11 | @EnvironmentObject private var apps: InstalledApps 12 | 13 | var body: some View { 14 | ListEmbeddedForm { 15 | ExcludedAppListSection() 16 | .environmentObject(apps) 17 | } 18 | } 19 | } 20 | 21 | #Preview { 22 | previewPage { 23 | ExcludedAppsSettingsPage() 24 | .environmentObject(InstalledApps()) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Clipchop/Views/Settings/Pages/GeneralSettingsPage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GeneralSettingsPage.swift 3 | // Clipchop 4 | // 5 | // Created by KrLite on 2024/4/27. 6 | // 7 | 8 | import SwiftUI 9 | import Defaults 10 | 11 | struct GeneralSettingsPage: View { 12 | @Default(.preferredColorScheme) private var preferredColorScheme 13 | var body: some View { 14 | ListEmbeddedForm { 15 | PermissionsSection() 16 | 17 | GlobalBehaviorsSection() 18 | } 19 | .preferredColorScheme(preferredColorScheme.colorScheme) 20 | } 21 | } 22 | 23 | 24 | #Preview { 25 | previewPage { 26 | GeneralSettingsPage() 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Clipchop/Views/Settings/Pages/SyncingSettingsPage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SyncingSettingsPage.swift 3 | // Clipchop 4 | // 5 | // Created by KrLite on 2024/4/27. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct SyncingSettingsPage: View { 11 | var body: some View { 12 | Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) 13 | } 14 | } 15 | 16 | #Preview { 17 | previewPage { 18 | SyncingSettingsPage() 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Clipchop/Views/Settings/Pages/TestSettingsPage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TestSettingsPage.swift 3 | // Clipchop 4 | // 5 | // Created by KrLite on 2024/5/18. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct TestSettingsPage: View { 11 | var body: some View { 12 | ListEmbeddedForm { 13 | Button { 14 | MetadataCache.shared.clearAllCaches() 15 | } label: { 16 | Text("clearAllCaches") 17 | } 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Clipchop/Views/Settings/Sections/Categorization/RoundedTagView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RoundedTagView.swift 3 | // Clipchop 4 | // 5 | // Created by Xinshao_Air on 2024/6/2. 6 | // 7 | 8 | import SwiftUI 9 | import SFSafeSymbols 10 | 11 | 12 | struct RoundedTagView: View { 13 | 14 | @Binding var isDeleteButtonShown: Bool 15 | 16 | var text: String 17 | var onDelete: () -> Void 18 | 19 | var body: some View { 20 | HStack { 21 | Text(text) 22 | .padding(.horizontal, 12) 23 | .padding(.vertical, 8) 24 | .background(.placeholder.opacity(0.1)) 25 | .clipShape(.rect(cornerRadius: 12)) 26 | .overlay(alignment: .topTrailing){ 27 | if isDeleteButtonShown{ 28 | Button(action: onDelete) { 29 | Image(systemSymbol: .xmarkCircleFill) 30 | .foregroundColor(.red) 31 | } 32 | .offset(x: 5, y: -5) 33 | .buttonStyle(.borderless) 34 | } 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Clipchop/Views/Settings/Sections/Clipboard/KeyboardShortcutsSection.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KeyboardShortcutsSection.swift 3 | // Clipchop 4 | // 5 | // Created by KrLite on 2024/5/13. 6 | // 7 | 8 | import SwiftUI 9 | import KeyboardShortcuts 10 | 11 | struct KeyboardShortcutsSection: View { 12 | @Environment(\.hasTitle) private var hasTitle 13 | 14 | var body: some View { 15 | Section { 16 | KeyboardShortcuts.Recorder(for: .window) { 17 | withCaption { 18 | Text("Show \(Bundle.main.appName)") 19 | } caption: { 20 | Text("Call up the clip history window.") 21 | } 22 | } 23 | } header: { 24 | if hasTitle { 25 | Text("Keyboard Shortcuts") 26 | } 27 | } 28 | 29 | Section { 30 | KeyboardShortcuts.Recorder(for: .start) { 31 | withCaption { 32 | Text("Clipboard Monitoring") 33 | } caption: { 34 | Text("Enable or disable monitoring of your clipboard history.") 35 | } 36 | } 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Clipchop/Views/Settings/Sections/ColoredPickerRow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ColoredPickerRow.swift 3 | // Clipchop 4 | // 5 | // Created by KrLite on 2024/5/19. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ColoredPickerRow: View where Style: ShapeStyle, Content: View { 11 | @Environment(\.colorScheme) private var colorScheme 12 | @Environment(\.displayScale) private var displayScale 13 | 14 | var style: Style 15 | var content: () -> Content 16 | 17 | var body: some View { 18 | HStack(alignment: .center) { 19 | render { 20 | ZStack { 21 | Image(systemSymbol: .circleFill) 22 | .foregroundStyle(colorScheme == .light ? .white.opacity(0.75) : .black.opacity(0.75)) 23 | 24 | Circle() 25 | .foregroundStyle(style) 26 | .padding(3.5) 27 | } 28 | } 29 | 30 | content() 31 | } 32 | } 33 | 34 | @MainActor 35 | func render(content: () -> some View) -> Image? { 36 | let renderer = ImageRenderer(content: content()) 37 | renderer.scale = displayScale 38 | 39 | if let image = renderer.nsImage { 40 | return Image(nsImage: image) 41 | } else { 42 | return nil 43 | } 44 | } 45 | } 46 | 47 | struct ColoredPickerRowVStack: View where Style: ShapeStyle, Content: View { 48 | @Environment(\.colorScheme) private var colorScheme 49 | @Environment(\.displayScale) private var displayScale 50 | 51 | var style: Style 52 | var content: () -> Content 53 | 54 | var body: some View { 55 | VStack(alignment: .center) { 56 | render { 57 | ZStack { 58 | Image(systemSymbol: .circleFill) 59 | .foregroundStyle(colorScheme == .light ? .white.opacity(0.75) : .black.opacity(0.75)) 60 | 61 | Circle() 62 | .foregroundStyle(style) 63 | .padding(3.5) 64 | } 65 | } 66 | 67 | content() 68 | } 69 | } 70 | 71 | @MainActor 72 | func render(content: () -> some View) -> Image? { 73 | let renderer = ImageRenderer(content: content()) 74 | renderer.scale = displayScale 75 | 76 | if let image = renderer.nsImage { 77 | return Image(nsImage: image) 78 | } else { 79 | return nil 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /Clipchop/Views/Settings/Sections/Customization/AppearanceSection.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppearanceSection.swift 3 | // Clipchop 4 | // 5 | // Created by KrLite on 2024/5/12. 6 | // 7 | 8 | import SwiftUI 9 | import Defaults 10 | import SFSafeSymbols 11 | 12 | struct AppearanceSection: View { 13 | @Default(.timesClipped) private var timesClipped 14 | @Default(.appIcon) private var appIcon 15 | @Default(.clipSound) private var clipSound 16 | @Default(.pasteSound) private var pasteSound 17 | 18 | @Default(.colorStyle) private var colorStyle 19 | @Default(.customAccentColor) private var customAccentColor 20 | 21 | @Environment(\.hasTitle) private var hasTitle 22 | 23 | var body: some View { 24 | if hasTitle { 25 | Section { 26 | Picker("App Icon", selection: $appIcon) { 27 | ForEach(AppIcon.unlockedAppIcons, id: \.self) { icon in 28 | HStack { 29 | Image(nsImage: icon.image) 30 | Text(icon.name ?? "") 31 | } 32 | .tag(icon.assetName) 33 | } 34 | } 35 | .onChange(of: appIcon) { _, newIcon in 36 | newIcon.setAppIcon() 37 | } 38 | } header: { 39 | withCaption { 40 | Text("Appearance") 41 | } caption: { 42 | Text(""" 43 | Clip more to unlock more! You've already clipped \(timesClipped) times. 44 | """) 45 | .contentTransition(.numericText(value: Double(timesClipped))) 46 | .animation(.snappy(duration: 0.5), value: timesClipped) 47 | } 48 | } 49 | } 50 | 51 | Section { 52 | soundPicker("Clip sound", selection: $clipSound) { _, newSound in 53 | newSound.setClipSound() 54 | newSound.play() 55 | } 56 | 57 | soundPicker("Paste sound", selection: $pasteSound) { _, newSound in 58 | newSound.setPasteSound() 59 | newSound.play() 60 | } 61 | } 62 | 63 | Section { 64 | withCaption("Custom accent color only applies to the clip history window.") { 65 | Picker(selection: $colorStyle) { 66 | ColoredPickerRow(style: Defaults.inlineAccentColor(style: .app, customColor: .accent)) { 67 | Text("Application") 68 | } 69 | .tag(ColorStyle.app) 70 | 71 | ColoredPickerRow(style: Defaults.inlineAccentColor(style: .system, customColor: .blue)) { 72 | Text("macOS Blue") 73 | } 74 | .tag(ColorStyle.system) 75 | 76 | ColoredPickerRow(style: Defaults.inlineAccentColor(style: .custom, customColor: customAccentColor)) { 77 | Text("Custom") 78 | } 79 | .tag(ColorStyle.custom) 80 | } label: { 81 | HStack { 82 | Text("Color style") 83 | 84 | Spacer() 85 | 86 | if colorStyle == .custom { 87 | ColorPicker(selection: $customAccentColor) { } 88 | } 89 | } 90 | } 91 | } 92 | PreferredColorSchemePicker() 93 | } header: { 94 | if hasTitle { 95 | Text("Color") 96 | } 97 | } 98 | } 99 | 100 | @ViewBuilder 101 | private func soundPicker( 102 | _ titleKey: LocalizedStringKey, 103 | selection: Binding, 104 | onChangePerform action: @escaping (Sound, Sound) -> Void 105 | ) -> some View { 106 | HStack { 107 | Picker(titleKey, selection: selection) { 108 | ForEach(Sound.unlockedSounds, id: \.self) { sound in 109 | Text(sound.name ?? "") 110 | .tag(sound.assetName) 111 | } 112 | } 113 | .onChange(of: selection.wrappedValue, action) 114 | 115 | if selection.wrappedValue.hasSound { 116 | Button { 117 | selection.wrappedValue.play() 118 | } label: { 119 | Image(systemSymbol: .speakerWave2Fill) 120 | } 121 | .buttonStyle(.plain) 122 | } 123 | } 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /Clipchop/Views/Settings/Sections/Excluded Apps/InstalledAppsMenu.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InstalledAppsMenu.swift 3 | // Clipchop 4 | // 5 | // Created by KrLite on 2024/5/12. 6 | // 7 | 8 | import SwiftUI 9 | import Defaults 10 | 11 | struct InstalledAppsMenu: View { 12 | @EnvironmentObject private var apps: InstalledApps 13 | 14 | @Default(.excludedApplications) private var excluded 15 | 16 | var entry: String? 17 | 18 | init(entry: String? = nil) { 19 | self.entry = entry 20 | } 21 | 22 | var body: some View { 23 | let availableApps = (apps.installedApps + apps.systemApps) 24 | .filter { !excluded.contains($0.bundleID) } 25 | .grouped { $0.installationFolder } 26 | let installationFolders = availableApps.keys 27 | .sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending } 28 | 29 | ForEach(installationFolders, id: \.self) { folder in 30 | Section(folder) { 31 | let containedApps = availableApps[folder]!.sorted { 32 | $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending 33 | } 34 | 35 | ForEach(containedApps) { app in 36 | Button { 37 | if 38 | let entry, 39 | let destination = excluded.firstIndex(of: entry) 40 | { 41 | excluded.insert(app.bundleID, at: excluded.index(after: destination)) 42 | } else { 43 | excluded.append(app.bundleID) 44 | } 45 | } label: { 46 | Image(nsImage: app.icon.resized(to: .init(width: 16, height: 16))) 47 | Text(app.displayName) 48 | } 49 | } 50 | } 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Clipchop/Views/Settings/Sections/FormSectionListContainer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FormSectionListContainer.swift 3 | // Clipchop 4 | // 5 | // Created by KrLite on 2024/5/28. 6 | // 7 | 8 | import SwiftUI 9 | import SFSafeSymbols 10 | 11 | struct FormSectionListContainer: View where Content: View, Footer: View { 12 | @ViewBuilder var content: () -> Content 13 | @ViewBuilder var footer: () -> Footer 14 | 15 | init( 16 | @ViewBuilder content: @escaping () -> Content, 17 | @ViewBuilder footer: @escaping () -> Footer 18 | ) { 19 | self.content = content 20 | self.footer = footer 21 | } 22 | 23 | init( 24 | @ViewBuilder content: @escaping () -> Content 25 | ) where Footer == EmptyView { 26 | self.init(content: content) { 27 | EmptyView() 28 | } 29 | } 30 | 31 | var body: some View { 32 | VStack(spacing: 0) { 33 | content() 34 | 35 | if Footer.self != EmptyView.self { 36 | Divider() 37 | 38 | Rectangle() 39 | .frame(height: 24) 40 | .foregroundStyle(.quinary) 41 | .overlay { 42 | HStack(spacing: 2) { 43 | footer() 44 | } 45 | .frame(height: 20) 46 | .padding(2) 47 | } 48 | } 49 | } 50 | .ignoresSafeArea() 51 | .padding(-10) 52 | } 53 | } 54 | 55 | struct FormSectionFooterLabel: View { 56 | var symbol: SFSymbol 57 | 58 | var body: some View { 59 | Rectangle() 60 | .foregroundStyle(.placeholder.opacity(0)) 61 | .overlay { 62 | Image(systemSymbol: symbol) 63 | .font(.footnote) 64 | .fontWeight(.semibold) 65 | } 66 | .aspectRatio(1, contentMode: .fit) 67 | .padding(-5) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Clipchop/Views/Settings/Sections/General/GlobalBehaviorsSection.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GlobalBehaviorsSection.swift 3 | // Clipchop 4 | // 5 | // Created by KrLite on 2024/4/27. 6 | // 7 | 8 | import SwiftUI 9 | import Defaults 10 | import LaunchAtLogin 11 | 12 | struct GlobalBehaviorsSection: View { 13 | 14 | @Default(.menuBarItemEnabled) private var menuBarItemEnabled 15 | @Default(.autoCloseTimeout) private var autoCloseTimeout 16 | @Default(.cursorPosition) private var cursorPosition 17 | @Default(.displayMore) private var displayMore 18 | 19 | @Environment(\.hasTitle) private var hasTitle 20 | 21 | @State private var showPopoverMore = false 22 | @State private var displayMoreChanged = false 23 | 24 | private var controller: ClipHistoryPanelController = ClipHistoryPanelController() 25 | 26 | var body: some View { 27 | Section { 28 | LaunchAtLogin.Toggle { 29 | Text("Starts with macOS") 30 | } 31 | 32 | withCaption(""" 33 | You can always open \(Bundle.main.appName) again to access this page. 34 | """) { 35 | 36 | Toggle("Shows menu bar item", isOn: $menuBarItemEnabled) 37 | } 38 | 39 | } header: { 40 | if hasTitle { 41 | Text("Global Behaviors") 42 | } 43 | } 44 | Section { 45 | withCaption("This option will determine where your panel appears") { 46 | HStack{ 47 | Text("Panel Position") 48 | if Defaults[.cursorPosition] == .adjustedPosition { 49 | Button(action: { 50 | self.showPopoverMore.toggle() 51 | }) { 52 | Image(systemSymbol: .infoCircle) 53 | } 54 | .buttonStyle(.plain) 55 | .popover(isPresented: $showPopoverMore, arrowEdge: .top) { 56 | VStack { 57 | Text("Some applications are not available and are displayed at the cursor.") 58 | .font(.body) 59 | } 60 | .frame(width: 200, height: 100) 61 | .padding() 62 | } 63 | } 64 | Spacer() 65 | Picker("", selection: $cursorPosition) { 66 | Text("At the mouse").tag(CursorPosition.mouseLocation) 67 | Text("At the cursor").tag(CursorPosition.adjustedPosition) 68 | } 69 | } 70 | } 71 | withCaption("This action will enlarge the panel size to display more content.") { 72 | Toggle("Display More", isOn: $displayMore) 73 | .onChange(of: displayMore) { newValue, _ in 74 | controller.logoutpanel() 75 | } 76 | } 77 | VStack { 78 | HStack { 79 | Text("Auto Close Timeout") 80 | Spacer() 81 | Text("\(Int(autoCloseTimeout))s") 82 | .monospaced() 83 | } 84 | Slider(value: $autoCloseTimeout, in: 5...60) { 85 | 86 | } minimumValueLabel: { 87 | Text("5") 88 | } maximumValueLabel: { 89 | Text("60") 90 | } 91 | .monospaced() 92 | } 93 | } header: { 94 | if hasTitle { 95 | Text("Panel Behaviors") 96 | } 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /Clipchop/Views/Settings/Sections/General/PermissionsSection.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PermissionsSection.swift 3 | // Clipchop 4 | // 5 | // Created by KrLite on 2024/4/27. 6 | // 7 | 8 | import SwiftUI 9 | import Defaults 10 | 11 | import LaunchAtLogin 12 | import FullDiskAccess 13 | 14 | struct PermissionsSection: View { 15 | 16 | @State private var isAccessibilityAccessGranted = false 17 | @State private var isFullDiskAccessGranted = false 18 | 19 | @Environment(\.hasTitle) private var hasTitle 20 | 21 | private let permissionsAutoCheck = Timer.publish( 22 | every: 1, tolerance: 0.5, 23 | on: .main, in: .common 24 | ).autoconnect() 25 | 26 | var body: some View { 27 | Section { 28 | HStack { 29 | withCaption { 30 | Text("Accessibility Access") 31 | } caption: { 32 | Text(""" 33 | Accessibility Access is needed to take over your clipboard. 34 | """) 35 | } 36 | 37 | Spacer() 38 | 39 | grantAccessButton(isGranted: isAccessibilityAccessGranted) { 40 | isAccessibilityAccessGranted = PermissionsManager.Accessibility.requestAccess() 41 | } 42 | .onAppear { 43 | isAccessibilityAccessGranted = PermissionsManager.Accessibility.getStatus() 44 | } 45 | #if !DEBUG 46 | .onReceive(permissionsAutoCheck) { _ in 47 | isAccessibilityAccessGranted = PermissionsManager.Accessibility.getStatus() 48 | } 49 | #endif 50 | } 51 | 52 | HStack { 53 | withCaption { 54 | Text("Full Disk Access") 55 | } caption: { 56 | Text(""" 57 | Full Disk Access is needed to generate file previews. 58 | """) 59 | } 60 | 61 | Spacer() 62 | 63 | grantAccessButton(isGranted: isFullDiskAccessGranted) { 64 | isFullDiskAccessGranted = PermissionsManager.FullDisk.requestAccess() 65 | } 66 | .onAppear { 67 | isFullDiskAccessGranted = PermissionsManager.FullDisk.getStatus() 68 | } 69 | #if !DEBUG 70 | .onReceive(permissionsAutoCheck) { _ in 71 | isFullDiskAccessGranted = PermissionsManager.FullDisk.getStatus() 72 | } 73 | #endif 74 | } 75 | 76 | #if DEBUG 77 | Button("Refresh States (Debug)") { 78 | isAccessibilityAccessGranted = PermissionsManager.Accessibility.getStatus() 79 | isFullDiskAccessGranted = PermissionsManager.FullDisk.getStatus() 80 | } 81 | #endif 82 | } header: { 83 | if hasTitle { 84 | Text("Permissions") 85 | } 86 | } 87 | } 88 | 89 | @ViewBuilder 90 | private func grantAccessButton(isGranted: Bool, action: @escaping () -> Void) -> some View { 91 | Button { 92 | action() 93 | } label: { 94 | Group { 95 | Text("Grant") 96 | .fixedSize() 97 | 98 | Image(systemSymbol: .arrowRightCircleFill) 99 | } 100 | .or(isGranted) { 101 | Group { 102 | Text("Granted") 103 | .fixedSize() 104 | 105 | Image(systemSymbol: .checkmarkSealFill) 106 | } 107 | } 108 | .frame(height: 16) 109 | } 110 | .animation(.default, value: isGranted) 111 | .controlSize(.large) 112 | .buttonStyle(.borderless) 113 | .buttonBorderShape(.capsule) 114 | .disabled(isGranted) 115 | .tint(isGranted ? .secondary : .red) 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /Clipchop/Views/Settings/Sections/PreferredColorSchemePicker.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PreferredColorSchemePicker.swift 3 | // Clipchop 4 | // 5 | // Created by KrLite on 2024/5/19. 6 | // 7 | 8 | 9 | import SwiftUI 10 | import Defaults 11 | 12 | struct PreferredColorSchemePicker: View { 13 | @Default(.preferredColorScheme) private var preferredColorScheme 14 | 15 | var body: some View { 16 | Picker("Preferred color scheme", selection: $preferredColorScheme) { 17 | Text("System").tag(PreferredColorScheme.system) 18 | Divider() 19 | Text("Light").tag(PreferredColorScheme.light) 20 | Text("Dark").tag(PreferredColorScheme.dark) 21 | } 22 | .onChange(of: preferredColorScheme) { _, _ in 23 | applyColorScheme() 24 | } 25 | .onAppear { 26 | applyColorScheme() 27 | } 28 | } 29 | 30 | private func applyColorScheme() { 31 | switch preferredColorScheme { 32 | case .system: 33 | NSApplication.shared.appearance = nil 34 | case .light: 35 | NSApplication.shared.appearance = NSAppearance(named: .aqua) 36 | case .dark: 37 | NSApplication.shared.appearance = NSAppearance(named: .darkAqua) 38 | } 39 | } 40 | } 41 | 42 | -------------------------------------------------------------------------------- /ClipchopTests/ClipchopTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ClipchopTests.swift 3 | // ClipchopTests 4 | // 5 | // Created by KrLite on 2024/7/6. 6 | // 7 | 8 | import Testing 9 | 10 | struct ClipchopTests { 11 | @Test func testExample() async throws { 12 | // Write your test here and use APIs like `#expect(...)` to check expected conditions. 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Docs/ADD_A_LOCALIZATION.md: -------------------------------------------------------------------------------- 1 | # Add a Localization for Clipchop 2 | 3 | In order to contribute to **Clipchop** by localizing it, you need to follow the steps below. 4 | 5 | ## The Clipchop App 6 | 7 | ## The Documentations 8 | -------------------------------------------------------------------------------- /Docs/Contents/简体中文/Overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cement-Labs/Clipchop/c6010a8ba9421c3508f1ac7edb05aca33f81b5c6/Docs/Contents/简体中文/Overview.png -------------------------------------------------------------------------------- /Docs/Contents/简体中文/Overview2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cement-Labs/Clipchop/c6010a8ba9421c3508f1ac7edb05aca33f81b5c6/Docs/Contents/简体中文/Overview2.png -------------------------------------------------------------------------------- /Docs/简体中文.md: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | あ ←→ A 5 | 6 | 7 |   Clipchop支持以下语言。↗ 添加一种语言 8 |
9 | 10 |
11 |   English 12 |
13 |   简体中文 14 |
15 |
16 | 17 | #


Clipchop


18 | 19 | ######

简化、整理、掌控你的 macOS 剪贴板。

20 | 21 | > [!IMPORTANT] 22 | > **Clipchop** 需要运行在 **macOS 14.0 Sonoma**[^check_your_macos_version] 及以上的系统中。 23 | 24 | [^check_your_macos_version]: [`↗ 确定你的 Mac 使用的是哪个 macOS 版本`](https://support.apple.com/zh-cn/HT201260) 25 | 26 | ## 了解Clipchop 27 | 28 | **Clipchop** 有两种形态 - **折叠**和**展开** 29 | 30 |
31 | 32 |
33 | 34 | - **折叠**模式是默认状态,只展示最近的剪贴板记录,保持界面简洁明快。 35 | 36 |
37 | 38 |
39 | 40 | - **展开**模式下,您可以浏览所有保存的剪贴板内容,并按类别整理,支持关键词搜索。 41 | 42 | 折叠和展开可以通过`[`和`]`进行切换。 43 | 44 | ## 快速操作指南 45 | 46 | **Clipchop** 允许您通过快捷键快速操作: 47 | 48 | - **呼出Clipchop**:默认使用`⌥`(Option)+ `w`呼出剪贴板历史面板。 49 | - **展开/折叠**:使用`]`展开面板,使用`[`折叠面板。 50 | - **复制/粘贴**:使用 `⌘`(Command)+ 数字键,即可从剪贴板中选择并粘贴特定条目。 51 | - **固定剪贴板历史**:通过 `⌥`(Option)+ 数字键,可以迅速锁定重要信息,防止意外清除。 52 | - **删除剪贴板历史**:若要移除某条记录,只需按下 `⌃`(Control)+ 数字键即可。 53 | 54 | **Clipchop** 同时还支持双击复制和拖拽粘贴[^drag_copy]。 55 | 56 | [^drag_copy]: `拖拽粘贴存在一定的局限性,部分应用可能不支持拖拽粘贴。` 57 | 58 | ## 特殊功能 59 | 60 | - **图像内文本搜索**:即使剪贴的是图片,也能够识别并搜索其中的文字内容。 61 | - **自定义分类**:根据个人偏好组织剪贴板内容,提升查找效率。 62 | 63 | ## 安装和运行 64 | 65 | > [!NOTE] 66 | > 作为一个开源且免费的软件,**Clipchop**目前并未上架App Store。这意味着您需要从 [Releases](https://github.com/Cement-Labs/Clipchop/releases) 页面手动下载应用包。同时,由于非官方商店来源,首次运行时您可能需要允许 **Clipchop** 作为未认证应用执行[^open_as_unidentified]。 67 | 68 | [^open_as_unidentified]: [`↗ 打开来自身份不明开发者的 Mac App`](https://support.apple.com/zh-cn/guide/mac-help/mh40616/mac) 69 | 70 | ### 下载Alpha版本 71 | 72 | 您可以直接从[Releases](https://github.com/Cement-Labs/Clipchop/releases)页面下载最新的Clipchop的alpha版本。 73 | 74 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | あ ←→ A 5 | 6 | 7 |   Clipchop supports the following languages. ↗ Add a localization 8 |
9 | 10 |
11 |   English 12 |
13 |   简体中文 14 |
15 |
16 | 17 | #


Clipchop


18 | 19 | ######

Simplify, organize, and control your macOS clipboard.

20 | 21 | > [!IMPORTANT] 22 | > **Clipchop** requires **macOS 14.0 Sonoma**[^check_your_macos_version] or above to run. 23 | 24 | > [^check_your_macos_version]: [`↗ Find out which macOS your Mac is using`](https://support.apple.com/en-us/HT201260) 25 | 26 | ## Getting Started with Clipchop 27 | 28 | **Clipchop** exists in two forms - **Collapsed** and **Expanded**. 29 | 30 |
31 | 32 |
33 | 34 | - In **Collapsed** mode, the default state, only recent clipboard entries are displayed to keep the interface clean and streamlined. 35 | 36 |
37 | 38 |
39 | 40 | - In **Expanded** mode, you can browse all saved clipboard content, organized by category, with support for keyword searches. 41 | 42 | The transition between Collapsed and Expanded can be toggled using `[` and `]`. 43 | 44 | ## Quick Operation Guide 45 | 46 | **Clipchop** enables quick actions through keyboard shortcuts: 47 | 48 | - **Summon Clipchop**: By default, use `⌥` (Option) + `w` to summon the clipboard history panel. 49 | - **Expand/Collapse**: Use `]` to expand the panel, and use `[` to collapse it. 50 | - **Copy/Paste**: Use `⌘` (Command) + number keys to select and paste a specific entry from the clipboard. 51 | - **Pin Clipboard History**: `⌥` (Option) + number keys quickly locks down important information to prevent accidental deletion. 52 | - **Delete Clipboard History**: To remove an entry, simply press `⌃` (Control) + number keys. 53 | 54 | **Clipchop** also supports double-click copying and drag-and-drop pasting[^drag_copy]. 55 | 56 | [^drag_copy]: `Drag-and-drop paste has certain limitations, as some applications may not support this feature.` 57 | 58 | ## Special Features 59 | 60 | - **Text Search in Images**: Even if you've copied an image, it can recognize and search for text within it. 61 | - **Custom Categories**: Organize clipboard content based on personal preference to enhance search efficiency. 62 | 63 | ## Installation and Running 64 | 65 | > [!NOTE] 66 | > As open-source and free software, **Clipchop** is not available on the App Store. This means you need to manually download the application package from the [Releases](https://github.com/Cement-Labs/Clipchop/releases) page. Additionally, because it's not from an official store, you might have to allow **Clipchop** to run as an unidentified developer[^open_as_unidentified] upon first launch. 67 | 68 | [^open_as_unidentified]: [`↗ Open apps from unidentified developers`](https://support.apple.com/en-us/HT202491) 69 | 70 | ### Download the Alpha Version 71 | 72 | You can download the latest alpha release of **Clipchop** directly from the [Releases](https://github.com/Cement-Labs/Clipchop/releases) page. 73 | 74 | --------------------------------------------------------------------------------