├── .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 |

158 |

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 |
--------------------------------------------------------------------------------