├── .github └── funding.yaml ├── .gitignore ├── FreeScreenshot-intel.dmg ├── FreeScreenshot-silicon.dmg ├── Package.resolved ├── Package.swift ├── README.md ├── freescreenshot.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── swiftpm │ │ └── Package.resolved └── xcuserdata │ └── licofis.xcuserdatad │ └── xcschemes │ └── xcschememanagement.plist └── freescreenshot ├── AppDelegate+Configuration.swift ├── Assets.xcassets └── AppIcon.appiconset │ ├── 1024.png │ ├── 128.png │ ├── 16.png │ ├── 256.png │ ├── 32.png │ ├── 512.png │ ├── 64.png │ └── Contents.json ├── ContentView.swift ├── Info.plist ├── Models └── EditorModels.swift ├── Utilities └── ImageUtilities.swift ├── ViewModels └── EditorViewModel.swift ├── Views ├── BackgroundPicker.swift ├── EditorView.swift └── ExportView.swift ├── freescreenshot.entitlements └── freescreenshotApp.swift /.github/funding.yaml: -------------------------------------------------------------------------------- 1 | # If you find my open-source work helpful, please consider sponsoring me! 2 | 3 | github: prosamik -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Build 2 | .build/ 3 | build/ 4 | DerivedData/ 5 | *.o 6 | *.pyc 7 | 8 | # Package Managers 9 | .swiftpm/ 10 | .resolved 11 | Package.resolved 12 | 13 | # IDE 14 | .vscode/ 15 | .idea/ 16 | *.xcodeproj 17 | *.xcworkspace 18 | 19 | # macOS 20 | .DS_Store 21 | .AppleDouble 22 | .LSOverride 23 | ._* 24 | 25 | # App specific 26 | FreeScreenshot.app/ 27 | dmg_temp/ 28 | AppIcon.iconset/ 29 | *.icns 30 | 31 | # Logs and databases 32 | *.log 33 | *.sqlite 34 | *.sqlite3 35 | 36 | -------------------------------------------------------------------------------- /FreeScreenshot-intel.dmg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/proSamik/freescreenshot/8d47739c4436c96eb1743e1e6a40b7c06596db0d/FreeScreenshot-intel.dmg -------------------------------------------------------------------------------- /FreeScreenshot-silicon.dmg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/proSamik/freescreenshot/8d47739c4436c96eb1743e1e6a40b7c06596db0d/FreeScreenshot-silicon.dmg -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "hotkey", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/soffes/HotKey", 7 | "state" : { 8 | "revision" : "a3cf605d7a96f6ff50e04fcb6dea6e2613cfcbe4", 9 | "version" : "0.2.1" 10 | } 11 | } 12 | ], 13 | "version" : 2 14 | } 15 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.7 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "FreeScreenshot", 6 | platforms: [ 7 | .macOS(.v12) 8 | ], 9 | products: [ 10 | .executable( 11 | name: "FreeScreenshot", 12 | targets: ["FreeScreenshot"]), 13 | ], 14 | dependencies: [ 15 | .package(url: "https://github.com/soffes/HotKey", from: "0.1.3"), 16 | ], 17 | targets: [ 18 | .executableTarget( 19 | name: "FreeScreenshot", 20 | dependencies: ["HotKey"], 21 | path: "freescreenshot", 22 | resources: [ 23 | .process("Assets.xcassets") 24 | ]), 25 | ] 26 | ) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FreeScreenshot 2 | 3 | FreeScreenshot is a macOS application that transforms dull screenshots into stunning visuals with just a few clicks. 4 | 5 | Demo Video- https://youtu.be/oOLXdRLYA24 6 | 7 | ## Features 8 | 9 | - Capture screenshots with Cmd+Shift+7 or drag and drop existing images 10 | - Add beautiful backgrounds to your screenshots 11 | - Choose from solid colors, gradients, or custom background images 12 | - Apply 3D perspective effects for a professional look 13 | - Export your enhanced screenshots in various formats 14 | - Then Edit more if required in FlameShot (Download it) or Preview (Pre-installed in MacOS) 15 | - Universal binary support for both Apple Silicon and Intel Macs 16 | 17 | ## Download 18 | 19 | [Download the Silicon Based Mac DMG](FreeScreenshot-silicon.dmg) 20 | 21 | [Download the Intel Based Mac DMG](FreeScreenshot-intel.dmg) 22 | 23 | > **⚠️ Caution**: Due to Financial Constraints. The application is not signed with an Apple Developer Certificate. Users may receive security warnings when trying to open the application for the first time. They can bypass this by right-clicking the app and selecting "Open" from the context menu, or by adjusting their security settings in System Preferences > Security & Privacy. For commercial distribution, consider enrolling in the [Apple Developer Program](https://developer.apple.com/programs/) to properly sign your application. 24 | 25 | ## System Requirements 26 | 27 | - macOS 12.0 or later 28 | - Compatible with both Apple Silicon (M1/M2/M3) and Intel-based Macs 29 | - Xcode 13.0 or later for development 30 | 31 | ## Getting Started 32 | 33 | ### Prerequisites 34 | 35 | - macOS 12.0 or later 36 | - Swift 5.7 or later 37 | - Git 38 | 39 | ### Installation 40 | 41 | 1. Clone the repository: 42 | ```bash 43 | git clone https://github.com/prosamik/freescreenshot.git 44 | cd freescreenshot 45 | ``` 46 | 47 | 2. Build the universal binary: 48 | ```bash 49 | swift build -c release --arch arm64 --arch x86_64 50 | ``` 51 | 52 | ## Creating a Universal DMG 53 | 54 | Follow these steps to create a professional universal DMG file for distribution: 55 | 56 | 1. Build the universal binary: 57 | ```bash 58 | swift build -c release --arch arm64 --arch x86_64 59 | ``` 60 | 61 | 2. Generate the application icon: 62 | ```bash 63 | # Create iconset directory 64 | mkdir -p AppIcon.iconset 65 | 66 | # Copy icons with proper naming 67 | cp freescreenshot/Assets.xcassets/AppIcon.appiconset/16.png AppIcon.iconset/icon_16x16.png 68 | cp freescreenshot/Assets.xcassets/AppIcon.appiconset/32.png AppIcon.iconset/icon_16x16@2x.png 69 | cp freescreenshot/Assets.xcassets/AppIcon.appiconset/32.png AppIcon.iconset/icon_32x32.png 70 | cp freescreenshot/Assets.xcassets/AppIcon.appiconset/64.png AppIcon.iconset/icon_32x32@2x.png 71 | cp freescreenshot/Assets.xcassets/AppIcon.appiconset/128.png AppIcon.iconset/icon_128x128.png 72 | cp freescreenshot/Assets.xcassets/AppIcon.appiconset/256.png AppIcon.iconset/icon_128x128@2x.png 73 | cp freescreenshot/Assets.xcassets/AppIcon.appiconset/256.png AppIcon.iconset/icon_256x256.png 74 | cp freescreenshot/Assets.xcassets/AppIcon.appiconset/512.png AppIcon.iconset/icon_256x256@2x.png 75 | cp freescreenshot/Assets.xcassets/AppIcon.appiconset/512.png AppIcon.iconset/icon_512x512.png 76 | cp freescreenshot/Assets.xcassets/AppIcon.appiconset/1024.png AppIcon.iconset/icon_512x512@2x.png 77 | 78 | # Generate icns file 79 | iconutil -c icns AppIcon.iconset 80 | ``` 81 | 82 | 3. Create the app bundle: 83 | ```bash 84 | # Create app bundle structure 85 | mkdir -p FreeScreenshot.app/Contents/{MacOS,Resources} 86 | 87 | # Copy binary and resources 88 | cp .build/apple/Products/Release/FreeScreenshot FreeScreenshot.app/Contents/MacOS/ 89 | cp freescreenshot/Info.plist FreeScreenshot.app/Contents/ 90 | cp AppIcon.icns FreeScreenshot.app/Contents/Resources/ 91 | cp -R freescreenshot/Assets.xcassets FreeScreenshot.app/Contents/Resources/ 92 | ``` 93 | 94 | 4. Create the DMG: 95 | ```bash 96 | # Create DMG structure 97 | mkdir -p dmg_temp 98 | cp -R FreeScreenshot.app dmg_temp/ 99 | ln -s /Applications dmg_temp/Applications 100 | 101 | # Create DMG file 102 | hdiutil create -volname "FreeScreenshot" -srcfolder dmg_temp -ov -format UDZO FreeScreenshot.dmg 103 | 104 | # Clean up 105 | rm -rf AppIcon.iconset AppIcon.icns dmg_temp FreeScreenshot.app 106 | ``` 107 | 108 | ## Usage 109 | 110 | 1. Mount the DMG file 111 | 2. Drag FreeScreenshot.app to your Applications folder 112 | 3. Right-click FreeScreenshot.app and select "Open" (required first time only) 113 | 4. Press Cmd+Shift+7 to capture a screenshot, or drag and drop an image 114 | 5. Use the toolbar to select different editing tools 115 | 6. Apply backgrounds, add annotations, and enhance your screenshot 116 | 7. Click "Export" to save your masterpiece 117 | 118 | ## Dependencies 119 | 120 | - [HotKey](https://github.com/soffes/HotKey) - For keyboard shortcut handling 121 | 122 | ## License 123 | 124 | This project is licensed under the MIT License - see the LICENSE file for details. 125 | 126 | ``` 127 | MIT License 128 | 129 | Copyright (c) 2025 FreeScreenshot 130 | 131 | Permission is hereby granted, free of charge, to any person obtaining a copy 132 | of this software and associated documentation files (the "Software"), to deal 133 | in the Software without restriction, including without limitation the rights 134 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 135 | copies of the Software, and to permit persons to whom the Software is 136 | furnished to do so, subject to the following conditions: 137 | 138 | The above copyright notice and this permission notice shall be included in all 139 | copies or substantial portions of the Software. 140 | 141 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 142 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 143 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 144 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 145 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 146 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 147 | SOFTWARE. 148 | ``` 149 | 150 | ## Acknowledgments 151 | 152 | - Inspired by Jumpshare and other screenshot enhancement tools 153 | - Built with SwiftUI for a native macOS experience 154 | 155 | 156 |
157 | Followers 158 | Readme count 159 |
160 | 161 | -------------------------------------------------------------------------------- /freescreenshot.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 77; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | C14568382DA2C128006C9032 /* HotKey in Frameworks */ = {isa = PBXBuildFile; productRef = C14568372DA2C128006C9032 /* HotKey */; }; 11 | /* End PBXBuildFile section */ 12 | 13 | /* Begin PBXContainerItemProxy section */ 14 | C14568072DA2B5A1006C9032 /* PBXContainerItemProxy */ = { 15 | isa = PBXContainerItemProxy; 16 | containerPortal = C14567F02DA2B57E006C9032 /* Project object */; 17 | proxyType = 1; 18 | remoteGlobalIDString = C14567F72DA2B57E006C9032; 19 | remoteInfo = freescreenshot; 20 | }; 21 | C14568112DA2B5A1006C9032 /* PBXContainerItemProxy */ = { 22 | isa = PBXContainerItemProxy; 23 | containerPortal = C14567F02DA2B57E006C9032 /* Project object */; 24 | proxyType = 1; 25 | remoteGlobalIDString = C14567F72DA2B57E006C9032; 26 | remoteInfo = freescreenshot; 27 | }; 28 | /* End PBXContainerItemProxy section */ 29 | 30 | /* Begin PBXFileReference section */ 31 | C14567F82DA2B57E006C9032 /* freescreenshot.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = freescreenshot.app; sourceTree = BUILT_PRODUCTS_DIR; }; 32 | C14568062DA2B5A1006C9032 /* freescreenshotTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = freescreenshotTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 33 | C14568102DA2B5A1006C9032 /* freescreenshotUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = freescreenshotUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 34 | /* End PBXFileReference section */ 35 | 36 | /* Begin PBXFileSystemSynchronizedRootGroup section */ 37 | C14567FA2DA2B57E006C9032 /* freescreenshot */ = { 38 | isa = PBXFileSystemSynchronizedRootGroup; 39 | path = freescreenshot; 40 | sourceTree = ""; 41 | }; 42 | /* End PBXFileSystemSynchronizedRootGroup section */ 43 | 44 | /* Begin PBXFrameworksBuildPhase section */ 45 | C14567F52DA2B57E006C9032 /* Frameworks */ = { 46 | isa = PBXFrameworksBuildPhase; 47 | buildActionMask = 2147483647; 48 | files = ( 49 | C14568382DA2C128006C9032 /* HotKey in Frameworks */, 50 | ); 51 | runOnlyForDeploymentPostprocessing = 0; 52 | }; 53 | C14568032DA2B5A1006C9032 /* Frameworks */ = { 54 | isa = PBXFrameworksBuildPhase; 55 | buildActionMask = 2147483647; 56 | files = ( 57 | ); 58 | runOnlyForDeploymentPostprocessing = 0; 59 | }; 60 | C145680D2DA2B5A1006C9032 /* Frameworks */ = { 61 | isa = PBXFrameworksBuildPhase; 62 | buildActionMask = 2147483647; 63 | files = ( 64 | ); 65 | runOnlyForDeploymentPostprocessing = 0; 66 | }; 67 | /* End PBXFrameworksBuildPhase section */ 68 | 69 | /* Begin PBXGroup section */ 70 | C14567EF2DA2B57E006C9032 = { 71 | isa = PBXGroup; 72 | children = ( 73 | C14567FA2DA2B57E006C9032 /* freescreenshot */, 74 | C14568362DA2BC6C006C9032 /* Frameworks */, 75 | C14567F92DA2B57E006C9032 /* Products */, 76 | ); 77 | sourceTree = ""; 78 | }; 79 | C14567F92DA2B57E006C9032 /* Products */ = { 80 | isa = PBXGroup; 81 | children = ( 82 | C14567F82DA2B57E006C9032 /* freescreenshot.app */, 83 | C14568062DA2B5A1006C9032 /* freescreenshotTests.xctest */, 84 | C14568102DA2B5A1006C9032 /* freescreenshotUITests.xctest */, 85 | ); 86 | name = Products; 87 | sourceTree = ""; 88 | }; 89 | C14568362DA2BC6C006C9032 /* Frameworks */ = { 90 | isa = PBXGroup; 91 | children = ( 92 | ); 93 | name = Frameworks; 94 | sourceTree = ""; 95 | }; 96 | /* End PBXGroup section */ 97 | 98 | /* Begin PBXNativeTarget section */ 99 | C14567F72DA2B57E006C9032 /* freescreenshot */ = { 100 | isa = PBXNativeTarget; 101 | buildConfigurationList = C145681A2DA2B5A1006C9032 /* Build configuration list for PBXNativeTarget "freescreenshot" */; 102 | buildPhases = ( 103 | C14567F42DA2B57E006C9032 /* Sources */, 104 | C14567F52DA2B57E006C9032 /* Frameworks */, 105 | C14567F62DA2B57E006C9032 /* Resources */, 106 | ); 107 | buildRules = ( 108 | ); 109 | dependencies = ( 110 | ); 111 | fileSystemSynchronizedGroups = ( 112 | C14567FA2DA2B57E006C9032 /* freescreenshot */, 113 | ); 114 | name = freescreenshot; 115 | packageProductDependencies = ( 116 | C14568372DA2C128006C9032 /* HotKey */, 117 | ); 118 | productName = freescreenshot; 119 | productReference = C14567F82DA2B57E006C9032 /* freescreenshot.app */; 120 | productType = "com.apple.product-type.application"; 121 | }; 122 | C14568052DA2B5A1006C9032 /* freescreenshotTests */ = { 123 | isa = PBXNativeTarget; 124 | buildConfigurationList = C145681D2DA2B5A1006C9032 /* Build configuration list for PBXNativeTarget "freescreenshotTests" */; 125 | buildPhases = ( 126 | C14568022DA2B5A1006C9032 /* Sources */, 127 | C14568032DA2B5A1006C9032 /* Frameworks */, 128 | C14568042DA2B5A1006C9032 /* Resources */, 129 | ); 130 | buildRules = ( 131 | ); 132 | dependencies = ( 133 | C14568082DA2B5A1006C9032 /* PBXTargetDependency */, 134 | ); 135 | name = freescreenshotTests; 136 | packageProductDependencies = ( 137 | ); 138 | productName = freescreenshotTests; 139 | productReference = C14568062DA2B5A1006C9032 /* freescreenshotTests.xctest */; 140 | productType = "com.apple.product-type.bundle.unit-test"; 141 | }; 142 | C145680F2DA2B5A1006C9032 /* freescreenshotUITests */ = { 143 | isa = PBXNativeTarget; 144 | buildConfigurationList = C14568202DA2B5A1006C9032 /* Build configuration list for PBXNativeTarget "freescreenshotUITests" */; 145 | buildPhases = ( 146 | C145680C2DA2B5A1006C9032 /* Sources */, 147 | C145680D2DA2B5A1006C9032 /* Frameworks */, 148 | C145680E2DA2B5A1006C9032 /* Resources */, 149 | ); 150 | buildRules = ( 151 | ); 152 | dependencies = ( 153 | C14568122DA2B5A1006C9032 /* PBXTargetDependency */, 154 | ); 155 | name = freescreenshotUITests; 156 | packageProductDependencies = ( 157 | ); 158 | productName = freescreenshotUITests; 159 | productReference = C14568102DA2B5A1006C9032 /* freescreenshotUITests.xctest */; 160 | productType = "com.apple.product-type.bundle.ui-testing"; 161 | }; 162 | /* End PBXNativeTarget section */ 163 | 164 | /* Begin PBXProject section */ 165 | C14567F02DA2B57E006C9032 /* Project object */ = { 166 | isa = PBXProject; 167 | attributes = { 168 | BuildIndependentTargetsInParallel = 1; 169 | LastSwiftUpdateCheck = 1630; 170 | LastUpgradeCheck = 1630; 171 | TargetAttributes = { 172 | C14567F72DA2B57E006C9032 = { 173 | CreatedOnToolsVersion = 16.3; 174 | }; 175 | C14568052DA2B5A1006C9032 = { 176 | CreatedOnToolsVersion = 16.3; 177 | TestTargetID = C14567F72DA2B57E006C9032; 178 | }; 179 | C145680F2DA2B5A1006C9032 = { 180 | CreatedOnToolsVersion = 16.3; 181 | TestTargetID = C14567F72DA2B57E006C9032; 182 | }; 183 | }; 184 | }; 185 | buildConfigurationList = C14567F32DA2B57E006C9032 /* Build configuration list for PBXProject "freescreenshot" */; 186 | developmentRegion = en; 187 | hasScannedForEncodings = 0; 188 | knownRegions = ( 189 | en, 190 | Base, 191 | ); 192 | mainGroup = C14567EF2DA2B57E006C9032; 193 | minimizedProjectReferenceProxies = 1; 194 | packageReferences = ( 195 | C14568352DA2BBBD006C9032 /* XCRemoteSwiftPackageReference "HotKey" */, 196 | ); 197 | preferredProjectObjectVersion = 77; 198 | productRefGroup = C14567F92DA2B57E006C9032 /* Products */; 199 | projectDirPath = ""; 200 | projectRoot = ""; 201 | targets = ( 202 | C14567F72DA2B57E006C9032 /* freescreenshot */, 203 | C14568052DA2B5A1006C9032 /* freescreenshotTests */, 204 | C145680F2DA2B5A1006C9032 /* freescreenshotUITests */, 205 | ); 206 | }; 207 | /* End PBXProject section */ 208 | 209 | /* Begin PBXResourcesBuildPhase section */ 210 | C14567F62DA2B57E006C9032 /* Resources */ = { 211 | isa = PBXResourcesBuildPhase; 212 | buildActionMask = 2147483647; 213 | files = ( 214 | ); 215 | runOnlyForDeploymentPostprocessing = 0; 216 | }; 217 | C14568042DA2B5A1006C9032 /* Resources */ = { 218 | isa = PBXResourcesBuildPhase; 219 | buildActionMask = 2147483647; 220 | files = ( 221 | ); 222 | runOnlyForDeploymentPostprocessing = 0; 223 | }; 224 | C145680E2DA2B5A1006C9032 /* Resources */ = { 225 | isa = PBXResourcesBuildPhase; 226 | buildActionMask = 2147483647; 227 | files = ( 228 | ); 229 | runOnlyForDeploymentPostprocessing = 0; 230 | }; 231 | /* End PBXResourcesBuildPhase section */ 232 | 233 | /* Begin PBXSourcesBuildPhase section */ 234 | C14567F42DA2B57E006C9032 /* Sources */ = { 235 | isa = PBXSourcesBuildPhase; 236 | buildActionMask = 2147483647; 237 | files = ( 238 | ); 239 | runOnlyForDeploymentPostprocessing = 0; 240 | }; 241 | C14568022DA2B5A1006C9032 /* Sources */ = { 242 | isa = PBXSourcesBuildPhase; 243 | buildActionMask = 2147483647; 244 | files = ( 245 | ); 246 | runOnlyForDeploymentPostprocessing = 0; 247 | }; 248 | C145680C2DA2B5A1006C9032 /* Sources */ = { 249 | isa = PBXSourcesBuildPhase; 250 | buildActionMask = 2147483647; 251 | files = ( 252 | ); 253 | runOnlyForDeploymentPostprocessing = 0; 254 | }; 255 | /* End PBXSourcesBuildPhase section */ 256 | 257 | /* Begin PBXTargetDependency section */ 258 | C14568082DA2B5A1006C9032 /* PBXTargetDependency */ = { 259 | isa = PBXTargetDependency; 260 | target = C14567F72DA2B57E006C9032 /* freescreenshot */; 261 | targetProxy = C14568072DA2B5A1006C9032 /* PBXContainerItemProxy */; 262 | }; 263 | C14568122DA2B5A1006C9032 /* PBXTargetDependency */ = { 264 | isa = PBXTargetDependency; 265 | target = C14567F72DA2B57E006C9032 /* freescreenshot */; 266 | targetProxy = C14568112DA2B5A1006C9032 /* PBXContainerItemProxy */; 267 | }; 268 | /* End PBXTargetDependency section */ 269 | 270 | /* Begin XCBuildConfiguration section */ 271 | C14568182DA2B5A1006C9032 /* Debug */ = { 272 | isa = XCBuildConfiguration; 273 | buildSettings = { 274 | ALWAYS_SEARCH_USER_PATHS = NO; 275 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 276 | CLANG_ANALYZER_NONNULL = YES; 277 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 278 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 279 | CLANG_ENABLE_MODULES = YES; 280 | CLANG_ENABLE_OBJC_ARC = YES; 281 | CLANG_ENABLE_OBJC_WEAK = YES; 282 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 283 | CLANG_WARN_BOOL_CONVERSION = YES; 284 | CLANG_WARN_COMMA = YES; 285 | CLANG_WARN_CONSTANT_CONVERSION = YES; 286 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 287 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 288 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 289 | CLANG_WARN_EMPTY_BODY = YES; 290 | CLANG_WARN_ENUM_CONVERSION = YES; 291 | CLANG_WARN_INFINITE_RECURSION = YES; 292 | CLANG_WARN_INT_CONVERSION = YES; 293 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 294 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 295 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 296 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 297 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 298 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 299 | CLANG_WARN_STRICT_PROTOTYPES = YES; 300 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 301 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 302 | CLANG_WARN_UNREACHABLE_CODE = YES; 303 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 304 | COPY_PHASE_STRIP = NO; 305 | DEBUG_INFORMATION_FORMAT = dwarf; 306 | ENABLE_STRICT_OBJC_MSGSEND = YES; 307 | ENABLE_TESTABILITY = YES; 308 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 309 | GCC_C_LANGUAGE_STANDARD = gnu17; 310 | GCC_DYNAMIC_NO_PIC = NO; 311 | GCC_NO_COMMON_BLOCKS = YES; 312 | GCC_OPTIMIZATION_LEVEL = 0; 313 | GCC_PREPROCESSOR_DEFINITIONS = ( 314 | "DEBUG=1", 315 | "$(inherited)", 316 | ); 317 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 318 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 319 | GCC_WARN_UNDECLARED_SELECTOR = YES; 320 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 321 | GCC_WARN_UNUSED_FUNCTION = YES; 322 | GCC_WARN_UNUSED_VARIABLE = YES; 323 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 324 | MACOSX_DEPLOYMENT_TARGET = 15.4; 325 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 326 | MTL_FAST_MATH = YES; 327 | ONLY_ACTIVE_ARCH = YES; 328 | SDKROOT = macosx; 329 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; 330 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 331 | }; 332 | name = Debug; 333 | }; 334 | C14568192DA2B5A1006C9032 /* Release */ = { 335 | isa = XCBuildConfiguration; 336 | buildSettings = { 337 | ALWAYS_SEARCH_USER_PATHS = NO; 338 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 339 | CLANG_ANALYZER_NONNULL = YES; 340 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 341 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 342 | CLANG_ENABLE_MODULES = YES; 343 | CLANG_ENABLE_OBJC_ARC = YES; 344 | CLANG_ENABLE_OBJC_WEAK = YES; 345 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 346 | CLANG_WARN_BOOL_CONVERSION = YES; 347 | CLANG_WARN_COMMA = YES; 348 | CLANG_WARN_CONSTANT_CONVERSION = YES; 349 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 350 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 351 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 352 | CLANG_WARN_EMPTY_BODY = YES; 353 | CLANG_WARN_ENUM_CONVERSION = YES; 354 | CLANG_WARN_INFINITE_RECURSION = YES; 355 | CLANG_WARN_INT_CONVERSION = YES; 356 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 357 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 358 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 359 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 360 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 361 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 362 | CLANG_WARN_STRICT_PROTOTYPES = YES; 363 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 364 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 365 | CLANG_WARN_UNREACHABLE_CODE = YES; 366 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 367 | COPY_PHASE_STRIP = NO; 368 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 369 | ENABLE_NS_ASSERTIONS = NO; 370 | ENABLE_STRICT_OBJC_MSGSEND = YES; 371 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 372 | GCC_C_LANGUAGE_STANDARD = gnu17; 373 | GCC_NO_COMMON_BLOCKS = YES; 374 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 375 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 376 | GCC_WARN_UNDECLARED_SELECTOR = YES; 377 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 378 | GCC_WARN_UNUSED_FUNCTION = YES; 379 | GCC_WARN_UNUSED_VARIABLE = YES; 380 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 381 | MACOSX_DEPLOYMENT_TARGET = 15.4; 382 | MTL_ENABLE_DEBUG_INFO = NO; 383 | MTL_FAST_MATH = YES; 384 | SDKROOT = macosx; 385 | SWIFT_COMPILATION_MODE = wholemodule; 386 | }; 387 | name = Release; 388 | }; 389 | C145681B2DA2B5A1006C9032 /* Debug */ = { 390 | isa = XCBuildConfiguration; 391 | buildSettings = { 392 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 393 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 394 | CODE_SIGN_ENTITLEMENTS = freescreenshot/freescreenshot.entitlements; 395 | CODE_SIGN_STYLE = Automatic; 396 | COMBINE_HIDPI_IMAGES = YES; 397 | CURRENT_PROJECT_VERSION = 1; 398 | ENABLE_PREVIEWS = YES; 399 | GENERATE_INFOPLIST_FILE = YES; 400 | INFOPLIST_KEY_NSHumanReadableCopyright = ""; 401 | LD_RUNPATH_SEARCH_PATHS = ( 402 | "$(inherited)", 403 | "@executable_path/../Frameworks", 404 | ); 405 | MARKETING_VERSION = 1.0; 406 | PRODUCT_BUNDLE_IDENTIFIER = samik.freescreenshot; 407 | PRODUCT_NAME = "$(TARGET_NAME)"; 408 | REGISTER_APP_GROUPS = YES; 409 | SWIFT_EMIT_LOC_STRINGS = YES; 410 | SWIFT_VERSION = 5.0; 411 | }; 412 | name = Debug; 413 | }; 414 | C145681C2DA2B5A1006C9032 /* Release */ = { 415 | isa = XCBuildConfiguration; 416 | buildSettings = { 417 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 418 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 419 | CODE_SIGN_ENTITLEMENTS = freescreenshot/freescreenshot.entitlements; 420 | CODE_SIGN_STYLE = Automatic; 421 | COMBINE_HIDPI_IMAGES = YES; 422 | CURRENT_PROJECT_VERSION = 1; 423 | ENABLE_PREVIEWS = YES; 424 | GENERATE_INFOPLIST_FILE = YES; 425 | INFOPLIST_KEY_NSHumanReadableCopyright = ""; 426 | LD_RUNPATH_SEARCH_PATHS = ( 427 | "$(inherited)", 428 | "@executable_path/../Frameworks", 429 | ); 430 | MARKETING_VERSION = 1.0; 431 | PRODUCT_BUNDLE_IDENTIFIER = samik.freescreenshot; 432 | PRODUCT_NAME = "$(TARGET_NAME)"; 433 | REGISTER_APP_GROUPS = YES; 434 | SWIFT_EMIT_LOC_STRINGS = YES; 435 | SWIFT_VERSION = 5.0; 436 | }; 437 | name = Release; 438 | }; 439 | C145681E2DA2B5A1006C9032 /* Debug */ = { 440 | isa = XCBuildConfiguration; 441 | buildSettings = { 442 | BUNDLE_LOADER = "$(TEST_HOST)"; 443 | CODE_SIGN_STYLE = Automatic; 444 | CURRENT_PROJECT_VERSION = 1; 445 | GENERATE_INFOPLIST_FILE = YES; 446 | MACOSX_DEPLOYMENT_TARGET = 15.4; 447 | MARKETING_VERSION = 1.0; 448 | PRODUCT_BUNDLE_IDENTIFIER = samik.freescreenshotTests; 449 | PRODUCT_NAME = "$(TARGET_NAME)"; 450 | SWIFT_EMIT_LOC_STRINGS = NO; 451 | SWIFT_VERSION = 5.0; 452 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/freescreenshot.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/freescreenshot"; 453 | }; 454 | name = Debug; 455 | }; 456 | C145681F2DA2B5A1006C9032 /* Release */ = { 457 | isa = XCBuildConfiguration; 458 | buildSettings = { 459 | BUNDLE_LOADER = "$(TEST_HOST)"; 460 | CODE_SIGN_STYLE = Automatic; 461 | CURRENT_PROJECT_VERSION = 1; 462 | GENERATE_INFOPLIST_FILE = YES; 463 | MACOSX_DEPLOYMENT_TARGET = 15.4; 464 | MARKETING_VERSION = 1.0; 465 | PRODUCT_BUNDLE_IDENTIFIER = samik.freescreenshotTests; 466 | PRODUCT_NAME = "$(TARGET_NAME)"; 467 | SWIFT_EMIT_LOC_STRINGS = NO; 468 | SWIFT_VERSION = 5.0; 469 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/freescreenshot.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/freescreenshot"; 470 | }; 471 | name = Release; 472 | }; 473 | C14568212DA2B5A1006C9032 /* Debug */ = { 474 | isa = XCBuildConfiguration; 475 | buildSettings = { 476 | CODE_SIGN_STYLE = Automatic; 477 | CURRENT_PROJECT_VERSION = 1; 478 | GENERATE_INFOPLIST_FILE = YES; 479 | MARKETING_VERSION = 1.0; 480 | PRODUCT_BUNDLE_IDENTIFIER = samik.freescreenshotUITests; 481 | PRODUCT_NAME = "$(TARGET_NAME)"; 482 | SWIFT_EMIT_LOC_STRINGS = NO; 483 | SWIFT_VERSION = 5.0; 484 | TEST_TARGET_NAME = freescreenshot; 485 | }; 486 | name = Debug; 487 | }; 488 | C14568222DA2B5A1006C9032 /* Release */ = { 489 | isa = XCBuildConfiguration; 490 | buildSettings = { 491 | CODE_SIGN_STYLE = Automatic; 492 | CURRENT_PROJECT_VERSION = 1; 493 | GENERATE_INFOPLIST_FILE = YES; 494 | MARKETING_VERSION = 1.0; 495 | PRODUCT_BUNDLE_IDENTIFIER = samik.freescreenshotUITests; 496 | PRODUCT_NAME = "$(TARGET_NAME)"; 497 | SWIFT_EMIT_LOC_STRINGS = NO; 498 | SWIFT_VERSION = 5.0; 499 | TEST_TARGET_NAME = freescreenshot; 500 | }; 501 | name = Release; 502 | }; 503 | /* End XCBuildConfiguration section */ 504 | 505 | /* Begin XCConfigurationList section */ 506 | C14567F32DA2B57E006C9032 /* Build configuration list for PBXProject "freescreenshot" */ = { 507 | isa = XCConfigurationList; 508 | buildConfigurations = ( 509 | C14568182DA2B5A1006C9032 /* Debug */, 510 | C14568192DA2B5A1006C9032 /* Release */, 511 | ); 512 | defaultConfigurationIsVisible = 0; 513 | defaultConfigurationName = Release; 514 | }; 515 | C145681A2DA2B5A1006C9032 /* Build configuration list for PBXNativeTarget "freescreenshot" */ = { 516 | isa = XCConfigurationList; 517 | buildConfigurations = ( 518 | C145681B2DA2B5A1006C9032 /* Debug */, 519 | C145681C2DA2B5A1006C9032 /* Release */, 520 | ); 521 | defaultConfigurationIsVisible = 0; 522 | defaultConfigurationName = Release; 523 | }; 524 | C145681D2DA2B5A1006C9032 /* Build configuration list for PBXNativeTarget "freescreenshotTests" */ = { 525 | isa = XCConfigurationList; 526 | buildConfigurations = ( 527 | C145681E2DA2B5A1006C9032 /* Debug */, 528 | C145681F2DA2B5A1006C9032 /* Release */, 529 | ); 530 | defaultConfigurationIsVisible = 0; 531 | defaultConfigurationName = Release; 532 | }; 533 | C14568202DA2B5A1006C9032 /* Build configuration list for PBXNativeTarget "freescreenshotUITests" */ = { 534 | isa = XCConfigurationList; 535 | buildConfigurations = ( 536 | C14568212DA2B5A1006C9032 /* Debug */, 537 | C14568222DA2B5A1006C9032 /* Release */, 538 | ); 539 | defaultConfigurationIsVisible = 0; 540 | defaultConfigurationName = Release; 541 | }; 542 | /* End XCConfigurationList section */ 543 | 544 | /* Begin XCRemoteSwiftPackageReference section */ 545 | C14568352DA2BBBD006C9032 /* XCRemoteSwiftPackageReference "HotKey" */ = { 546 | isa = XCRemoteSwiftPackageReference; 547 | repositoryURL = "https://github.com/soffes/HotKey"; 548 | requirement = { 549 | branch = main; 550 | kind = branch; 551 | }; 552 | }; 553 | /* End XCRemoteSwiftPackageReference section */ 554 | 555 | /* Begin XCSwiftPackageProductDependency section */ 556 | C14568372DA2C128006C9032 /* HotKey */ = { 557 | isa = XCSwiftPackageProductDependency; 558 | package = C14568352DA2BBBD006C9032 /* XCRemoteSwiftPackageReference "HotKey" */; 559 | productName = HotKey; 560 | }; 561 | /* End XCSwiftPackageProductDependency section */ 562 | }; 563 | rootObject = C14567F02DA2B57E006C9032 /* Project object */; 564 | } 565 | -------------------------------------------------------------------------------- /freescreenshot.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /freescreenshot.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "01f286328852d165d87f8fbee99b01f9c2c936f57ac234f76396f74f9d2496dd", 3 | "pins" : [ 4 | { 5 | "identity" : "hotkey", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/soffes/HotKey", 8 | "state" : { 9 | "branch" : "main", 10 | "revision" : "a3cf605d7a96f6ff50e04fcb6dea6e2613cfcbe4" 11 | } 12 | } 13 | ], 14 | "version" : 3 15 | } 16 | -------------------------------------------------------------------------------- /freescreenshot.xcodeproj/xcuserdata/licofis.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | freescreenshot.xcscheme_^#shared#^_ 8 | 9 | orderHint 10 | 0 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /freescreenshot/AppDelegate+Configuration.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate+Configuration.swift 3 | // freescreenshot 4 | // 5 | // Created by Samik Choudhury on 06/04/25. 6 | // 7 | 8 | import Cocoa 9 | 10 | /** 11 | * Extension for handling app permissions persistently 12 | */ 13 | extension AppDelegate { 14 | 15 | /** 16 | * Saves the current permissions state to UserDefaults 17 | * This helps track if permissions were previously granted 18 | */ 19 | func savePermissionState(granted: Bool) { 20 | UserDefaults.standard.set(granted, forKey: "ScreenCapturePermissionGranted") 21 | UserDefaults.standard.synchronize() 22 | } 23 | 24 | /** 25 | * Checks if permissions were previously granted 26 | */ 27 | func wasPermissionPreviouslyGranted() -> Bool { 28 | return UserDefaults.standard.bool(forKey: "ScreenCapturePermissionGranted") 29 | } 30 | 31 | /** 32 | * Ensures screen capture permission is properly saved in TCC database 33 | * This function takes a more robust approach to macOS permissions 34 | */ 35 | func ensureScreenCapturePermission() { 36 | // Check current permission status 37 | let screenCaptureAccess = CGPreflightScreenCaptureAccess() 38 | 39 | if !screenCaptureAccess { 40 | // We don't have permission - need to request it 41 | showPermissionAlert() 42 | } else { 43 | print("Screen capture access is already granted") 44 | 45 | // Even with permission, we need to verify it's working correctly 46 | // Schedule a test capture after a short delay 47 | DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { 48 | self.verifyPermissionWorks() 49 | } 50 | } 51 | } 52 | 53 | /** 54 | * Shows an alert explaining the permission requirements 55 | */ 56 | private func showPermissionAlert() { 57 | let alert = NSAlert() 58 | alert.messageText = "Screen Recording Permission Required" 59 | alert.informativeText = "FreeScreenshot needs screen recording permission to capture screenshots. You'll be prompted to grant this permission in System Settings.\n\nIf you've already granted permission but still see this message, you may need to manually add this application in System Settings > Privacy & Security > Screen Recording." 60 | alert.addButton(withTitle: "Continue") 61 | alert.addButton(withTitle: "Open System Settings") 62 | 63 | let response = alert.runModal() 64 | 65 | if response == .alertFirstButtonReturn { 66 | // User chose to continue - request the permission 67 | requestPermission() 68 | } else { 69 | // User chose to open System Settings 70 | openScreenRecordingPreferences() 71 | } 72 | } 73 | 74 | /** 75 | * Requests screen recording permission from macOS 76 | */ 77 | private func requestPermission() { 78 | let granted = CGRequestScreenCaptureAccess() 79 | print("Screen capture access requested: \(granted)") 80 | 81 | if granted { 82 | // Permission was granted - verify it works after a short delay 83 | DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { 84 | self.verifyPermissionWorks() 85 | } 86 | } else { 87 | // Permission was denied - show another alert 88 | DispatchQueue.main.async { 89 | self.showPermissionDeniedAlert() 90 | } 91 | } 92 | } 93 | 94 | /** 95 | * Shows an alert if permission was denied 96 | */ 97 | private func showPermissionDeniedAlert() { 98 | let alert = NSAlert() 99 | alert.messageText = "Permission Denied" 100 | alert.informativeText = "FreeScreenshot cannot function without screen recording permission. Please enable it in System Settings > Privacy & Security > Screen Recording." 101 | alert.addButton(withTitle: "Open System Settings") 102 | alert.addButton(withTitle: "Later") 103 | 104 | let response = alert.runModal() 105 | if response == .alertFirstButtonReturn { 106 | openScreenRecordingPreferences() 107 | } 108 | } 109 | 110 | /** 111 | * Verifies that the permission actually works by trying a screen capture 112 | */ 113 | private func verifyPermissionWorks() { 114 | // Use the native screencapture tool to test if permission is working 115 | let tempFilePath = NSTemporaryDirectory() + "permission_test.png" 116 | let tempFileURL = URL(fileURLWithPath: tempFilePath) 117 | 118 | let task = Process() 119 | task.launchPath = "/usr/sbin/screencapture" 120 | 121 | // Capture a small region of the screen to minimize disruption 122 | // -R: region (x,y,width,height) 123 | // -x: no sound 124 | task.arguments = ["-R", "0,0,1,1", "-x", tempFilePath] 125 | 126 | do { 127 | try task.run() 128 | task.waitUntilExit() 129 | 130 | // Check if the file exists and has a size 131 | if FileManager.default.fileExists(atPath: tempFilePath) { 132 | let fileSize = try FileManager.default.attributesOfItem(atPath: tempFilePath)[.size] as? NSNumber ?? 0 133 | 134 | if fileSize.intValue > 0 { 135 | print("Permission verified: successfully captured screen") 136 | 137 | // Clean up the temporary file 138 | try? FileManager.default.removeItem(at: tempFileURL) 139 | } else { 140 | print("Permission issue: capture file exists but is empty") 141 | showPermissionIssueAlert() 142 | } 143 | } else { 144 | print("Permission issue: failed to create capture file") 145 | showPermissionIssueAlert() 146 | } 147 | } catch { 148 | print("Error testing permission: \(error)") 149 | showPermissionIssueAlert() 150 | } 151 | } 152 | 153 | /** 154 | * Shows an alert if permission was granted but doesn't seem to work 155 | */ 156 | private func showPermissionIssueAlert() { 157 | let alert = NSAlert() 158 | alert.messageText = "Permission Issue Detected" 159 | alert.informativeText = "The app has permission to capture your screen, but the capture doesn't seem to be working correctly. This might be fixed by:\n\n1. Removing FreeScreenshot from Screen Recording in System Settings, then adding it back\n2. Restarting your Mac\n3. Reinstalling the application" 160 | alert.addButton(withTitle: "Open System Settings") 161 | alert.addButton(withTitle: "Later") 162 | 163 | let response = alert.runModal() 164 | if response == .alertFirstButtonReturn { 165 | openScreenRecordingPreferences() 166 | } 167 | } 168 | 169 | /** 170 | * Opens System Settings to the Screen Recording privacy settings 171 | */ 172 | private func openScreenRecordingPreferences() { 173 | // Modern URL scheme for macOS 13+ 174 | var url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture")! 175 | 176 | // Fallback for older macOS versions 177 | if #available(macOS 13.0, *) { 178 | // Using modern URL scheme 179 | } else { 180 | // Older URL scheme 181 | let prefPane = "com.apple.preference.security" 182 | url = URL(string: "x-apple.systempreferences:\(prefPane)")! 183 | } 184 | 185 | NSWorkspace.shared.open(url) 186 | } 187 | } -------------------------------------------------------------------------------- /freescreenshot/Assets.xcassets/AppIcon.appiconset/1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/proSamik/freescreenshot/8d47739c4436c96eb1743e1e6a40b7c06596db0d/freescreenshot/Assets.xcassets/AppIcon.appiconset/1024.png -------------------------------------------------------------------------------- /freescreenshot/Assets.xcassets/AppIcon.appiconset/128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/proSamik/freescreenshot/8d47739c4436c96eb1743e1e6a40b7c06596db0d/freescreenshot/Assets.xcassets/AppIcon.appiconset/128.png -------------------------------------------------------------------------------- /freescreenshot/Assets.xcassets/AppIcon.appiconset/16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/proSamik/freescreenshot/8d47739c4436c96eb1743e1e6a40b7c06596db0d/freescreenshot/Assets.xcassets/AppIcon.appiconset/16.png -------------------------------------------------------------------------------- /freescreenshot/Assets.xcassets/AppIcon.appiconset/256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/proSamik/freescreenshot/8d47739c4436c96eb1743e1e6a40b7c06596db0d/freescreenshot/Assets.xcassets/AppIcon.appiconset/256.png -------------------------------------------------------------------------------- /freescreenshot/Assets.xcassets/AppIcon.appiconset/32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/proSamik/freescreenshot/8d47739c4436c96eb1743e1e6a40b7c06596db0d/freescreenshot/Assets.xcassets/AppIcon.appiconset/32.png -------------------------------------------------------------------------------- /freescreenshot/Assets.xcassets/AppIcon.appiconset/512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/proSamik/freescreenshot/8d47739c4436c96eb1743e1e6a40b7c06596db0d/freescreenshot/Assets.xcassets/AppIcon.appiconset/512.png -------------------------------------------------------------------------------- /freescreenshot/Assets.xcassets/AppIcon.appiconset/64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/proSamik/freescreenshot/8d47739c4436c96eb1743e1e6a40b7c06596db0d/freescreenshot/Assets.xcassets/AppIcon.appiconset/64.png -------------------------------------------------------------------------------- /freescreenshot/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | {"images":[{"size":"128x128","expected-size":"128","filename":"128.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"1x"},{"size":"256x256","expected-size":"256","filename":"256.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"1x"},{"size":"128x128","expected-size":"256","filename":"256.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"2x"},{"size":"256x256","expected-size":"512","filename":"512.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"2x"},{"size":"32x32","expected-size":"32","filename":"32.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"1x"},{"size":"512x512","expected-size":"512","filename":"512.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"1x"},{"size":"16x16","expected-size":"16","filename":"16.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"1x"},{"size":"16x16","expected-size":"32","filename":"32.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"2x"},{"size":"32x32","expected-size":"64","filename":"64.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"2x"},{"size":"512x512","expected-size":"1024","filename":"1024.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"2x"}]} -------------------------------------------------------------------------------- /freescreenshot/ContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // freescreenshot 4 | // 5 | // Created by Samik Choudhury on 06/04/25. 6 | // 7 | 8 | import SwiftUI 9 | import UniformTypeIdentifiers 10 | 11 | /** 12 | * ContentView: Main view of the application 13 | * Handles transitions between welcome screen and editor view 14 | */ 15 | struct ContentView: View { 16 | @EnvironmentObject private var appState: AppState 17 | @StateObject private var editorViewModel = EditorViewModel() 18 | @State private var isDropTargeted = false 19 | @State private var isScreenCaptureInProgress = false 20 | @State private var isBackgroundPickerPresented = false 21 | @State private var isDragOver = false 22 | 23 | var body: some View { 24 | ZStack { 25 | // Welcome screen when no image is captured 26 | if !appState.isEditorOpen { 27 | welcomeView 28 | } else { 29 | // Editor view when an image is available 30 | if let image = appState.capturedImage { 31 | EditorView(viewModel: editorViewModel) 32 | .onAppear { 33 | editorViewModel.setImage(image) 34 | } 35 | } 36 | } 37 | } 38 | .frame(minWidth: 600, minHeight: 800) 39 | } 40 | 41 | /** 42 | * Welcome screen view 43 | */ 44 | private var welcomeView: some View { 45 | VStack(spacing: 30) { 46 | Image(systemName: "camera.viewfinder") 47 | .font(.system(size: 80)) 48 | .foregroundColor(.accentColor) 49 | .padding(.top, 40) 50 | 51 | Text("Free Screenshot") 52 | .font(.largeTitle) 53 | .fontWeight(.bold) 54 | 55 | Text("Transform your screenshots into stunning visuals") 56 | .font(.title3) 57 | .foregroundColor(.secondary) 58 | .padding(.bottom, 10) 59 | 60 | // Drag & Drop Zone 61 | dropZoneView 62 | .padding(.vertical, 30) 63 | 64 | VStack(alignment: .leading, spacing: 15) { 65 | instructionRow(icon: "keyboard", text: "Press Cmd+Shift+7 to capture a screenshot") 66 | instructionRow(icon: "wand.and.stars", text: "Add backgrounds, arrows, text, and effects") 67 | instructionRow(icon: "square.and.arrow.up", text: "Export your enhanced screenshot") 68 | } 69 | .padding(.horizontal, 30) 70 | .padding(.bottom, 30) 71 | 72 | Button(action: { 73 | appState.initiateScreenCapture() 74 | }) { 75 | Text("Take Screenshot") 76 | .fontWeight(.semibold) 77 | .padding(.horizontal, 24) 78 | .padding(.vertical, 12) 79 | .background(Color.accentColor) 80 | .foregroundColor(.white) 81 | .cornerRadius(10) 82 | } 83 | .buttonStyle(.plain) 84 | .keyboardShortcut("7", modifiers: [.command, .shift]) 85 | .padding(.bottom, 40) 86 | } 87 | .frame(maxWidth: .infinity, maxHeight: .infinity) 88 | .background(Color(NSColor.windowBackgroundColor)) 89 | } 90 | 91 | /** 92 | * Drop zone for image files 93 | */ 94 | private var dropZoneView: some View { 95 | VStack { 96 | Image(systemName: "arrow.down.doc.fill") 97 | .font(.system(size: 30)) 98 | .foregroundColor(isDropTargeted ? .accentColor : .secondary) 99 | .padding(.bottom, 8) 100 | 101 | Text("Drag & Drop Image Here") 102 | .font(.headline) 103 | .foregroundColor(isDropTargeted ? .accentColor : .primary) 104 | } 105 | .frame(width: 300, height: 140) 106 | .background( 107 | RoundedRectangle(cornerRadius: 12) 108 | .stroke(isDropTargeted ? Color.accentColor : Color.secondary.opacity(0.5), 109 | style: StrokeStyle(lineWidth: 2, dash: [5])) 110 | .background(isDropTargeted ? Color.accentColor.opacity(0.1) : Color.secondary.opacity(0.05)) 111 | .cornerRadius(12) 112 | ) 113 | .onDrop(of: [UTType.fileURL.identifier], isTargeted: $isDropTargeted) { providers, _ in 114 | handleDrop(providers: providers) 115 | return true 116 | } 117 | } 118 | 119 | /** 120 | * Helper function to create consistent instruction rows 121 | */ 122 | private func instructionRow(icon: String, text: String) -> some View { 123 | HStack(spacing: 12) { 124 | Image(systemName: icon) 125 | .font(.system(size: 16)) 126 | .frame(width: 28, height: 28) 127 | .foregroundColor(.accentColor) 128 | 129 | Text(text) 130 | .font(.body) 131 | } 132 | } 133 | 134 | /** 135 | * Handles file drop for image import 136 | */ 137 | private func handleDrop(providers: [NSItemProvider]) { 138 | guard let provider = providers.first else { return } 139 | 140 | if provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) { 141 | provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier, options: nil) { (urlData, error) in 142 | if let urlData = urlData as? Data, 143 | let url = URL(dataRepresentation: urlData, relativeTo: nil), 144 | ["jpg", "jpeg", "png", "gif", "tiff", "bmp"].contains(url.pathExtension.lowercased()) { 145 | DispatchQueue.main.async { 146 | if let image = NSImage(contentsOf: url) { 147 | self.appState.capturedImage = image 148 | self.appState.isEditorOpen = true 149 | } 150 | } 151 | } 152 | } 153 | } 154 | } 155 | 156 | /** 157 | * Initiates the screenshot process 158 | */ 159 | private func initiateScreenshot() { 160 | isScreenCaptureInProgress = true 161 | 162 | // Use the appState to initiate screen capture 163 | appState.initiateScreenCapture() 164 | isScreenCaptureInProgress = false 165 | } 166 | 167 | /** 168 | * Saves the screenshot to disk 169 | */ 170 | private func saveScreenshot() { 171 | let savePanel = NSSavePanel() 172 | savePanel.allowedContentTypes = [.png, .jpeg] 173 | savePanel.canCreateDirectories = true 174 | savePanel.isExtensionHidden = false 175 | savePanel.title = "Save Screenshot" 176 | savePanel.message = "Choose a location to save your screenshot" 177 | savePanel.nameFieldLabel = "File name:" 178 | 179 | if savePanel.runModal() == .OK { 180 | if let url = savePanel.url { 181 | self.editorViewModel.saveImage(to: url) 182 | } 183 | } 184 | } 185 | } 186 | 187 | struct ContentView_Previews: PreviewProvider { 188 | static var previews: some View { 189 | ContentView() 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /freescreenshot/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | FreeScreenshot 9 | CFBundleIconFile 10 | AppIcon 11 | CFBundleIdentifier 12 | com.prosamik.freescreenshot 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | FreeScreenshot 17 | CFBundlePackageType 18 | APPL 19 | CFBundleShortVersionString 20 | 1.0 21 | CFBundleVersion 22 | 1 23 | LSMinimumSystemVersion 24 | 12.0 25 | NSHumanReadableCopyright 26 | Copyright © 2025 Samik Choudhury. All rights reserved. 27 | NSPrincipalClass 28 | NSApplication 29 | LSApplicationCategoryType 30 | public.app-category.graphics-design 31 | NSCameraUsageDescription 32 | FreeScreenshot needs access to your camera to capture screenshots. 33 | NSScreenCaptureUsageDescription 34 | FreeScreenshot needs access to screen recording to capture screenshots. 35 | NSPhotoLibraryUsageDescription 36 | FreeScreenshot needs access to your photos to save screenshots. 37 | LSUIElement 38 | 39 | NSSupportsAutomaticTermination 40 | 41 | NSSupportsSuddenTermination 42 | 43 | NSAppleEventsUsageDescription 44 | FreeScreenshot needs to interact with other apps to perform screenshot capture. 45 | LSMultipleInstancesProhibited 46 | 47 | NSPrefPaneSpecificationKey 48 | FreeScreenshot 49 | NSHighResolutionCapable 50 | 51 | NSMainStoryboardFile 52 | Main 53 | NSAppTransportSecurity 54 | 55 | NSAllowsArbitraryLoads 56 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /freescreenshot/Models/EditorModels.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EditorModels.swift 3 | // freescreenshot 4 | // 5 | // Created by Samik Choudhury on 06/04/25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /** 11 | * EditingTool: Represents the various editing tools available in the editor 12 | */ 13 | enum EditingTool: String, CaseIterable, Identifiable { 14 | case select 15 | case arrow 16 | case text 17 | case highlighter 18 | case boxShadow 19 | case glassEffect 20 | 21 | var id: String { self.rawValue } 22 | 23 | /** 24 | * Returns the icon name for the tool 25 | */ 26 | var iconName: String { 27 | switch self { 28 | case .select: return "arrow.up.left.and.arrow.down.right" 29 | case .arrow: return "arrow.up.right" 30 | case .text: return "textformat" 31 | case .highlighter: return "highlighter" 32 | case .boxShadow: return "rectangle.fill" 33 | case .glassEffect: return "circle.dotted" 34 | } 35 | } 36 | 37 | /** 38 | * Returns the display name for the tool 39 | */ 40 | var displayName: String { 41 | switch self { 42 | case .select: return "Select" 43 | case .arrow: return "Arrow" 44 | case .text: return "Text" 45 | case .highlighter: return "Highlight" 46 | case .boxShadow: return "Box Shadow" 47 | case .glassEffect: return "Glass Effect" 48 | } 49 | } 50 | } 51 | 52 | /** 53 | * ArrowStyle: Represents the styles available for arrows 54 | */ 55 | enum ArrowStyle: String, CaseIterable, Identifiable { 56 | case straight 57 | case curved 58 | case spiral 59 | case bent 60 | 61 | var id: String { self.rawValue } 62 | } 63 | 64 | /** 65 | * BackgroundType: Represents the types of backgrounds that can be applied 66 | */ 67 | enum BackgroundType: String, CaseIterable, Identifiable { 68 | case solid 69 | case gradient 70 | case image 71 | case none 72 | 73 | var id: String { self.rawValue } 74 | 75 | /** 76 | * Returns the display name for the background type 77 | */ 78 | var displayName: String { 79 | switch self { 80 | case .solid: return "Solid Color" 81 | case .gradient: return "Gradient" 82 | case .image: return "Image" 83 | case .none: return "None" 84 | } 85 | } 86 | } 87 | 88 | /** 89 | * EditableElement: Base protocol for all editable elements in the editor 90 | */ 91 | protocol EditableElement: Identifiable { 92 | var id: UUID { get } 93 | var position: CGPoint { get set } 94 | var rotation: Angle { get set } 95 | var scale: CGFloat { get set } 96 | 97 | associatedtype ViewType: View 98 | func render() -> ViewType 99 | } 100 | 101 | /** 102 | * TextElement: Represents a text annotation in the editor 103 | */ 104 | struct TextElement: EditableElement { 105 | var id = UUID() 106 | var position: CGPoint 107 | var rotation: Angle = .zero 108 | var scale: CGFloat = 1.0 109 | var text: String 110 | var fontSize: CGFloat = 16 111 | var fontColor: Color = .black 112 | var fontWeight: Font.Weight = .regular 113 | var fontStyle: Font.Design = .default 114 | 115 | /** 116 | * Renders the text element in the editor 117 | */ 118 | func render() -> some View { 119 | Text(text) 120 | .font(.system(size: fontSize, weight: fontWeight, design: fontStyle)) 121 | .foregroundColor(fontColor) 122 | .position(position) 123 | .rotationEffect(rotation) 124 | .scaleEffect(scale) 125 | } 126 | } 127 | 128 | /** 129 | * ArrowElement: Represents an arrow annotation in the editor 130 | */ 131 | struct ArrowElement: EditableElement { 132 | var id = UUID() 133 | var position: CGPoint 134 | var rotation: Angle = .zero 135 | var scale: CGFloat = 1.0 136 | var startPoint: CGPoint 137 | var endPoint: CGPoint 138 | var style: ArrowStyle = .straight 139 | var strokeWidth: CGFloat = 2 140 | var color: Color = .black 141 | 142 | /** 143 | * Renders the arrow element in the editor 144 | */ 145 | func render() -> some View { 146 | ArrowShape(start: startPoint, end: endPoint, style: style) 147 | .stroke(color, lineWidth: strokeWidth) 148 | .position(position) 149 | .rotationEffect(rotation) 150 | .scaleEffect(scale) 151 | } 152 | } 153 | 154 | /** 155 | * HighlighterElement: Represents a highlighter annotation in the editor 156 | */ 157 | struct HighlighterElement: EditableElement { 158 | var id = UUID() 159 | var position: CGPoint 160 | var rotation: Angle = .zero 161 | var scale: CGFloat = 1.0 162 | var points: [CGPoint] 163 | var color: Color = .yellow 164 | var opacity: Double = 0.5 165 | var lineWidth: CGFloat = 10 166 | 167 | /** 168 | * Renders the highlighter element in the editor 169 | */ 170 | func render() -> some View { 171 | Path { path in 172 | guard let firstPoint = points.first else { return } 173 | path.move(to: firstPoint) 174 | for point in points.dropFirst() { 175 | path.addLine(to: point) 176 | } 177 | } 178 | .stroke(color.opacity(opacity), lineWidth: lineWidth) 179 | .position(position) 180 | .rotationEffect(rotation) 181 | .scaleEffect(scale) 182 | } 183 | } 184 | 185 | /** 186 | * BoxShadowElement: Represents a box shadow effect in the editor 187 | */ 188 | struct BoxShadowElement: EditableElement { 189 | var id = UUID() 190 | var position: CGPoint 191 | var rotation: Angle = .zero 192 | var scale: CGFloat = 1.0 193 | var rect: CGRect 194 | var shadowRadius: CGFloat = 10 195 | var shadowColor: Color = .black 196 | var shadowOpacity: Double = 0.5 197 | 198 | /** 199 | * Renders the box shadow element in the editor 200 | */ 201 | func render() -> some View { 202 | ZStack { 203 | // Darkened background with a hole for the highlighted area 204 | Rectangle() 205 | .fill(Color.black.opacity(shadowOpacity)) 206 | .mask( 207 | Rectangle() 208 | .fill(Color.black) 209 | .overlay( 210 | Rectangle() 211 | .frame(width: rect.width, height: rect.height) 212 | .position(x: rect.midX, y: rect.midY) 213 | .blendMode(.destinationOut) 214 | ) 215 | ) 216 | 217 | // Border around the highlighted area 218 | Rectangle() 219 | .frame(width: rect.width, height: rect.height) 220 | .position(x: rect.midX, y: rect.midY) 221 | .overlay( 222 | Rectangle() 223 | .stroke(Color.white, lineWidth: 2) 224 | ) 225 | .shadow(color: shadowColor, radius: shadowRadius) 226 | } 227 | .position(position) 228 | .rotationEffect(rotation) 229 | .scaleEffect(scale) 230 | } 231 | } 232 | 233 | /** 234 | * GlassEffectElement: Represents a glass blur effect in the editor 235 | */ 236 | struct GlassEffectElement: EditableElement { 237 | var id = UUID() 238 | var position: CGPoint 239 | var rotation: Angle = .zero 240 | var scale: CGFloat = 1.0 241 | var rect: CGRect 242 | var blurRadius: CGFloat = 10 243 | 244 | /** 245 | * Renders the glass effect element in the editor 246 | */ 247 | func render() -> some View { 248 | Rectangle() 249 | .frame(width: rect.width, height: rect.height) 250 | .position(x: rect.midX, y: rect.midY) 251 | .blur(radius: blurRadius) 252 | .background(Color.white.opacity(0.1)) 253 | .cornerRadius(8) 254 | .position(position) 255 | .rotationEffect(rotation) 256 | .scaleEffect(scale) 257 | } 258 | } 259 | 260 | /** 261 | * ArrowShape: Custom shape for drawing different arrow styles 262 | */ 263 | struct ArrowShape: Shape { 264 | var start: CGPoint 265 | var end: CGPoint 266 | var style: ArrowStyle 267 | 268 | /** 269 | * Draws the path for the arrow based on its style 270 | */ 271 | func path(in rect: CGRect) -> Path { 272 | var path = Path() 273 | 274 | switch style { 275 | case .straight: 276 | path.move(to: start) 277 | path.addLine(to: end) 278 | 279 | // Add arrowhead 280 | let angle = atan2(end.y - start.y, end.x - start.x) 281 | let arrowLength: CGFloat = 15 282 | let arrowAngle: CGFloat = .pi / 6 // 30 degrees 283 | 284 | let arrowPoint1 = CGPoint( 285 | x: end.x - arrowLength * cos(angle - arrowAngle), 286 | y: end.y - arrowLength * sin(angle - arrowAngle) 287 | ) 288 | 289 | let arrowPoint2 = CGPoint( 290 | x: end.x - arrowLength * cos(angle + arrowAngle), 291 | y: end.y - arrowLength * sin(angle + arrowAngle) 292 | ) 293 | 294 | path.move(to: end) 295 | path.addLine(to: arrowPoint1) 296 | path.move(to: end) 297 | path.addLine(to: arrowPoint2) 298 | 299 | case .curved: 300 | let control = CGPoint( 301 | x: start.x, 302 | y: end.y 303 | ) 304 | 305 | path.move(to: start) 306 | path.addQuadCurve(to: end, control: control) 307 | 308 | // Add arrowhead to curved line 309 | let arrowLength: CGFloat = 15 310 | let arrowAngle: CGFloat = .pi / 6 311 | 312 | // Calculate tangent at the end point 313 | let tangentX = end.x - control.x 314 | let tangentY = end.y - control.y 315 | let angle = atan2(tangentY, tangentX) 316 | 317 | let arrowPoint1 = CGPoint( 318 | x: end.x - arrowLength * cos(angle - arrowAngle), 319 | y: end.y - arrowLength * sin(angle - arrowAngle) 320 | ) 321 | 322 | let arrowPoint2 = CGPoint( 323 | x: end.x - arrowLength * cos(angle + arrowAngle), 324 | y: end.y - arrowLength * sin(angle + arrowAngle) 325 | ) 326 | 327 | path.move(to: end) 328 | path.addLine(to: arrowPoint1) 329 | path.move(to: end) 330 | path.addLine(to: arrowPoint2) 331 | 332 | case .spiral: 333 | let distance = sqrt(pow(end.x - start.x, 2) + pow(end.y - start.y, 2)) 334 | let revolutions = distance / 100 335 | let steps = 50 336 | 337 | path.move(to: start) 338 | 339 | for i in 1...steps { 340 | let t = CGFloat(i) / CGFloat(steps) 341 | let radius = t * distance / 2 342 | let angle = t * revolutions * 2 * .pi 343 | 344 | let x = start.x + (end.x - start.x) * t + radius * cos(angle) 345 | let y = start.y + (end.y - start.y) * t + radius * sin(angle) 346 | 347 | path.addLine(to: CGPoint(x: x, y: y)) 348 | } 349 | 350 | // Add arrowhead 351 | let lastPoint = CGPoint( 352 | x: start.x + (end.x - start.x) + (distance / 2) * cos(revolutions * 2 * .pi), 353 | y: start.y + (end.y - start.y) + (distance / 2) * sin(revolutions * 2 * .pi) 354 | ) 355 | 356 | let angle = atan2(end.y - lastPoint.y, end.x - lastPoint.x) 357 | let arrowLength: CGFloat = 15 358 | let arrowAngle: CGFloat = .pi / 6 359 | 360 | let arrowPoint1 = CGPoint( 361 | x: end.x - arrowLength * cos(angle - arrowAngle), 362 | y: end.y - arrowLength * sin(angle - arrowAngle) 363 | ) 364 | 365 | let arrowPoint2 = CGPoint( 366 | x: end.x - arrowLength * cos(angle + arrowAngle), 367 | y: end.y - arrowLength * sin(angle + arrowAngle) 368 | ) 369 | 370 | path.move(to: end) 371 | path.addLine(to: arrowPoint1) 372 | path.move(to: end) 373 | path.addLine(to: arrowPoint2) 374 | 375 | case .bent: 376 | let midX = (start.x + end.x) / 2 377 | let _ = (start.y + end.y) / 2 378 | 379 | let controlPoint1 = CGPoint(x: midX, y: start.y) 380 | let controlPoint2 = CGPoint(x: midX, y: end.y) 381 | 382 | path.move(to: start) 383 | path.addCurve(to: end, control1: controlPoint1, control2: controlPoint2) 384 | 385 | // Add arrowhead to bent line 386 | let tangentX = end.x - controlPoint2.x 387 | let tangentY = end.y - controlPoint2.y 388 | let angle = atan2(tangentY, tangentX) 389 | 390 | let arrowLength: CGFloat = 15 391 | let arrowAngle: CGFloat = .pi / 6 392 | 393 | let arrowPoint1 = CGPoint( 394 | x: end.x - arrowLength * cos(angle - arrowAngle), 395 | y: end.y - arrowLength * sin(angle - arrowAngle) 396 | ) 397 | 398 | let arrowPoint2 = CGPoint( 399 | x: end.x - arrowLength * cos(angle + arrowAngle), 400 | y: end.y - arrowLength * sin(angle + arrowAngle) 401 | ) 402 | 403 | path.move(to: end) 404 | path.addLine(to: arrowPoint1) 405 | path.move(to: end) 406 | path.addLine(to: arrowPoint2) 407 | } 408 | 409 | return path 410 | } 411 | } -------------------------------------------------------------------------------- /freescreenshot/Utilities/ImageUtilities.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ImageUtilities.swift 3 | // freescreenshot 4 | // 5 | // Created by Samik Choudhury on 06/04/25. 6 | // 7 | 8 | import SwiftUI 9 | import Cocoa 10 | import UniformTypeIdentifiers 11 | 12 | /** 13 | * ImageUtilities: Contains utility functions for image processing 14 | */ 15 | class ImageUtilities { 16 | /** 17 | * Loads an image from the clipboard if available 18 | * Returns nil if no valid image is in the clipboard 19 | */ 20 | static func loadImageFromClipboard() -> NSImage? { 21 | let pasteboard = NSPasteboard.general 22 | if let items = pasteboard.readObjects(forClasses: [NSImage.self], options: nil), 23 | let image = items.first as? NSImage { 24 | return image 25 | } 26 | return nil 27 | } 28 | 29 | /** 30 | * Saves an image to the clipboard 31 | */ 32 | static func saveImageToClipboard(_ image: NSImage) { 33 | let pasteboard = NSPasteboard.general 34 | pasteboard.clearContents() 35 | pasteboard.writeObjects([image]) 36 | } 37 | 38 | /** 39 | * Applies a 3D perspective transform to an image 40 | */ 41 | static func apply3DEffect(to image: NSImage, direction: Perspective3DDirection = .bottomRight, intensity: CGFloat = 0.2) -> NSImage? { 42 | // Create a larger result image to accommodate the transformed content 43 | let imageSize = image.size 44 | let padding = CGFloat(100) // Padding to prevent clipping 45 | let resultSize = CGSize(width: imageSize.width + padding*2, height: imageSize.height + padding*2) 46 | let resultImage = NSImage(size: resultSize) 47 | 48 | resultImage.lockFocus() 49 | 50 | // Clear the background 51 | NSColor.clear.setFill() 52 | NSRect(origin: .zero, size: resultSize).fill() 53 | 54 | // Create shadow based on direction 55 | let shadow = NSShadow() 56 | shadow.shadowColor = NSColor.black.withAlphaComponent(0.5) 57 | 58 | // Set shadow offset based on direction 59 | switch direction { 60 | case .topLeft: 61 | shadow.shadowOffset = NSSize(width: -20, height: 20) 62 | case .top: 63 | shadow.shadowOffset = NSSize(width: 0, height: 20) 64 | case .topRight: 65 | shadow.shadowOffset = NSSize(width: 20, height: 20) 66 | case .bottomLeft: 67 | shadow.shadowOffset = NSSize(width: -20, height: -20) 68 | case .bottom: 69 | shadow.shadowOffset = NSSize(width: 0, height: -20) 70 | case .bottomRight: 71 | shadow.shadowOffset = NSSize(width: 20, height: -20) 72 | } 73 | 74 | shadow.shadowBlurRadius = 15 75 | shadow.set() 76 | 77 | // Center the transform 78 | let transform = NSAffineTransform() 79 | transform.translateX(by: padding, yBy: padding) 80 | transform.concat() 81 | 82 | // Draw the image with perspective effect 83 | let path = NSBezierPath() 84 | 85 | // Define corner points based on image size 86 | let topLeft = NSPoint(x: 0, y: imageSize.height) 87 | let topRight = NSPoint(x: imageSize.width, y: imageSize.height) 88 | let bottomLeft = NSPoint(x: 0, y: 0) 89 | let bottomRight = NSPoint(x: imageSize.width, y: 0) 90 | 91 | // Calculate transformed corner points based on perspective direction 92 | var transformedTopLeft = topLeft 93 | var transformedTopRight = topRight 94 | var transformedBottomLeft = bottomLeft 95 | var transformedBottomRight = bottomRight 96 | 97 | let xOffset = imageSize.width * intensity 98 | let yOffset = imageSize.height * intensity 99 | 100 | switch direction { 101 | case .topLeft: 102 | transformedTopLeft = NSPoint(x: xOffset, y: imageSize.height) 103 | transformedBottomLeft = NSPoint(x: xOffset, y: 0) 104 | transformedTopRight = NSPoint(x: imageSize.width, y: imageSize.height - yOffset) 105 | case .top: 106 | transformedTopLeft = NSPoint(x: xOffset, y: imageSize.height) 107 | transformedTopRight = NSPoint(x: imageSize.width - xOffset, y: imageSize.height) 108 | case .topRight: 109 | transformedTopRight = NSPoint(x: imageSize.width - xOffset, y: imageSize.height) 110 | transformedBottomRight = NSPoint(x: imageSize.width - xOffset, y: 0) 111 | transformedTopLeft = NSPoint(x: 0, y: imageSize.height - yOffset) 112 | case .bottomLeft: 113 | transformedBottomLeft = NSPoint(x: xOffset, y: 0) 114 | transformedTopLeft = NSPoint(x: xOffset, y: imageSize.height) 115 | transformedBottomRight = NSPoint(x: imageSize.width, y: yOffset) 116 | case .bottom: 117 | transformedBottomLeft = NSPoint(x: xOffset, y: 0) 118 | transformedBottomRight = NSPoint(x: imageSize.width - xOffset, y: 0) 119 | case .bottomRight: 120 | transformedBottomRight = NSPoint(x: imageSize.width - xOffset, y: 0) 121 | transformedTopRight = NSPoint(x: imageSize.width - xOffset, y: imageSize.height) 122 | transformedBottomLeft = NSPoint(x: 0, y: yOffset) 123 | } 124 | 125 | // Draw the perspective shape with transformed points 126 | path.move(to: transformedTopLeft) 127 | path.line(to: transformedTopRight) 128 | path.line(to: transformedBottomRight) 129 | path.line(to: transformedBottomLeft) 130 | path.close() 131 | 132 | // Clip to this perspective shape 133 | path.setClip() 134 | 135 | // Draw the image 136 | image.draw(in: CGRect(origin: .zero, size: imageSize), from: .zero, operation: .sourceOver, fraction: 1.0) 137 | 138 | resultImage.unlockFocus() 139 | 140 | return resultImage 141 | } 142 | 143 | /** 144 | * Converts image to data for saving 145 | */ 146 | static func imageToData(_ image: NSImage, format: NSBitmapImageRep.FileType = .png) -> Data? { 147 | guard let tiffData = image.tiffRepresentation, 148 | let bitmap = NSBitmapImageRep(data: tiffData) else { 149 | return nil 150 | } 151 | 152 | return bitmap.representation(using: format, properties: [:]) 153 | } 154 | } 155 | 156 | /** 157 | * Extension to add UTType conformance for file operations 158 | */ 159 | extension UTType { 160 | static let png = UTType(filenameExtension: "png")! 161 | static let jpeg = UTType(filenameExtension: "jpeg")! 162 | static let jpg = UTType(filenameExtension: "jpg")! 163 | static let tiff = UTType(filenameExtension: "tiff")! 164 | static let gif = UTType(filenameExtension: "gif")! 165 | static let bmp = UTType(filenameExtension: "bmp")! 166 | } -------------------------------------------------------------------------------- /freescreenshot/ViewModels/EditorViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EditorViewModel.swift 3 | // freescreenshot 4 | // 5 | // Created by Samik Choudhury on 06/04/25. 6 | // 7 | 8 | import SwiftUI 9 | import Cocoa 10 | 11 | /** 12 | * EditorViewModel: Manages the state and business logic for the screenshot editor 13 | */ 14 | class EditorViewModel: ObservableObject { 15 | // Image related properties 16 | @Published var image: NSImage? 17 | @Published var originalImage: NSImage? 18 | @Published var backgroundType: BackgroundType = .none 19 | @Published var backgroundColor: Color = .white 20 | @Published var backgroundGradient: Gradient = Gradient(colors: [.blue, .purple]) 21 | @Published var backgroundImage: NSImage? 22 | @Published var is3DEffect: Bool = false 23 | @Published var perspective3DDirection: Perspective3DDirection = .bottomRight 24 | @Published var aspectRatio: AspectRatio = .square 25 | @Published var imagePadding: CGFloat = 20 // Percentage padding around the image (0-50) 26 | @Published var cornerRadius: CGFloat = 0 // Corner radius for the screenshot (0-50) 27 | 28 | // Editor state 29 | @Published var currentTool: EditingTool = .select 30 | @Published var arrowStyle: ArrowStyle = .straight 31 | @Published var textColor: Color = .black 32 | @Published var textSize: CGFloat = 16 33 | @Published var lineWidth: CGFloat = 2 34 | @Published var highlighterColor: Color = .yellow 35 | @Published var highlighterOpacity: Double = 0.5 36 | @Published var selectedElementId: UUID? 37 | 38 | // Elements 39 | @Published var elements: [any EditableElement] = [] 40 | 41 | // For drag operations 42 | @Published var isDragging: Bool = false 43 | @Published var dragStart: CGPoint = .zero 44 | @Published var dragOffset: CGSize = .zero 45 | 46 | /** 47 | * Sets the image to edit and initializes the editor 48 | */ 49 | func setImage(_ newImage: NSImage) { 50 | self.image = newImage 51 | self.originalImage = newImage 52 | 53 | // Set initial aspect ratio based on image dimensions 54 | let imageRatio = newImage.size.width / newImage.size.height 55 | 56 | // Choose the closest aspect ratio 57 | if abs(imageRatio - 1.0) < 0.1 { 58 | self.aspectRatio = .square 59 | } else if abs(imageRatio - (16.0/9.0)) < 0.1 { 60 | self.aspectRatio = .widescreen 61 | } else if abs(imageRatio - (9.0/16.0)) < 0.1 { 62 | self.aspectRatio = .portrait 63 | } else if abs(imageRatio - (4.0/3.0)) < 0.1 { 64 | self.aspectRatio = .traditional 65 | } else if abs(imageRatio - (3.0/4.0)) < 0.1 { 66 | self.aspectRatio = .traditionalPortrait 67 | } else if abs(imageRatio - (3.0/2.0)) < 0.1 { 68 | self.aspectRatio = .photo 69 | } else if abs(imageRatio - (2.0/3.0)) < 0.1 { 70 | self.aspectRatio = .photoPortrait 71 | } else if imageRatio > 1.0 { 72 | // Default for landscape images 73 | self.aspectRatio = .widescreen 74 | } else { 75 | // Default for portrait images 76 | self.aspectRatio = .portrait 77 | } 78 | 79 | // Set default padding 80 | self.imagePadding = 20 81 | 82 | // Reset all editing settings 83 | self.elements = [] 84 | self.selectedElementId = nil 85 | self.currentTool = .select 86 | self.backgroundType = .none 87 | self.backgroundColor = .white 88 | self.backgroundGradient = Gradient(colors: [.blue, .purple]) 89 | self.backgroundImage = nil 90 | self.is3DEffect = false 91 | } 92 | 93 | /** 94 | * Loads an image from file for the editor 95 | */ 96 | func loadImage(from url: URL) { 97 | if let image = NSImage(contentsOf: url) { 98 | setImage(image) 99 | } 100 | } 101 | 102 | /** 103 | * Applies the selected background to the image 104 | */ 105 | func applyBackground() { 106 | guard let originalImage = originalImage else { return } 107 | 108 | // Original image dimensions 109 | let imageSize = originalImage.size 110 | let aspectRatio = self.aspectRatio.ratio 111 | 112 | // Determine the canvas size based on aspect ratio 113 | var canvasWidth: CGFloat 114 | var canvasHeight: CGFloat 115 | 116 | if aspectRatio >= 1.0 { 117 | // Landscape or square aspect ratio 118 | canvasWidth = 1000 // Base width 119 | canvasHeight = canvasWidth / aspectRatio 120 | } else { 121 | // Portrait aspect ratio 122 | canvasHeight = 1000 // Base height 123 | canvasWidth = canvasHeight * aspectRatio 124 | } 125 | 126 | let resultSize = CGSize(width: canvasWidth, height: canvasHeight) 127 | 128 | // Create a new larger image to draw on for the background 129 | let resultImage = NSImage(size: resultSize) 130 | resultImage.lockFocus() 131 | 132 | // Clear the canvas first with white/transparent background 133 | if backgroundType == .none { 134 | NSColor.white.setFill() 135 | } else { 136 | NSColor.clear.setFill() 137 | } 138 | NSRect(origin: .zero, size: resultSize).fill() 139 | 140 | // Calculate the rectangle to fill with the background 141 | let backgroundRect = CGRect(origin: .zero, size: resultSize) 142 | 143 | // Draw background based on selected type 144 | switch backgroundType { 145 | case .solid: 146 | // Draw solid color background 147 | NSColor(backgroundColor).setFill() 148 | NSRect(origin: .zero, size: resultSize).fill() 149 | 150 | case .gradient: 151 | // Draw gradient background 152 | if let gradientContext = NSGraphicsContext.current?.cgContext { 153 | let colors = backgroundGradient.stops.map { NSColor($0.color).cgColor } 154 | let colorSpace = CGColorSpaceCreateDeviceRGB() 155 | let positions = backgroundGradient.stops.map { CGFloat($0.location) } 156 | 157 | if let gradient = CGGradient(colorsSpace: colorSpace, colors: colors as CFArray, locations: positions) { 158 | gradientContext.drawLinearGradient( 159 | gradient, 160 | start: CGPoint(x: 0, y: 0), 161 | end: CGPoint(x: resultSize.width, y: resultSize.height), 162 | options: [] 163 | ) 164 | } 165 | } 166 | 167 | case .image: 168 | // Draw image background if available 169 | if let bgImage = backgroundImage { 170 | bgImage.draw(in: backgroundRect, from: .zero, operation: .copy, fraction: 1.0) 171 | } else { 172 | // Fallback to white background if no image is set 173 | NSColor.white.setFill() 174 | NSRect(origin: .zero, size: resultSize).fill() 175 | } 176 | 177 | case .none: 178 | // Just leave the white background 179 | break 180 | } 181 | 182 | // Only draw the screenshot if we're not in device mockup mode 183 | // (device mockup handles drawing the screenshot itself) 184 | 185 | // Calculate where to draw the original image (centered and with padding) 186 | let imageRatio = imageSize.width / imageSize.height 187 | let canvasRatio = resultSize.width / resultSize.height 188 | 189 | // Calculate available space after padding 190 | let paddingFactor = min(max(imagePadding, 0), 50) / 100 // Convert percentage to factor (0-0.5) 191 | let availableWidth = resultSize.width * (1 - paddingFactor * 2) 192 | let availableHeight = resultSize.height * (1 - paddingFactor * 2) 193 | 194 | var drawingSize = imageSize 195 | var drawingOrigin = CGPoint.zero 196 | 197 | if imageRatio > canvasRatio { 198 | // Image is wider compared to canvas, fit by width 199 | drawingSize.width = availableWidth 200 | drawingSize.height = drawingSize.width / imageRatio 201 | } else { 202 | // Image is taller compared to canvas, fit by height 203 | drawingSize.height = availableHeight 204 | drawingSize.width = drawingSize.height * imageRatio 205 | } 206 | 207 | // Center the image on the canvas 208 | drawingOrigin.x = (resultSize.width - drawingSize.width) / 2 209 | drawingOrigin.y = (resultSize.height - drawingSize.height) / 2 210 | 211 | let imageRect = CGRect(origin: drawingOrigin, size: drawingSize) 212 | 213 | // Important: We don't apply 3D effects here - they'll be handled by SwiftUI 214 | // Only draw the image with corner radius if needed 215 | drawImageWithCornerRadius( 216 | originalImage, 217 | in: imageRect, 218 | radius: cornerRadius 219 | ) 220 | 221 | resultImage.unlockFocus() 222 | self.image = resultImage 223 | objectWillChange.send() 224 | } 225 | 226 | /** 227 | * Draws an image with optional corner radius 228 | */ 229 | private func drawImageWithCornerRadius(_ image: NSImage, in rect: CGRect, radius: CGFloat) { 230 | if radius > 0 { 231 | let cornerRadiusScaled = min(radius, min(rect.width, rect.height) / 2) 232 | 233 | let path = NSBezierPath(roundedRect: NSRect( 234 | x: rect.origin.x, 235 | y: rect.origin.y, 236 | width: rect.width, 237 | height: rect.height 238 | ), xRadius: cornerRadiusScaled, yRadius: cornerRadiusScaled) 239 | 240 | // Save the current graphics state 241 | NSGraphicsContext.current?.saveGraphicsState() 242 | 243 | // Set the path as clipping path 244 | path.setClip() 245 | 246 | // Draw the image within the clipping path 247 | image.draw(in: rect, from: .zero, operation: .sourceOver, fraction: 1.0) 248 | 249 | // Restore the graphics state 250 | NSGraphicsContext.current?.restoreGraphicsState() 251 | } else { 252 | // Draw without rounded corners 253 | image.draw(in: rect, from: .zero, operation: .sourceOver, fraction: 1.0) 254 | } 255 | } 256 | 257 | /** 258 | * Exports the current image with all applied effects 259 | */ 260 | func exportImage() -> NSImage? { 261 | guard let originalImage = originalImage else { return nil } 262 | 263 | // For non-3D effects, we can just return the current image 264 | if !is3DEffect { 265 | return compressImageForExport(image) 266 | } 267 | 268 | // For 3D effect, we need to completely rebuild the image with flat background 269 | 270 | // 1. Use a more reasonable resolution to keep file size under 1MB 271 | var canvasWidth: CGFloat 272 | var canvasHeight: CGFloat 273 | 274 | if aspectRatio.ratio >= 1.0 { 275 | // Landscape or square aspect ratio 276 | canvasWidth = 1500 // Reduced resolution for smaller file size 277 | canvasHeight = canvasWidth / aspectRatio.ratio 278 | } else { 279 | // Portrait aspect ratio 280 | canvasHeight = 1500 // Reduced resolution for smaller file size 281 | canvasWidth = canvasHeight * aspectRatio.ratio 282 | } 283 | 284 | let baseSize = CGSize(width: canvasWidth, height: canvasHeight) 285 | 286 | // 2. Create our export canvas - reasonable size to keep file under 1MB 287 | let exportImage = NSImage(size: baseSize) 288 | 289 | // Enable high quality rendering 290 | exportImage.lockFocusFlipped(false) 291 | 292 | // Enable higher quality image interpolation, but not max to keep size reasonable 293 | if let context = NSGraphicsContext.current { 294 | context.imageInterpolation = .high 295 | context.shouldAntialias = true 296 | context.compositingOperation = .copy 297 | } 298 | 299 | // 3. Draw the FLAT background first (no 3D effect applied) 300 | switch backgroundType { 301 | case .solid: 302 | // Simple solid color 303 | NSColor(backgroundColor).setFill() 304 | NSRect(origin: .zero, size: baseSize).fill() 305 | 306 | case .gradient: 307 | // Draw gradient background 308 | if let gradientContext = NSGraphicsContext.current?.cgContext { 309 | let colors = backgroundGradient.stops.map { NSColor($0.color).cgColor } 310 | let colorSpace = CGColorSpaceCreateDeviceRGB() 311 | let positions = backgroundGradient.stops.map { CGFloat($0.location) } 312 | 313 | if let gradient = CGGradient(colorsSpace: colorSpace, colors: colors as CFArray, locations: positions) { 314 | // Draw gradient to fill the entire background as a flat surface 315 | gradientContext.drawLinearGradient( 316 | gradient, 317 | start: CGPoint(x: 0, y: 0), 318 | end: CGPoint(x: baseSize.width, y: baseSize.height), 319 | options: [.drawsBeforeStartLocation, .drawsAfterEndLocation] 320 | ) 321 | } 322 | } 323 | 324 | case .image: 325 | if let bgImage = backgroundImage { 326 | // Draw background image at high quality 327 | bgImage.draw(in: NSRect(origin: .zero, size: baseSize), 328 | from: .zero, 329 | operation: .copy, 330 | fraction: 1.0, 331 | respectFlipped: true, 332 | hints: [NSImageRep.HintKey.interpolation: NSNumber(value: NSImageInterpolation.high.rawValue)]) 333 | } 334 | 335 | case .none: 336 | // White background 337 | NSColor.white.setFill() 338 | NSRect(origin: .zero, size: baseSize).fill() 339 | } 340 | 341 | // 4. Calculate padding for the screenshot content 342 | let paddingFactor = min(max(imagePadding, 0), 50) / 100 // Convert percentage to factor (0-0.5) 343 | 344 | // Define the area where the screenshot will be placed 345 | let contentWidth = baseSize.width * (1 - paddingFactor * 2) 346 | let contentHeight = baseSize.height * (1 - paddingFactor * 2) 347 | let contentX = baseSize.width * paddingFactor 348 | let contentY = baseSize.height * paddingFactor 349 | 350 | // 5. Create a SEPARATE IMAGE for the 3D screenshot 351 | let screenshotSize = CGSize(width: contentWidth * 1.2, height: contentHeight * 1.2) 352 | let screenshotImage = NSImage(size: screenshotSize) 353 | 354 | screenshotImage.lockFocusFlipped(false) 355 | 356 | // Set high quality for screenshot 357 | if let context = NSGraphicsContext.current { 358 | context.imageInterpolation = .high 359 | context.shouldAntialias = true 360 | } 361 | 362 | // Clear background 363 | NSColor.clear.setFill() 364 | NSRect(origin: .zero, size: screenshotSize).fill() 365 | 366 | // Calculate position for the screenshot content in its own canvas 367 | let screenshotContentWidth = contentWidth * 0.9 368 | let screenshotContentHeight = contentHeight * 0.9 369 | let screenshotContentX = (screenshotSize.width - screenshotContentWidth) / 2 370 | let screenshotContentY = (screenshotSize.height - screenshotContentHeight) / 2 371 | 372 | // Draw the screenshot with corner radius if needed 373 | let screenshotRect = NSRect(x: screenshotContentX, 374 | y: screenshotContentY, 375 | width: screenshotContentWidth, 376 | height: screenshotContentHeight) 377 | 378 | if cornerRadius > 0 { 379 | let path = NSBezierPath(roundedRect: screenshotRect, xRadius: cornerRadius, yRadius: cornerRadius) 380 | NSGraphicsContext.current?.saveGraphicsState() 381 | path.setClip() 382 | originalImage.draw(in: screenshotRect, from: .zero, operation: .copy, fraction: 1.0) 383 | NSGraphicsContext.current?.restoreGraphicsState() 384 | } else { 385 | originalImage.draw(in: screenshotRect, from: .zero, operation: .copy, fraction: 1.0) 386 | } 387 | 388 | screenshotImage.unlockFocus() 389 | 390 | // 6. Apply 3D transformation to the screenshot image 391 | let transform3D = create3DTransform(for: perspective3DDirection) 392 | 393 | // Apply the 3D transformation to get the final screenshot image 394 | if let transformedScreenshot = apply3DTransform(to: screenshotImage, 395 | transform: transform3D) { 396 | // 7. Center the 3D transformed screenshot on the background 397 | let transformedSize = transformedScreenshot.size 398 | let transformedX = contentX + (contentWidth - transformedSize.width) / 2 399 | let transformedY = contentY + (contentHeight - transformedSize.height) / 2 400 | 401 | // Add shadow 402 | let shadow = NSShadow() 403 | shadow.shadowColor = NSColor.black.withAlphaComponent(0.4) 404 | shadow.shadowOffset = NSSize(width: 8, height: 8) 405 | shadow.shadowBlurRadius = 15 406 | shadow.set() 407 | 408 | // Draw the transformed screenshot on the background 409 | transformedScreenshot.draw(in: NSRect(x: transformedX, 410 | y: transformedY, 411 | width: transformedSize.width, 412 | height: transformedSize.height), 413 | from: .zero, 414 | operation: .sourceOver, 415 | fraction: 1.0) 416 | } 417 | 418 | exportImage.unlockFocus() 419 | 420 | // Compress the final image to ensure it's under 1MB 421 | return compressImageForExport(exportImage) 422 | } 423 | 424 | /** 425 | * Creates a 3D transformation matrix based on the perspective direction 426 | */ 427 | private func create3DTransform(for direction: Perspective3DDirection) -> CATransform3D { 428 | var transform3D = CATransform3DIdentity 429 | transform3D.m34 = -1.0 / 800.0 // Perspective depth 430 | 431 | // Angle in radians (15 degrees) 432 | let angle = CGFloat.pi / 12 433 | 434 | // Apply rotation based on direction 435 | switch direction { 436 | case .topLeft: 437 | transform3D = CATransform3DRotate(transform3D, angle, 1, 0, 0) 438 | transform3D = CATransform3DRotate(transform3D, -angle, 0, 1, 0) 439 | case .top: 440 | transform3D = CATransform3DRotate(transform3D, angle, 1, 0, 0) 441 | case .topRight: 442 | transform3D = CATransform3DRotate(transform3D, angle, 1, 0, 0) 443 | transform3D = CATransform3DRotate(transform3D, angle, 0, 1, 0) 444 | case .bottomLeft: 445 | transform3D = CATransform3DRotate(transform3D, -angle, 1, 0, 0) 446 | transform3D = CATransform3DRotate(transform3D, -angle, 0, 1, 0) 447 | case .bottom: 448 | transform3D = CATransform3DRotate(transform3D, -angle, 1, 0, 0) 449 | case .bottomRight: 450 | transform3D = CATransform3DRotate(transform3D, -angle, 1, 0, 0) 451 | transform3D = CATransform3DRotate(transform3D, angle, 0, 1, 0) 452 | } 453 | 454 | return transform3D 455 | } 456 | 457 | /** 458 | * Applies a 3D transformation to an image 459 | */ 460 | private func apply3DTransform(to image: NSImage, transform: CATransform3D) -> NSImage? { 461 | let imageSize = image.size 462 | let exportSize = CGSize(width: imageSize.width * 1.3, height: imageSize.height * 1.3) 463 | let result = NSImage(size: exportSize) 464 | 465 | result.lockFocusFlipped(false) 466 | 467 | // Clear background 468 | NSColor.clear.setFill() 469 | NSRect(origin: .zero, size: exportSize).fill() 470 | 471 | // Calculate the translation to center the image 472 | let translateX = (exportSize.width - imageSize.width) / 2 473 | let translateY = (exportSize.height - imageSize.height) / 2 474 | 475 | if let context = NSGraphicsContext.current?.cgContext { 476 | // Apply high quality rendering 477 | context.setShouldAntialias(true) 478 | context.setAllowsAntialiasing(true) 479 | context.interpolationQuality = .high 480 | 481 | // Apply the transformation 482 | context.saveGState() 483 | context.translateBy(x: translateX, y: translateY) 484 | context.concatenate(CATransform3DGetAffineTransform(transform)) 485 | 486 | // Draw the image 487 | image.draw(in: CGRect(origin: .zero, size: imageSize), 488 | from: .zero, 489 | operation: .copy, 490 | fraction: 1.0) 491 | 492 | context.restoreGState() 493 | } 494 | 495 | result.unlockFocus() 496 | return result 497 | } 498 | 499 | /** 500 | * Compresses an image to keep file size under 1MB 501 | */ 502 | private func compressImageForExport(_ image: NSImage?) -> NSImage? { 503 | guard let image = image else { return nil } 504 | 505 | // Convert to bitmap for compression 506 | guard let tiffData = image.tiffRepresentation, 507 | let bitmap = NSBitmapImageRep(data: tiffData) else { 508 | return image 509 | } 510 | 511 | // Use JPEG compression with medium quality to keep file size under 1MB 512 | guard let jpegData = bitmap.representation(using: .jpeg, properties: [.compressionFactor: NSNumber(value: 0.8)]) else { 513 | return image 514 | } 515 | 516 | // Convert back to NSImage 517 | return NSImage(data: jpegData) 518 | } 519 | 520 | /** 521 | * Saves the image to a file 522 | */ 523 | func saveImage(to url: URL) { 524 | guard let image = exportImage(), 525 | let data = ImageUtilities.imageToData(image) else { 526 | return 527 | } 528 | 529 | try? data.write(to: url) 530 | } 531 | 532 | /** 533 | * Selects an element by ID 534 | */ 535 | func selectElement(id: UUID?) { 536 | selectedElementId = id 537 | } 538 | 539 | /** 540 | * Creates a new image with corner radius applied 541 | */ 542 | private func applyCornerRadius(to image: NSImage, radius: CGFloat) -> NSImage { 543 | let size = image.size 544 | let scaledRadius = min(radius, min(size.width, size.height) / 2) 545 | 546 | // Create a new image with the same size 547 | let result = NSImage(size: size) 548 | 549 | result.lockFocus() 550 | 551 | // Create a rounded rectangle path 552 | let path = NSBezierPath(roundedRect: NSRect(origin: .zero, size: size), 553 | xRadius: scaledRadius, yRadius: scaledRadius) 554 | 555 | // Set the path as clipping path 556 | path.setClip() 557 | 558 | // Draw the image within the clipping path 559 | image.draw(in: NSRect(origin: .zero, size: size), from: .zero, operation: .sourceOver, fraction: 1.0) 560 | 561 | result.unlockFocus() 562 | 563 | return result 564 | } 565 | 566 | /** 567 | * Draw background in the specified rectangle 568 | */ 569 | private func drawBackground(in rect: NSRect) { 570 | // Clear the background first 571 | NSColor.clear.setFill() 572 | rect.fill() 573 | 574 | switch backgroundType { 575 | case .solid: 576 | // Draw solid color background 577 | NSColor(backgroundColor).setFill() 578 | rect.fill() 579 | 580 | case .gradient: 581 | // Draw gradient background 582 | if let gradientContext = NSGraphicsContext.current?.cgContext { 583 | let colors = backgroundGradient.stops.map { NSColor($0.color).cgColor } 584 | let colorSpace = CGColorSpaceCreateDeviceRGB() 585 | let positions = backgroundGradient.stops.map { CGFloat($0.location) } 586 | 587 | if let gradient = CGGradient(colorsSpace: colorSpace, colors: colors as CFArray, locations: positions) { 588 | gradientContext.drawLinearGradient( 589 | gradient, 590 | start: CGPoint(x: 0, y: 0), 591 | end: CGPoint(x: rect.width, y: rect.height), 592 | options: [] 593 | ) 594 | } 595 | } 596 | 597 | case .image: 598 | // Draw background image scaled to fill 599 | if let bgImage = backgroundImage { 600 | bgImage.draw(in: rect, from: .zero, operation: .copy, fraction: 1.0) 601 | } 602 | 603 | case .none: 604 | // No background 605 | break 606 | } 607 | } 608 | 609 | /** 610 | * Helper to get perspective transform parameters based on direction 611 | */ 612 | private func getPerspectiveTransform(for direction: Perspective3DDirection, size: CGSize) -> (rotationX: CGFloat, rotationY: CGFloat, shadowOffsetX: CGFloat, shadowOffsetY: CGFloat) { 613 | // Convert degrees to radians - increase from 10 to 15 degrees for more visible effect 614 | let angleInRadians = CGFloat.pi / 12 // 15 degrees 615 | 616 | switch direction { 617 | case .topLeft: 618 | return (rotationX: angleInRadians, rotationY: -angleInRadians, shadowOffsetX: -20, shadowOffsetY: 20) 619 | case .top: 620 | return (rotationX: angleInRadians, rotationY: 0, shadowOffsetX: 0, shadowOffsetY: 20) 621 | case .topRight: 622 | return (rotationX: angleInRadians, rotationY: angleInRadians, shadowOffsetX: 20, shadowOffsetY: 20) 623 | case .bottomLeft: 624 | return (rotationX: -angleInRadians, rotationY: -angleInRadians, shadowOffsetX: -20, shadowOffsetY: -20) 625 | case .bottom: 626 | return (rotationX: -angleInRadians, rotationY: 0, shadowOffsetX: 0, shadowOffsetY: -20) 627 | case .bottomRight: 628 | return (rotationX: -angleInRadians, rotationY: angleInRadians, shadowOffsetX: 20, shadowOffsetY: -20) 629 | } 630 | } 631 | } 632 | 633 | // Extension to convert CGPath to NSBezierPath 634 | extension CGPath { 635 | func toBezierPath() -> NSBezierPath { 636 | let path = NSBezierPath() 637 | var _ = [CGPoint](repeating: .zero, count: 3) 638 | 639 | self.applyWithBlock { (elementPtr: UnsafePointer) in 640 | let element = elementPtr.pointee 641 | 642 | switch element.type { 643 | case .moveToPoint: 644 | let point = element.points[0] 645 | path.move(to: point) 646 | case .addLineToPoint: 647 | let point = element.points[0] 648 | path.line(to: point) 649 | case .addQuadCurveToPoint: 650 | // Convert quadratic curve to cubic curve 651 | let currentPoint = path.currentPoint 652 | let point1 = element.points[0] 653 | let point2 = element.points[1] 654 | 655 | path.curve(to: point2, 656 | controlPoint1: CGPoint( 657 | x: currentPoint.x + 2/3 * (point1.x - currentPoint.x), 658 | y: currentPoint.y + 2/3 * (point1.y - currentPoint.y) 659 | ), 660 | controlPoint2: CGPoint( 661 | x: point2.x + 2/3 * (point1.x - point2.x), 662 | y: point2.y + 2/3 * (point1.y - point2.y) 663 | )) 664 | case .addCurveToPoint: 665 | let point1 = element.points[0] 666 | let point2 = element.points[1] 667 | let point3 = element.points[2] 668 | path.curve(to: point3, controlPoint1: point1, controlPoint2: point2) 669 | case .closeSubpath: 670 | path.close() 671 | @unknown default: 672 | break 673 | } 674 | } 675 | 676 | return path 677 | } 678 | } 679 | 680 | /** 681 | * Enum defining perspective 3D viewing angles 682 | */ 683 | enum Perspective3DDirection: String, CaseIterable, Identifiable { 684 | case topLeft 685 | case top 686 | case topRight 687 | case bottomLeft 688 | case bottom 689 | case bottomRight 690 | 691 | var id: String { self.rawValue } 692 | 693 | var displayName: String { 694 | switch self { 695 | case .topLeft: return "Top Left" 696 | case .top: return "Top" 697 | case .topRight: return "Top Right" 698 | case .bottomLeft: return "Bottom Left" 699 | case .bottom: return "Bottom" 700 | case .bottomRight: return "Bottom Right" 701 | } 702 | } 703 | } 704 | 705 | /** 706 | * Enum defining common aspect ratios for the canvas 707 | */ 708 | enum AspectRatio: String, CaseIterable, Identifiable { 709 | case square = "1:1" 710 | case widescreen = "16:9" 711 | case portrait = "9:16" 712 | case traditional = "4:3" 713 | case traditionalPortrait = "3:4" 714 | case photo = "3:2" 715 | case photoPortrait = "2:3" 716 | 717 | var id: String { self.rawValue } 718 | 719 | var ratio: CGFloat { 720 | switch self { 721 | case .square: return 1.0 722 | case .widescreen: return 16.0 / 9.0 723 | case .portrait: return 9.0 / 16.0 724 | case .traditional: return 4.0 / 3.0 725 | case .traditionalPortrait: return 3.0 / 4.0 726 | case .photo: return 3.0 / 2.0 727 | case .photoPortrait: return 2.0 / 3.0 728 | } 729 | } 730 | 731 | var displayName: String { rawValue } 732 | } -------------------------------------------------------------------------------- /freescreenshot/Views/BackgroundPicker.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BackgroundPicker.swift 3 | // freescreenshot 4 | // 5 | // Created by Samik Choudhury on 06/04/25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /** 11 | * BackgroundPicker: A view for selecting different types of backgrounds 12 | */ 13 | struct BackgroundPicker: View { 14 | @ObservedObject var viewModel: EditorViewModel 15 | @Binding var isPresented: Bool 16 | @State private var selectedGradientPreset = 0 17 | @State private var tempBackgroundType: BackgroundType 18 | @State private var tempBackgroundColor: Color 19 | @State private var tempBackgroundGradient: Gradient 20 | @State private var tempIs3DEffect: Bool 21 | @State private var tempPerspective3DDirection: Perspective3DDirection 22 | @State private var tempAspectRatio: AspectRatio 23 | @State private var tempImagePadding: CGFloat 24 | @State private var tempCornerRadius: CGFloat 25 | @State private var refreshPreview: Bool = false 26 | 27 | // Predefined gradient presets 28 | private let gradientPresets: [Gradient] = [ 29 | Gradient(colors: [.blue, .purple]), 30 | Gradient(colors: [.green, .blue]), 31 | Gradient(colors: [.orange, .red]), 32 | Gradient(colors: [.pink, .purple]), 33 | Gradient(colors: [.gray, .black]), 34 | Gradient(colors: [.yellow, .green]) 35 | ] 36 | 37 | init(viewModel: EditorViewModel, isPresented: Binding) { 38 | self.viewModel = viewModel 39 | self._isPresented = isPresented 40 | self._tempBackgroundType = State(initialValue: viewModel.backgroundType) 41 | self._tempBackgroundColor = State(initialValue: viewModel.backgroundColor) 42 | self._tempBackgroundGradient = State(initialValue: viewModel.backgroundGradient) 43 | self._tempIs3DEffect = State(initialValue: viewModel.is3DEffect) 44 | self._tempPerspective3DDirection = State(initialValue: viewModel.perspective3DDirection) 45 | self._tempAspectRatio = State(initialValue: viewModel.aspectRatio) 46 | self._tempImagePadding = State(initialValue: viewModel.imagePadding) 47 | self._tempCornerRadius = State(initialValue: viewModel.cornerRadius) 48 | } 49 | 50 | var body: some View { 51 | HStack(spacing: 0) { 52 | // LEFT COLUMN - Controls 53 | controlsColumn 54 | 55 | Divider() 56 | 57 | // RIGHT COLUMN - Preview 58 | previewColumn 59 | } 60 | .frame(width: 800, height: 700) 61 | .onAppear { 62 | // Apply any existing background settings when picker appears 63 | updateAndApplyChanges() 64 | } 65 | } 66 | 67 | // MARK: - UI Components 68 | 69 | // Left column with all controls 70 | private var controlsColumn: some View { 71 | VStack(spacing: 0) { 72 | Text("Background Options") 73 | .font(.headline) 74 | .padding(.top, 8) 75 | 76 | // Background type selector 77 | Picker("Background Type", selection: $tempBackgroundType) { 78 | ForEach(BackgroundType.allCases) { type in 79 | Text(type.displayName).tag(type) 80 | } 81 | } 82 | .pickerStyle(.segmented) 83 | .padding(.horizontal, 16) 84 | .onChange(of: tempBackgroundType) { _ in 85 | updateAndApplyChanges() 86 | } 87 | 88 | // Different options based on selected background type 89 | backgroundTypeOptions 90 | .padding(.horizontal, 16) 91 | .padding(.vertical, 4) 92 | .frame(minHeight: 10) 93 | 94 | Divider() 95 | .padding(.horizontal, 16) 96 | 97 | // 3D effect toggle 98 | Toggle("Apply 3D Perspective Effect", isOn: $tempIs3DEffect) 99 | .onChange(of: tempIs3DEffect) { _ in 100 | updateAndApplyChanges() 101 | } 102 | .padding(.horizontal, 16) 103 | .padding(.top, 8) 104 | 105 | // 3D perspective direction selector (only shown when 3D effect is enabled) 106 | if tempIs3DEffect { 107 | perspectiveDirectionSelector 108 | } 109 | 110 | // Canvas size adjustment 111 | canvasSettingsView 112 | 113 | Spacer() 114 | 115 | // Buttons 116 | buttonRow 117 | } 118 | .frame(width: 400) 119 | .padding(.horizontal, 12) 120 | } 121 | 122 | // Right column with preview 123 | private var previewColumn: some View { 124 | VStack { 125 | Text("Preview") 126 | .font(.headline) 127 | .padding(.top, 20) 128 | 129 | // Preview container 130 | GeometryReader { geo in 131 | // Fixed size container for the image preview 132 | ZStack { 133 | standardPreviewView(in: geo) 134 | } 135 | .frame(maxWidth: .infinity, maxHeight: .infinity) 136 | } 137 | 138 | Spacer() 139 | } 140 | .frame(minWidth: 300) 141 | .padding(.horizontal, 16) 142 | } 143 | 144 | // Background type specific options 145 | private var backgroundTypeOptions: some View { 146 | Group { 147 | switch tempBackgroundType { 148 | case .solid: 149 | solidColorView 150 | 151 | case .gradient: 152 | gradientView 153 | 154 | case .image: 155 | backgroundImageView 156 | 157 | case .none: 158 | Text("No background will be applied") 159 | .font(.subheadline) 160 | .foregroundColor(.secondary) 161 | } 162 | } 163 | } 164 | 165 | // Solid color background options 166 | private var solidColorView: some View { 167 | VStack(alignment: .leading) { 168 | Text("Color") 169 | .font(.subheadline) 170 | 171 | ColorPicker("", selection: $tempBackgroundColor) 172 | .labelsHidden() 173 | .onChange(of: tempBackgroundColor) { _ in 174 | updateAndApplyChanges() 175 | } 176 | } 177 | } 178 | 179 | // Gradient background options 180 | private var gradientView: some View { 181 | VStack(alignment: .leading, spacing: 10) { 182 | Text("Gradient Preset") 183 | .font(.subheadline) 184 | 185 | ScrollView(.horizontal, showsIndicators: false) { 186 | HStack(spacing: 4) { 187 | ForEach(0.. 0 { 344 | HStack { 345 | Spacer() 346 | RoundedRectangle(cornerRadius: tempCornerRadius / 2) 347 | .stroke(Color.accentColor, lineWidth: 1) 348 | .frame(width: 80, height: 50) 349 | .background( 350 | RoundedRectangle(cornerRadius: tempCornerRadius / 2) 351 | .fill(Color.accentColor.opacity(0.2)) 352 | ) 353 | Spacer() 354 | } 355 | .padding(.top, 4) 356 | } 357 | } 358 | .padding(.horizontal, 16) 359 | .padding(.vertical, 8) 360 | } 361 | 362 | // Button row at the bottom 363 | private var buttonRow: some View { 364 | HStack { 365 | Button("Cancel") { 366 | // Revert to original image 367 | if let originalImage = viewModel.originalImage { 368 | viewModel.image = originalImage 369 | viewModel.backgroundType = .none 370 | } 371 | isPresented = false 372 | } 373 | .keyboardShortcut(.escape) 374 | 375 | Spacer() 376 | 377 | Button("Apply") { 378 | // Keep current changes and apply them permanently 379 | applyChanges() 380 | isPresented = false 381 | } 382 | .keyboardShortcut(.return) 383 | .buttonStyle(.borderedProminent) 384 | } 385 | .padding(.horizontal, 16) 386 | .padding(.vertical, 16) 387 | } 388 | 389 | // MARK: - Preview Views 390 | 391 | // Standard background preview for non-device types 392 | private func standardPreviewView(in geo: GeometryProxy) -> some View { 393 | Group { 394 | // Static background that doesn't rotate (if background is applied) 395 | if tempBackgroundType != .none { 396 | RoundedRectangle(cornerRadius: 12) 397 | .fill(Color(NSColor.windowBackgroundColor).opacity(0.5)) 398 | .frame( 399 | // Increased canvas size to accommodate 3D rotation 400 | width: min(geo.size.width * 0.95, geo.size.height * 0.95), 401 | height: min(geo.size.width * 0.95, geo.size.height * 0.95) 402 | ) 403 | } 404 | 405 | // Image preview with 3D rotation applied only to the image 406 | if let image = viewModel.image { 407 | // Use SwiftUI's built-in 3D rotation for the preview only 408 | Image(nsImage: image) 409 | .resizable() 410 | .aspectRatio(contentMode: .fit) 411 | .frame( 412 | // Make image smaller to ensure it stays within canvas when rotated 413 | width: tempIs3DEffect 414 | ? min(geo.size.width * 0.7, geo.size.height * 0.7) 415 | : min(geo.size.width * 0.8, geo.size.height * 0.8), 416 | height: tempIs3DEffect 417 | ? min(geo.size.width * 0.7, geo.size.height * 0.7) 418 | : min(geo.size.width * 0.8, geo.size.height * 0.8) 419 | ) 420 | // Apply 3D rotation using SwiftUI's built-in effect - only for non-device backgrounds 421 | .rotation3DEffect( 422 | tempIs3DEffect ? getRotationAngle() : .zero, 423 | axis: tempIs3DEffect ? getRotationAxis() : (x: 0, y: 0, z: 1), 424 | anchor: getRotationAnchor(), 425 | perspective: tempIs3DEffect ? 0.2 : 0 426 | ) 427 | .shadow(color: .black.opacity(0.3), radius: 15, x: 0, y: 5) 428 | .id(refreshPreview) 429 | } else { 430 | Text("No preview available") 431 | .foregroundColor(.secondary) 432 | } 433 | } 434 | } 435 | 436 | // MARK: - Helper Methods 437 | 438 | /** 439 | * Opens a file picker to select a background image 440 | */ 441 | private func selectBackgroundImage() { 442 | let openPanel = NSOpenPanel() 443 | openPanel.allowsMultipleSelection = false 444 | openPanel.canChooseDirectories = false 445 | openPanel.canChooseFiles = true 446 | openPanel.allowedContentTypes = [.png, .jpeg, .jpg, .tiff, .gif, .bmp] 447 | 448 | if openPanel.runModal() == .OK, let url = openPanel.url { 449 | if let image = NSImage(contentsOf: url) { 450 | viewModel.backgroundImage = image 451 | updateAndApplyChanges() 452 | } 453 | } 454 | } 455 | 456 | /** 457 | * Updates view model properties and applies changes 458 | */ 459 | private func updateAndApplyChanges() { 460 | viewModel.backgroundType = tempBackgroundType 461 | viewModel.backgroundColor = tempBackgroundColor 462 | viewModel.backgroundGradient = tempBackgroundGradient 463 | viewModel.is3DEffect = tempIs3DEffect 464 | viewModel.perspective3DDirection = tempPerspective3DDirection 465 | viewModel.aspectRatio = tempAspectRatio 466 | viewModel.imagePadding = tempImagePadding 467 | viewModel.cornerRadius = tempCornerRadius 468 | 469 | // Apply the background change 470 | viewModel.applyBackground() 471 | 472 | // Trigger preview refresh 473 | refreshPreview.toggle() 474 | } 475 | 476 | /** 477 | * Applies all changes permanently 478 | */ 479 | private func applyChanges() { 480 | updateAndApplyChanges() 481 | } 482 | 483 | /** 484 | * Returns an appropriate system icon name for each perspective direction 485 | */ 486 | private func directionIcon(for direction: Perspective3DDirection) -> String { 487 | switch direction { 488 | case .topLeft: 489 | return "arrow.up.left" 490 | case .top: 491 | return "arrow.up" 492 | case .topRight: 493 | return "arrow.up.right" 494 | case .bottomLeft: 495 | return "arrow.down.left" 496 | case .bottom: 497 | return "arrow.down" 498 | case .bottomRight: 499 | return "arrow.down.right" 500 | } 501 | } 502 | 503 | /** 504 | * Returns the 3D rotation angle based on selected direction 505 | */ 506 | private func getRotationAngle() -> Angle { 507 | switch tempPerspective3DDirection { 508 | case .topLeft, .top, .topRight: 509 | return .degrees(15) // Increase from 10 to 15 degrees 510 | case .bottomLeft, .bottom, .bottomRight: 511 | return .degrees(-15) // Increase from -10 to -15 degrees 512 | } 513 | } 514 | 515 | /** 516 | * Returns the 3D rotation axis based on selected direction 517 | */ 518 | private func getRotationAxis() -> (x: CGFloat, y: CGFloat, z: CGFloat) { 519 | switch tempPerspective3DDirection { 520 | case .topLeft: 521 | return (x: 1, y: 1, z: 0) 522 | case .top: 523 | return (x: 1, y: 0, z: 0) 524 | case .topRight: 525 | return (x: 1, y: -1, z: 0) 526 | case .bottomLeft: 527 | return (x: -1, y: 1, z: 0) 528 | case .bottom: 529 | return (x: -1, y: 0, z: 0) 530 | case .bottomRight: 531 | return (x: -1, y: -1, z: 0) 532 | } 533 | } 534 | 535 | /** 536 | * Returns the anchor point for rotation based on direction 537 | */ 538 | private func getRotationAnchor() -> UnitPoint { 539 | switch tempPerspective3DDirection { 540 | case .topLeft: 541 | return .topLeading 542 | case .top: 543 | return .top 544 | case .topRight: 545 | return .topTrailing 546 | case .bottomLeft: 547 | return .bottomLeading 548 | case .bottom: 549 | return .bottom 550 | case .bottomRight: 551 | return .bottomTrailing 552 | } 553 | } 554 | } -------------------------------------------------------------------------------- /freescreenshot/Views/EditorView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EditorView.swift 3 | // freescreenshot 4 | // 5 | // Created by Samik Choudhury on 06/04/25. 6 | // 7 | 8 | import SwiftUI 9 | import UniformTypeIdentifiers 10 | import Cocoa 11 | 12 | /** 13 | * EditorView: Main view for editing screenshots 14 | * Provides toolbar with editing tools and canvas for manipulation 15 | */ 16 | struct EditorView: View { 17 | @ObservedObject var viewModel: EditorViewModel 18 | @Environment(\.presentationMode) var presentationMode 19 | 20 | // Editor state 21 | @State private var isShowingBackgroundPicker = false 22 | @State private var isShowingSaveDialog = false 23 | 24 | /** 25 | * Calculate canvas width based on aspect ratio 26 | */ 27 | private var calculatedCanvasWidth: CGFloat { 28 | if let image = viewModel.image { 29 | let aspectRatio = viewModel.aspectRatio.ratio 30 | if aspectRatio >= 1 { 31 | // For landscape or square 32 | return min(700, max(500, image.size.width * 1.2)) 33 | } else { 34 | // For portrait 35 | return min(600, max(400, image.size.height * 1.2 * aspectRatio)) 36 | } 37 | } 38 | return 600 // Default 39 | } 40 | 41 | /** 42 | * Calculate canvas height based on aspect ratio 43 | */ 44 | private var calculatedCanvasHeight: CGFloat { 45 | if let image = viewModel.image { 46 | let aspectRatio = viewModel.aspectRatio.ratio 47 | if aspectRatio >= 1 { 48 | // For landscape or square 49 | return calculatedCanvasWidth / aspectRatio 50 | } else { 51 | // For portrait 52 | return min(800, max(500, image.size.height * 1.2)) 53 | } 54 | } 55 | return 500 // Default 56 | } 57 | 58 | var body: some View { 59 | VStack(spacing: 0) { 60 | // Toolbar - simplified to only show background feature 61 | HStack { 62 | Spacer() 63 | Text("Screenshot Background Tool") 64 | .font(.headline) 65 | Spacer() 66 | } 67 | .padding(.vertical, 12) 68 | .background(Color(NSColor.controlBackgroundColor)) 69 | 70 | // Main editor canvas 71 | GeometryReader { geometry in 72 | ScrollView([.horizontal, .vertical], showsIndicators: true) { 73 | editorCanvasView 74 | // Dynamic sizing based on aspect ratio and available space 75 | .frame( 76 | width: calculatedCanvasWidth, 77 | height: calculatedCanvasHeight 78 | ) 79 | .frame(maxWidth: .infinity) 80 | .frame(maxHeight: .infinity) 81 | .padding(20) 82 | } 83 | .background(Color(NSColor.windowBackgroundColor)) 84 | .frame(maxWidth: .infinity) 85 | .frame(maxHeight: .infinity) 86 | } 87 | .frame(minHeight: 450) 88 | 89 | // Bottom toolbar with background and export buttons 90 | HStack(spacing: 16) { 91 | // Background button 92 | Button { 93 | isShowingBackgroundPicker = true 94 | } label: { 95 | VStack(spacing: 4) { 96 | Image(systemName: "photo.fill") 97 | .font(.title2) 98 | .frame(width: 30, height: 30) 99 | 100 | Text("Background") 101 | .font(.caption) 102 | } 103 | .padding(.vertical, 8) 104 | .padding(.horizontal, 16) 105 | .background( 106 | RoundedRectangle(cornerRadius: 8) 107 | .fill(isShowingBackgroundPicker ? Color.accentColor.opacity(0.2) : Color.clear) 108 | ) 109 | .foregroundColor(isShowingBackgroundPicker ? .accentColor : .primary) 110 | } 111 | .buttonStyle(.plain) 112 | 113 | Spacer() 114 | 115 | // Export button 116 | Button { 117 | exportImage() 118 | } label: { 119 | Text("Export") 120 | .fontWeight(.medium) 121 | .padding(.horizontal, 16) 122 | .padding(.vertical, 8) 123 | .background(Color.accentColor) 124 | .foregroundColor(.white) 125 | .cornerRadius(6) 126 | } 127 | } 128 | .padding(16) 129 | .background(Color(NSColor.controlBackgroundColor)) 130 | } 131 | .frame(minWidth: 700) 132 | .frame(minHeight: 600) 133 | .sheet(isPresented: $isShowingBackgroundPicker) { 134 | BackgroundPicker(viewModel: viewModel, isPresented: $isShowingBackgroundPicker) 135 | } 136 | .navigationTitle("Screenshot Background Tool") 137 | .toolbar { 138 | ToolbarItem(placement: .navigation) { 139 | Button("Back") { 140 | presentationMode.wrappedValue.dismiss() 141 | } 142 | } 143 | } 144 | } 145 | 146 | /** 147 | * Creates the main editor canvas view 148 | */ 149 | private var editorCanvasView: some View { 150 | // Show only the processed image with background 151 | Group { 152 | if let image = viewModel.image { 153 | GeometryReader { geo in 154 | // Container with static background 155 | ZStack { 156 | // Static background that never rotates - provide extra padding for 3D rotation 157 | if viewModel.backgroundType != .none { 158 | RoundedRectangle(cornerRadius: 12) 159 | .fill(Color(NSColor.windowBackgroundColor).opacity(0.5)) 160 | .frame( 161 | // Increased canvas size to accommodate 3D rotation 162 | width: min(geo.size.width * 0.95, geo.size.height * 0.95), 163 | height: min(geo.size.width * 0.95, geo.size.height * 0.95) 164 | ) 165 | .shadow(color: Color.black.opacity(0.1), radius: 5, x: 0, y: 2) 166 | } 167 | 168 | // Only apply 3D rotation to the screenshot, not the background 169 | Image(nsImage: image) 170 | .resizable() 171 | .scaledToFit() 172 | .frame( 173 | // Make image smaller to ensure it stays within canvas when rotated 174 | width: viewModel.is3DEffect 175 | ? min(geo.size.width * 0.7, geo.size.height * 0.7) 176 | : min(geo.size.width * 0.8, geo.size.height * 0.8), 177 | height: viewModel.is3DEffect 178 | ? min(geo.size.width * 0.7, geo.size.height * 0.7) 179 | : min(geo.size.width * 0.8, geo.size.height * 0.8) 180 | ) 181 | // Apply 3D rotation effect only to the image and only if not in device mockup mode 182 | .rotation3DEffect( 183 | viewModel.is3DEffect ? get3DRotationAngle() : .zero, 184 | axis: viewModel.is3DEffect ? get3DRotationAxis() : (x: 0, y: 0, z: 1), 185 | anchor: get3DRotationAnchor(), 186 | perspective: viewModel.is3DEffect ? 0.2 : 0 187 | ) 188 | .shadow(color: Color.black.opacity(0.3), radius: 15, x: 0, y: 5) 189 | .id(image.hashValue) // Force refresh when image changes 190 | } 191 | .frame(maxWidth: geo.size.width, maxHeight: geo.size.height) 192 | } 193 | .frame(maxWidth: .infinity) 194 | .frame(maxHeight: .infinity) 195 | } else { 196 | Color(NSColor.windowBackgroundColor) 197 | .frame(maxWidth: .infinity) 198 | .frame(maxHeight: .infinity) 199 | } 200 | } 201 | .background(Color(NSColor.windowBackgroundColor).opacity(0.5)) 202 | .cornerRadius(8) 203 | .shadow(color: Color.black.opacity(0.1), radius: 5, x: 0, y: 2) 204 | } 205 | 206 | /** 207 | * Returns the 3D rotation angle based on selected direction 208 | */ 209 | private func get3DRotationAngle() -> Angle { 210 | switch viewModel.perspective3DDirection { 211 | case .topLeft, .top, .topRight: 212 | return .degrees(15) 213 | case .bottomLeft, .bottom, .bottomRight: 214 | return .degrees(-15) 215 | } 216 | } 217 | 218 | /** 219 | * Returns the 3D rotation axis based on selected direction 220 | */ 221 | private func get3DRotationAxis() -> (x: CGFloat, y: CGFloat, z: CGFloat) { 222 | switch viewModel.perspective3DDirection { 223 | case .topLeft: 224 | return (x: 1, y: 1, z: 0) 225 | case .top: 226 | return (x: 1, y: 0, z: 0) 227 | case .topRight: 228 | return (x: 1, y: -1, z: 0) 229 | case .bottomLeft: 230 | return (x: -1, y: 1, z: 0) 231 | case .bottom: 232 | return (x: -1, y: 0, z: 0) 233 | case .bottomRight: 234 | return (x: -1, y: -1, z: 0) 235 | } 236 | } 237 | 238 | /** 239 | * Returns the anchor point for rotation based on direction 240 | */ 241 | private func get3DRotationAnchor() -> UnitPoint { 242 | switch viewModel.perspective3DDirection { 243 | case .topLeft: 244 | return .topLeading 245 | case .top: 246 | return .top 247 | case .topRight: 248 | return .topTrailing 249 | case .bottomLeft: 250 | return .bottomLeading 251 | case .bottom: 252 | return .bottom 253 | case .bottomRight: 254 | return .bottomTrailing 255 | } 256 | } 257 | 258 | /** 259 | * Exports the screenshot using NSSavePanel instead of fileExporter 260 | * This prevents the app from quitting after export 261 | */ 262 | private func exportImage() { 263 | // Use the viewModel's exportImage method which handles 3D effects properly 264 | guard let exportedImage = viewModel.exportImage() else { return } 265 | 266 | let savePanel = NSSavePanel() 267 | savePanel.allowedContentTypes = [.png, .jpeg] 268 | savePanel.canCreateDirectories = true 269 | savePanel.isExtensionHidden = false 270 | savePanel.title = "Save Screenshot" 271 | savePanel.message = "Choose a location to save your screenshot" 272 | savePanel.nameFieldLabel = "File name:" 273 | savePanel.nameFieldStringValue = "screenshot" 274 | 275 | savePanel.beginSheetModal(for: NSApp.keyWindow ?? NSWindow()) { response in 276 | if response == .OK { 277 | if let url = savePanel.url { 278 | // Get the file extension 279 | let isJpeg = url.pathExtension.lowercased() == "jpg" || url.pathExtension.lowercased() == "jpeg" 280 | 281 | // Convert NSImage to appropriate format (JPEG for smaller file size if selected) 282 | if let tiffData = exportedImage.tiffRepresentation, 283 | let bitmap = NSBitmapImageRep(data: tiffData) { 284 | 285 | let fileData: Data? 286 | 287 | if isJpeg { 288 | // Use JPEG with 80% quality for optimal size/quality balance (under 1MB) 289 | fileData = bitmap.representation(using: .jpeg, 290 | properties: [.compressionFactor: NSNumber(value: 0.8)]) 291 | } else { 292 | // Use PNG for lossless quality when requested 293 | fileData = bitmap.representation(using: .png, properties: [:]) 294 | } 295 | 296 | if let data = fileData { 297 | do { 298 | try data.write(to: url) 299 | print("Image saved successfully to \(url)") 300 | } catch { 301 | print("Error saving image: \(error)") 302 | } 303 | } 304 | } 305 | } 306 | } 307 | } 308 | } 309 | } 310 | 311 | /** 312 | * ImageDocument: Represents an image document for export 313 | */ 314 | struct ImageDocument: FileDocument, @unchecked Sendable { 315 | static var readableContentTypes: [UTType] { [UTType.png, UTType.jpeg] } 316 | 317 | var image: NSImage 318 | 319 | /** 320 | * Initializes an image document with an NSImage 321 | */ 322 | init(image: NSImage) { 323 | self.image = image 324 | } 325 | 326 | /** 327 | * Initializes an image document from file data 328 | */ 329 | init(configuration: ReadConfiguration) throws { 330 | guard let data = configuration.file.regularFileContents, 331 | let image = NSImage(data: data) 332 | else { 333 | throw CocoaError(.fileReadCorruptFile) 334 | } 335 | self.image = image 336 | } 337 | 338 | /** 339 | * Writes the image to a file 340 | */ 341 | func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { 342 | let data = ImageUtilities.imageToData(image, format: .png) ?? Data() 343 | return FileWrapper(regularFileWithContents: data) 344 | } 345 | } 346 | 347 | /** 348 | * Color preview component 349 | */ 350 | struct ColorPreview: View { 351 | let color: NSColor 352 | 353 | var body: some View { 354 | ZStack { 355 | // Checkered background to show transparency 356 | Rectangle() 357 | .foregroundColor(Color.gray.opacity(0.2)) 358 | // Color overlay 359 | Rectangle() 360 | .foregroundColor(Color(color)) 361 | // Border 362 | Rectangle() 363 | .stroke(Color.gray, lineWidth: 1) 364 | } 365 | .cornerRadius(4) 366 | } 367 | } 368 | 369 | /** 370 | * Button style for toolbar buttons 371 | */ 372 | struct ToolbarButtonStyle: ButtonStyle { 373 | var isActive: Bool 374 | 375 | func makeBody(configuration: Configuration) -> some View { 376 | configuration.label 377 | .padding(8) 378 | .background( 379 | ZStack { 380 | // Background fill 381 | RoundedRectangle(cornerRadius: 8) 382 | .foregroundColor(isActive ? Color.accentColor.opacity(0.2) : Color.clear) 383 | 384 | // Border 385 | if isActive { 386 | RoundedRectangle(cornerRadius: 8) 387 | .stroke(Color.accentColor, lineWidth: 1) 388 | } 389 | } 390 | ) 391 | .foregroundColor(isActive ? .accentColor : .primary) 392 | .scaleEffect(configuration.isPressed ? 0.95 : 1.0) 393 | } 394 | } 395 | 396 | /** 397 | * Primary button style for main actions 398 | */ 399 | struct PrimaryButtonStyle: ButtonStyle { 400 | func makeBody(configuration: Configuration) -> some View { 401 | configuration.label 402 | .padding(.horizontal, 16) 403 | .padding(.vertical, 8) 404 | .background( 405 | RoundedRectangle(cornerRadius: 8) 406 | .fill(Color.accentColor) 407 | ) 408 | .foregroundColor(.white) 409 | .scaleEffect(configuration.isPressed ? 0.95 : 1.0) 410 | .animation(.spring(response: 0.2), value: configuration.isPressed) 411 | } 412 | } -------------------------------------------------------------------------------- /freescreenshot/Views/ExportView.swift: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /freescreenshot/freescreenshot.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-write 8 | 9 | com.apple.security.files.user-selected.read-only 10 | 11 | com.apple.security.device.camera 12 | 13 | com.apple.security.device.microphone 14 | 15 | com.apple.security.network.client 16 | 17 | com.apple.security.personal-information.photos-library 18 | 19 | com.apple.security.temporary-exception.apple-events 20 | 21 | com.apple.systemevents 22 | com.apple.finder 23 | 24 | com.apple.security.temporary-exception.screen-capture 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /freescreenshot/freescreenshotApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // freescreenshotApp.swift 3 | // freescreenshot 4 | // 5 | // Created by Samik Choudhury on 06/04/25. 6 | // 7 | 8 | import SwiftUI 9 | import Cocoa 10 | import HotKey 11 | import Carbon.HIToolbox 12 | import ServiceManagement 13 | 14 | /** 15 | * Main application class that sets up the UI and keyboard shortcuts 16 | */ 17 | @main 18 | struct FreeScreenshotApp: App { 19 | @StateObject private var appState = AppState.shared 20 | @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate 21 | 22 | /** 23 | * Defines the app's UI scene 24 | */ 25 | var body: some Scene { 26 | // Use Settings scene instead of WindowGroup to support menu bar app 27 | Settings { 28 | ContentView() 29 | .environmentObject(self.appState) 30 | } 31 | .commands { 32 | CommandGroup(after: .newItem) { 33 | Button("Take Screenshot") { 34 | self.appState.initiateScreenCapture() 35 | } 36 | .keyboardShortcut("7", modifiers: [.command, .shift]) 37 | } 38 | } 39 | } 40 | } 41 | 42 | /** 43 | * AppDelegate: Handles application lifecycle events and permissions 44 | */ 45 | class AppDelegate: NSObject, NSApplicationDelegate { 46 | // Use a strong property for the status item so it can't be deallocated 47 | private(set) var statusItem: NSStatusItem! = nil 48 | private var hotkey: HotKey? 49 | 50 | // Keep strong references to windows to prevent them from being deallocated 51 | private var launcherWindowController: NSWindowController? 52 | private var windowDelegates = [NSWindowDelegate]() 53 | 54 | // Keep a reference to the app state 55 | private let appState = AppState.shared 56 | 57 | /** 58 | * Called when the application finishes launching 59 | * Requests necessary permissions for screen capture 60 | */ 61 | func applicationDidFinishLaunching(_ notification: Notification) { 62 | // Hide the app from Dock - set this first thing 63 | NSApp.setActivationPolicy(.accessory) 64 | 65 | // Use our improved permission handling 66 | ensureScreenCapturePermission() 67 | 68 | // Set up the status bar item 69 | setupStatusBarItem() 70 | 71 | // Set up global hotkey for taking screenshots (Cmd+Shift+7) 72 | setupHotkey() 73 | 74 | // Configure app to launch at login 75 | setupLoginItem() 76 | 77 | // Check if app was launched directly (not reactivated) 78 | // This could be from Spotlight, Finder, or first launch 79 | if NSApp.windows.isEmpty { 80 | // Open the launcher window when app is first launched 81 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { 82 | self.openLauncher() 83 | } 84 | } 85 | } 86 | 87 | /** 88 | * Called when app is about to terminate 89 | * Only happens with explicit quit, not window close 90 | */ 91 | func applicationWillTerminate(_ notification: Notification) { 92 | // Clean up resources before exit 93 | print("Application is terminating") 94 | // Additional cleanup if needed 95 | } 96 | 97 | /** 98 | * Prevent the app from quitting when all windows are closed 99 | */ 100 | func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { 101 | return false 102 | } 103 | 104 | /** 105 | * Handle reactivation of the app (when icon is clicked in Dock if visible) 106 | */ 107 | func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { 108 | if !flag { 109 | // If no windows visible, open the launcher 110 | openLauncher() 111 | } 112 | return true 113 | } 114 | 115 | /** 116 | * Sets up the status bar (menu bar) item with icon and menu 117 | */ 118 | private func setupStatusBarItem() { 119 | // Create a status item with fixed length to ensure it's visible 120 | statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength) 121 | 122 | if let button = statusItem.button { 123 | // Use standard template image that works well in both light and dark mode 124 | button.image = NSImage(systemSymbolName: "camera.fill", accessibilityDescription: "FreeScreenshot") 125 | 126 | // Create the menu 127 | let menu = NSMenu() 128 | 129 | menu.addItem(NSMenuItem(title: "Take Screenshot", action: #selector(takeScreenshot), keyEquivalent: "")) 130 | menu.addItem(NSMenuItem(title: "Open Launcher", action: #selector(openLauncher), keyEquivalent: "")) 131 | 132 | menu.addItem(NSMenuItem.separator()) 133 | 134 | menu.addItem(NSMenuItem(title: "Configuration", action: #selector(showConfiguration), keyEquivalent: "")) 135 | 136 | menu.addItem(NSMenuItem.separator()) 137 | 138 | // menu.addItem(NSMenuItem(title: "Check for updates", action: #selector(checkForUpdates), keyEquivalent: "")) 139 | menu.addItem(NSMenuItem(title: "About", action: #selector(showAbout), keyEquivalent: "")) 140 | 141 | menu.addItem(NSMenuItem.separator()) 142 | menu.addItem(NSMenuItem(title: "Quit", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")) 143 | 144 | statusItem.menu = menu 145 | } 146 | } 147 | 148 | /** 149 | * Sets up the global hotkey for taking screenshots 150 | */ 151 | private func setupHotkey() { 152 | hotkey = HotKey(key: .seven, modifiers: [.command, .shift]) 153 | 154 | // Set up the hotkey action 155 | hotkey?.keyDownHandler = { [weak self] in 156 | self?.takeScreenshot() 157 | } 158 | } 159 | 160 | /** 161 | * Configure the app to launch at login 162 | */ 163 | private func setupLoginItem() { 164 | // Check if already configured 165 | if !isLoginItemEnabled() { 166 | toggleLaunchAtLogin() 167 | } 168 | } 169 | 170 | /** 171 | * Checks if the app is configured to launch at login 172 | */ 173 | private func isLoginItemEnabled() -> Bool { 174 | if #available(macOS 13.0, *) { 175 | // Use the modern API for macOS 13+ 176 | return SMAppService.mainApp.status == .enabled 177 | } else { 178 | // For older macOS versions, we can't reliably check without using deprecated APIs 179 | // This is a best-effort approach that doesn't trigger deprecation warnings 180 | if let bundleID = Bundle.main.bundleIdentifier { 181 | // Use FileManager to check if the launch agent plist exists 182 | let libraryURL = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first 183 | let launchAgentsURL = libraryURL?.appendingPathComponent("LaunchAgents") 184 | let plistPath = launchAgentsURL?.appendingPathComponent("\(bundleID).plist") 185 | 186 | return plistPath != nil && FileManager.default.fileExists(atPath: plistPath!.path) 187 | } 188 | return false 189 | } 190 | } 191 | 192 | /** 193 | * Toggle whether the app launches at login 194 | */ 195 | @objc private func toggleLaunchAtLogin() { 196 | if let bundleID = Bundle.main.bundleIdentifier { 197 | let loginItemEnabled = !isLoginItemEnabled() 198 | 199 | if loginItemEnabled { 200 | // Using the API available in macOS 13+ 201 | if #available(macOS 13.0, *) { 202 | do { 203 | try SMAppService.mainApp.register() 204 | } catch { 205 | print("Error registering login item: \(error)") 206 | } 207 | } else { 208 | // Fall back to older API for older macOS versions 209 | let helper = SMLoginItemSetEnabled(bundleID as CFString, true) 210 | print("Login item status: \(helper)") 211 | } 212 | } else { 213 | if #available(macOS 13.0, *) { 214 | do { 215 | try SMAppService.mainApp.unregister() 216 | } catch { 217 | print("Error unregistering login item: \(error)") 218 | } 219 | } else { 220 | // Fall back to older API for older macOS versions 221 | let helper = SMLoginItemSetEnabled(bundleID as CFString, false) 222 | print("Login item status: \(helper)") 223 | } 224 | } 225 | 226 | // Update menu item state 227 | if let menu = statusItem?.menu { 228 | for item in menu.items { 229 | if item.title == "Launch at Login" { 230 | item.state = loginItemEnabled ? .on : .off 231 | } 232 | } 233 | } 234 | } 235 | } 236 | 237 | /** 238 | * Captures a screenshot when triggered from menu or hotkey 239 | */ 240 | @objc private func takeScreenshot() { 241 | // Use the singleton AppState 242 | AppState.shared.initiateScreenCapture() 243 | } 244 | 245 | /** 246 | * Open launcher window 247 | */ 248 | @objc private func openLauncher() { 249 | // Switch to regular activation policy before showing window 250 | NSApp.setActivationPolicy(.regular) 251 | 252 | // If we already have a launcher window controller, just show its window 253 | if let windowController = launcherWindowController, let window = windowController.window { 254 | window.makeKeyAndOrderFront(nil) 255 | NSApplication.shared.activate(ignoringOtherApps: true) 256 | return 257 | } 258 | 259 | // Otherwise check if a launcher window is already open 260 | for window in NSApplication.shared.windows { 261 | if window.title == "FreeScreenshot Launcher" { 262 | window.makeKeyAndOrderFront(nil) 263 | NSApplication.shared.activate(ignoringOtherApps: true) 264 | return 265 | } 266 | } 267 | 268 | // Create a new window with ContentView using the shared AppState singleton 269 | let contentView = ContentView() 270 | .environmentObject(AppState.shared) 271 | let hostingController = NSHostingController(rootView: contentView) 272 | 273 | let window = NSWindow( 274 | contentRect: NSRect(x: 0, y: 0, width: 800, height: 600), 275 | styleMask: [.titled, .closable, .miniaturizable, .resizable], 276 | backing: .buffered, 277 | defer: false 278 | ) 279 | 280 | // Set up window properties 281 | window.contentViewController = hostingController 282 | window.title = "FreeScreenshot Launcher" 283 | window.center() 284 | 285 | // Create a window delegate and keep a strong reference to it 286 | let windowDelegate = WindowDelegate() 287 | windowDelegates.append(windowDelegate) 288 | window.delegate = windowDelegate 289 | 290 | // Create a window controller to manage the window lifecycle 291 | let windowController = NSWindowController(window: window) 292 | launcherWindowController = windowController 293 | 294 | windowController.showWindow(nil) 295 | NSApplication.shared.activate(ignoringOtherApps: true) 296 | } 297 | 298 | 299 | /** 300 | * Show configuration settings 301 | */ 302 | @objc private func showConfiguration() { 303 | // Create and display a configuration window with settings 304 | let configMenu = NSMenu(title: "Configuration") 305 | 306 | // Add Launch at Login toggle 307 | let launchAtLoginItem = NSMenuItem(title: "Launch at Login", action: #selector(toggleLaunchAtLogin), keyEquivalent: "") 308 | launchAtLoginItem.state = isLoginItemEnabled() ? .on : .off 309 | configMenu.addItem(launchAtLoginItem) 310 | 311 | // Add other configuration options here 312 | configMenu.addItem(NSMenuItem(title: "Keyboard Shortcuts", action: #selector(configureShortcuts), keyEquivalent: "")) 313 | configMenu.addItem(NSMenuItem(title: "Upload Settings", action: #selector(configureUpload), keyEquivalent: "")) 314 | 315 | // Position and display the menu 316 | if let event = NSApplication.shared.currentEvent { 317 | NSMenu.popUpContextMenu(configMenu, with: event, for: NSApp.mainWindow?.contentView ?? NSView()) 318 | } 319 | } 320 | 321 | /** 322 | * Configure keyboard shortcuts 323 | */ 324 | @objc private func configureShortcuts() { 325 | // Placeholder for keyboard shortcuts configuration 326 | print("Configure keyboard shortcuts") 327 | } 328 | 329 | /** 330 | * Configure upload settings 331 | */ 332 | @objc private func configureUpload() { 333 | // Placeholder for upload settings configuration 334 | print("Configure upload settings") 335 | } 336 | 337 | /** 338 | * Check for app updates 339 | */ 340 | @objc private func checkForUpdates() { 341 | // Placeholder for update checking functionality 342 | print("Check for updates") 343 | } 344 | 345 | /** 346 | * Show about information 347 | */ 348 | @objc private func showAbout() { 349 | // Display about information 350 | let alert = NSAlert() 351 | alert.messageText = "FreeScreenshot" 352 | alert.informativeText = "Version 1.0\n© 2025 Samik Choudhury" 353 | alert.runModal() 354 | } 355 | } 356 | 357 | /** 358 | * AppState: Manages the central state of the application 359 | * Handles screenshot capture process and maintains editor state 360 | */ 361 | class AppState: ObservableObject { 362 | // Shared instance for the application 363 | static let shared = AppState() 364 | 365 | @Published var isCapturingScreen = false 366 | @Published var capturedImage: NSImage? 367 | @Published var isEditorOpen = false 368 | 369 | // Keep strong references to prevent deallocation 370 | private var editorWindowController: NSWindowController? 371 | private var windowDelegates = [NSWindowDelegate]() 372 | 373 | init() { 374 | // Listen for screenshot notifications from global hotkey 375 | NotificationCenter.default.addObserver(self, selector: #selector(handleScreenshotNotification), name: Notification.Name("TakeScreenshot"), object: nil) 376 | } 377 | 378 | deinit { 379 | NotificationCenter.default.removeObserver(self) 380 | } 381 | 382 | @objc private func handleScreenshotNotification() { 383 | initiateScreenCapture() 384 | } 385 | 386 | /** 387 | * Initiates the screen capture process 388 | * Activates crosshair selection tool for user to select screen area 389 | */ 390 | func initiateScreenCapture() { 391 | isCapturingScreen = true 392 | 393 | // Ensure the status item remains visible 394 | DispatchQueue.main.async { 395 | // Make sure we're in accessory mode before starting capture 396 | NSApp.setActivationPolicy(.accessory) 397 | 398 | // Force the status item to refresh by toggling its length 399 | if let appDelegate = NSApp.delegate as? AppDelegate, 400 | let statusItem = appDelegate.statusItem { 401 | let currentLength = statusItem.length 402 | statusItem.length = currentLength + 0.1 403 | statusItem.length = currentLength 404 | } 405 | } 406 | 407 | // Close the main window temporarily if it's open 408 | if let window = NSApplication.shared.windows.first { 409 | window.orderOut(nil) 410 | } 411 | 412 | // Give time for window to close before capturing 413 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { 414 | self.captureScreenWithSelection() 415 | } 416 | } 417 | 418 | /** 419 | * Captures screen with selection using native macOS APIs 420 | * Creates a crosshair selection tool for the user to select an area 421 | */ 422 | private func captureScreenWithSelection() { 423 | let task = Process() 424 | task.launchPath = "/usr/sbin/screencapture" 425 | task.arguments = ["-i", "-s", "-c"] // Interactive, Selection, to Clipboard 426 | 427 | task.terminationHandler = { process in 428 | DispatchQueue.main.async { 429 | self.isCapturingScreen = false 430 | 431 | // Get image from clipboard 432 | if let image = NSPasteboard.general.readObjects(forClasses: [NSImage.self], options: nil)?.first as? NSImage { 433 | self.capturedImage = image 434 | self.isEditorOpen = true 435 | 436 | // Create and show a new editing window 437 | self.showEditingWindow(with: image) 438 | } 439 | } 440 | } 441 | 442 | do { 443 | try task.run() 444 | } catch { 445 | print("Error capturing screenshot: \(error)") 446 | self.isCapturingScreen = false 447 | } 448 | } 449 | 450 | /** 451 | * Shows a new window for editing the captured screenshot 452 | */ 453 | private func showEditingWindow(with image: NSImage) { 454 | // Switch to regular activation policy before showing editor window 455 | DispatchQueue.main.async { 456 | NSApp.setActivationPolicy(.regular) 457 | } 458 | 459 | let editorViewModel = EditorViewModel() 460 | editorViewModel.setImage(image) 461 | 462 | let editorView = EditorView(viewModel: editorViewModel) 463 | let hostingController = NSHostingController(rootView: editorView) 464 | 465 | let window = NSWindow( 466 | contentRect: NSRect(x: 0, y: 0, width: 800, height: 600), 467 | styleMask: [.titled, .closable, .miniaturizable, .resizable], 468 | backing: .buffered, 469 | defer: false 470 | ) 471 | 472 | // Set up window properties 473 | window.contentViewController = hostingController 474 | window.title = "Screenshot Editor" 475 | window.center() 476 | 477 | // Create a window delegate and keep a strong reference to it 478 | let windowDelegate = WindowDelegate() 479 | windowDelegates.append(windowDelegate) 480 | window.delegate = windowDelegate 481 | 482 | // Create a window controller to manage the window lifecycle 483 | let windowController = NSWindowController(window: window) 484 | editorWindowController = windowController 485 | 486 | windowController.showWindow(nil) 487 | NSApplication.shared.activate(ignoringOtherApps: true) 488 | } 489 | } 490 | 491 | /** 492 | * WindowDelegate: Handles window close events to prevent app termination 493 | */ 494 | class WindowDelegate: NSObject, NSWindowDelegate { 495 | /** 496 | * Called when the window close button is clicked 497 | * Just hides the window instead of allowing it to be destructively closed 498 | */ 499 | func windowShouldClose(_ sender: NSWindow) -> Bool { 500 | // Don't actually close the window, just hide it 501 | sender.orderOut(nil) 502 | 503 | // Force activation policy to accessory (menu bar only) after a slight delay 504 | // This ensures all animations complete and app UI state is updated 505 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { 506 | // Double-check if any windows are still visible 507 | let visibleWindows = NSApp.windows.filter { 508 | $0.isVisible && !($0 is NSPanel) && $0.title != "" 509 | } 510 | 511 | if visibleWindows.isEmpty { 512 | // If no visible windows, set app back to accessory mode (menu bar only) 513 | NSApp.setActivationPolicy(.accessory) 514 | 515 | // Force hide dock icon by activating another app briefly then coming back 516 | if let finder = NSWorkspace.shared.runningApplications.first(where: { $0.bundleIdentifier == "com.apple.finder" }) { 517 | finder.activate(options: .activateIgnoringOtherApps) 518 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { 519 | NSApp.activate(ignoringOtherApps: true) 520 | } 521 | } 522 | } 523 | } 524 | 525 | // Return false to prevent the default close behavior 526 | return false 527 | } 528 | 529 | /** 530 | * Called when the window becomes key (active) 531 | * Ensure app is visible in Dock while windows are open 532 | */ 533 | func windowDidBecomeKey(_ notification: Notification) { 534 | // When a window becomes active, make sure app is visible 535 | NSApp.setActivationPolicy(.regular) 536 | } 537 | } 538 | --------------------------------------------------------------------------------