├── Figura ├── Resources │ ├── Assets.xcassets │ │ ├── Contents.json │ │ ├── AppIcon.appiconset │ │ │ ├── AppIcon-128.png │ │ │ ├── AppIcon-16.png │ │ │ ├── AppIcon-256.png │ │ │ ├── AppIcon-32.png │ │ │ ├── AppIcon-512.png │ │ │ ├── AppIcon-1024 1.png │ │ │ ├── AppIcon-256 1.png │ │ │ ├── AppIcon-32 1.png │ │ │ ├── AppIcon-512 1.png │ │ │ ├── AppIcon-64 1.png │ │ │ └── Contents.json │ │ └── AccentColor.colorset │ │ │ └── Contents.json │ ├── Extensions.swift │ ├── GlassButtonStyle.swift │ ├── MenuBarController.swift │ └── UpdateChecker.swift ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json ├── Info.plist ├── Figura.entitlements ├── UI Components │ ├── TitleBarAccessory.swift │ ├── VisualEffectBlur.swift │ ├── WindowAccessor.swift │ └── ButtonGroup.swift ├── Views │ ├── DropZoneView.swift │ ├── LoaderView.swift │ ├── MenuBarView.swift │ └── ContentView.swift └── App │ └── FiguraApp.swift ├── Figura.xcodeproj ├── project.xcworkspace │ └── contents.xcworkspacedata └── project.pbxproj ├── LICENSE └── README.md /Figura/Resources/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Figura/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Figura/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nuance-dev/figura/HEAD/Figura/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-128.png -------------------------------------------------------------------------------- /Figura/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nuance-dev/figura/HEAD/Figura/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-16.png -------------------------------------------------------------------------------- /Figura/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nuance-dev/figura/HEAD/Figura/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-256.png -------------------------------------------------------------------------------- /Figura/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nuance-dev/figura/HEAD/Figura/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-32.png -------------------------------------------------------------------------------- /Figura/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nuance-dev/figura/HEAD/Figura/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-512.png -------------------------------------------------------------------------------- /Figura/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-1024 1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nuance-dev/figura/HEAD/Figura/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-1024 1.png -------------------------------------------------------------------------------- /Figura/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-256 1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nuance-dev/figura/HEAD/Figura/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-256 1.png -------------------------------------------------------------------------------- /Figura/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-32 1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nuance-dev/figura/HEAD/Figura/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-32 1.png -------------------------------------------------------------------------------- /Figura/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-512 1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nuance-dev/figura/HEAD/Figura/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-512 1.png -------------------------------------------------------------------------------- /Figura/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-64 1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nuance-dev/figura/HEAD/Figura/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-64 1.png -------------------------------------------------------------------------------- /Figura.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Figura/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSAppTransportSecurity 6 | 7 | NSAllowsArbitraryLoads 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /Figura/Figura.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.network.client 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /Figura/Resources/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "display-p3", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.850", 9 | "green" : "0.470", 10 | "red" : "0.250" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Figura/UI Components/TitleBarAccessory.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct TitleBarAccessory: View { 4 | @AppStorage("isDarkMode") private var isDarkMode = false 5 | 6 | var body: some View { 7 | Button(action: { 8 | isDarkMode.toggle() 9 | }) { 10 | Image(systemName: isDarkMode ? "sun.max.fill" : "moon.fill") 11 | .foregroundColor(.primary) 12 | } 13 | .buttonStyle(PlainButtonStyle()) 14 | .frame(width: 30, height: 30) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Figura/Resources/Extensions.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | 3 | extension NSPasteboard { 4 | func getImageFromPasteboard() -> NSImage? { 5 | if let image = NSImage(pasteboard: self) { 6 | return image 7 | } 8 | 9 | // Try reading from file URL if image is not directly available 10 | if let url = self.pasteboardItems?.first?.string(forType: .fileURL), 11 | let fileURL = URL(string: url), 12 | let image = NSImage(contentsOf: fileURL) { 13 | return image 14 | } 15 | 16 | return nil 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Figura/UI Components/VisualEffectBlur.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct VisualEffectBlur: NSViewRepresentable { 4 | var material: NSVisualEffectView.Material 5 | var blendingMode: NSVisualEffectView.BlendingMode 6 | 7 | func makeNSView(context: Context) -> NSVisualEffectView { 8 | let view = NSVisualEffectView() 9 | view.state = .active 10 | view.material = material 11 | view.blendingMode = blendingMode 12 | view.alphaValue = 0.9 13 | return view 14 | } 15 | 16 | func updateNSView(_ nsView: NSVisualEffectView, context: Context) {} 17 | } 18 | -------------------------------------------------------------------------------- /Figura/Resources/GlassButtonStyle.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct GlassButtonStyle: ButtonStyle { 4 | func makeBody(configuration: Configuration) -> some View { 5 | configuration.label 6 | .padding(.horizontal, 20) 7 | .padding(.vertical, 10) 8 | .background( 9 | RoundedRectangle(cornerRadius: 8) 10 | .fill(Color.primary.opacity(0.1)) 11 | .overlay( 12 | RoundedRectangle(cornerRadius: 8) 13 | .stroke(Color.primary.opacity(0.2), lineWidth: 1) 14 | ) 15 | ) 16 | .opacity(configuration.isPressed ? 0.8 : 1.0) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Figura/UI Components/WindowAccessor.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct WindowAccessor: NSViewRepresentable { 4 | func makeNSView(context: Context) -> NSView { 5 | let nsView = NSView() 6 | 7 | DispatchQueue.main.async { 8 | if let window = nsView.window { 9 | let titleBarAccessory = NSTitlebarAccessoryViewController() 10 | let hostingView = NSHostingView(rootView: TitleBarAccessory()) 11 | 12 | hostingView.frame.size = hostingView.fittingSize 13 | titleBarAccessory.view = hostingView 14 | titleBarAccessory.layoutAttribute = .trailing 15 | 16 | window.addTitlebarAccessoryViewController(titleBarAccessory) 17 | } 18 | } 19 | 20 | return nsView 21 | } 22 | 23 | func updateNSView(_ nsView: NSView, context: Context) {} 24 | } 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 nuance-dev 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Figura/Views/DropZoneView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct DropZoneView: View { 4 | @Binding var isDragging: Bool 5 | let onTap: () -> Void 6 | 7 | var body: some View { 8 | Button(action: onTap) { 9 | VStack(spacing: 15) { 10 | Image(systemName: "arrow.down.circle") 11 | .font(.system(size: 50)) 12 | .foregroundColor(.secondary) 13 | 14 | Text("Click or drop image here") 15 | .font(.title3) 16 | .foregroundColor(.secondary) 17 | } 18 | .frame(maxWidth: .infinity, maxHeight: .infinity) 19 | .background( 20 | RoundedRectangle(cornerRadius: 15) 21 | .strokeBorder(isDragging ? Color.accentColor : Color.secondary.opacity(0.3), 22 | style: StrokeStyle(lineWidth: 2, dash: [10])) 23 | .background(Color.clear) 24 | ) 25 | .padding() 26 | } 27 | .buttonStyle(PlainButtonStyle()) 28 | .overlay( 29 | isDragging ? 30 | RoundedRectangle(cornerRadius: 15) 31 | .stroke(Color.accentColor, lineWidth: 2) 32 | .padding() 33 | : nil 34 | ) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Figura/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "AppIcon-16.png", 5 | "idiom" : "mac", 6 | "scale" : "1x", 7 | "size" : "16x16" 8 | }, 9 | { 10 | "filename" : "AppIcon-32 1.png", 11 | "idiom" : "mac", 12 | "scale" : "2x", 13 | "size" : "16x16" 14 | }, 15 | { 16 | "filename" : "AppIcon-32.png", 17 | "idiom" : "mac", 18 | "scale" : "1x", 19 | "size" : "32x32" 20 | }, 21 | { 22 | "filename" : "AppIcon-64 1.png", 23 | "idiom" : "mac", 24 | "scale" : "2x", 25 | "size" : "32x32" 26 | }, 27 | { 28 | "filename" : "AppIcon-128.png", 29 | "idiom" : "mac", 30 | "scale" : "1x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "filename" : "AppIcon-256 1.png", 35 | "idiom" : "mac", 36 | "scale" : "2x", 37 | "size" : "128x128" 38 | }, 39 | { 40 | "filename" : "AppIcon-256.png", 41 | "idiom" : "mac", 42 | "scale" : "1x", 43 | "size" : "256x256" 44 | }, 45 | { 46 | "filename" : "AppIcon-512 1.png", 47 | "idiom" : "mac", 48 | "scale" : "2x", 49 | "size" : "256x256" 50 | }, 51 | { 52 | "filename" : "AppIcon-512.png", 53 | "idiom" : "mac", 54 | "scale" : "1x", 55 | "size" : "512x512" 56 | }, 57 | { 58 | "filename" : "AppIcon-1024 1.png", 59 | "idiom" : "mac", 60 | "scale" : "2x", 61 | "size" : "512x512" 62 | } 63 | ], 64 | "info" : { 65 | "author" : "xcode", 66 | "version" : 1 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Figura - A Free MacOS (14+) Native Background Remover 2 | 3 | ![image](https://github.com/user-attachments/assets/dc2dc142-ef86-4655-9034-2cb1e52db842) 4 | 5 | 6 | A sleek, free native macOS app that removes backgrounds from images with a minimal interface and modern design. It's fast! 7 | 8 | Note: This app wraps macOS's own native background removing functionality, and it requires macOS version 14+ 9 | 10 | ## Features 11 | - **One-Click Background Removal**: Instantly remove backgrounds from any image 12 | - **Multiple Input Methods**: Drag & drop, paste (⌘V), or click to upload 13 | - **Native Performance**: Built with SwiftUI and Vision framework for optimal processing 14 | - **Dark and Light modes**: Because we care about your eyes 15 | 16 | 17 | https://github.com/user-attachments/assets/2599e483-8a5e-4147-8a81-c647a6e9f1cb 18 | 19 | 20 | 21 | ## 💻 Get it 22 | 23 | Download from the [releases](https://github.com/nuance-dev/Figura/releases/) page. 24 | 25 | ## 🥑 Fun facts? 26 | - v1 was made with Claude Sonnet 3.5 in under 4 hours 27 | - Yes, I love cats 28 | 29 | ![Screenshot 2024-10-25 at 1 11 45 AM](https://github.com/user-attachments/assets/7d90b952-8049-4036-8761-285df0164985) 30 | 31 | ## 🤝 Contributing 32 | We welcome contributions! Here's how you can help: 33 | 34 | 1. Fork the repository 35 | 2. Create your feature branch (`git checkout -b feature/AmazingFeature`) 36 | 3. Commit your changes (`git commit -m 'Add some AmazingFeature'`) 37 | 4. Push to the branch (`git push origin feature/AmazingFeature`) 38 | 5. Open a Pull Request 39 | 40 | Please ensure your PR: 41 | - Follows the existing code style 42 | - Includes appropriate tests 43 | - Updates documentation as needed 44 | 45 | ## 📝 License 46 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 47 | 48 | ## 🔗 Links 49 | - Website: [Nuance](https://nuanc.me) 50 | - Report issues: [GitHub Issues](https://github.com/nuance-dev/Figura/issues) 51 | - Follow updates: [@Nuanced](https://twitter.com/Nuancedev) 52 | -------------------------------------------------------------------------------- /Figura/App/FiguraApp.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import AppKit 3 | 4 | @main 5 | struct FiguraApp: App { 6 | @AppStorage("isDarkMode") private var isDarkMode = false 7 | @StateObject private var menuBarController = MenuBarController() 8 | @State private var showingUpdateSheet = false 9 | 10 | var body: some Scene { 11 | WindowGroup { 12 | ContentView() 13 | .preferredColorScheme(isDarkMode ? .dark : .light) 14 | .background(WindowAccessor()) 15 | .environmentObject(menuBarController) 16 | .sheet(isPresented: $showingUpdateSheet) { 17 | MenuBarView(updater: menuBarController.updater) 18 | .environmentObject(menuBarController) 19 | } 20 | .onAppear { 21 | // Check for updates when app launches 22 | menuBarController.updater.checkForUpdates() 23 | 24 | // Set up observer for update availability 25 | menuBarController.updater.onUpdateAvailable = { 26 | showingUpdateSheet = true 27 | } 28 | } 29 | } 30 | .windowStyle(HiddenTitleBarWindowStyle()) 31 | .commands { 32 | CommandGroup(after: .appInfo) { 33 | Button("Check for Updates...") { 34 | showingUpdateSheet = true 35 | menuBarController.updater.checkForUpdates() 36 | } 37 | .keyboardShortcut("U", modifiers: [.command]) 38 | 39 | if menuBarController.updater.updateAvailable { 40 | Button("Download Update") { 41 | if let url = menuBarController.updater.downloadURL { 42 | NSWorkspace.shared.open(url) 43 | } 44 | } 45 | } 46 | 47 | Divider() 48 | } 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Figura/UI Components/ButtonGroup.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ToolbarButton: View { 4 | let title: String 5 | let icon: String 6 | let action: () -> Void 7 | let isFirst: Bool 8 | let isLast: Bool 9 | 10 | var body: some View { 11 | Button(action: action) { 12 | HStack(spacing: 6) { 13 | Image(systemName: icon) 14 | .font(.system(size: 14)) 15 | Text(title) 16 | .font(.system(size: 13, weight: .medium)) 17 | } 18 | .frame(height: 36) 19 | .padding(.horizontal, 16) 20 | .foregroundColor(.primary) 21 | .background(Color.clear) 22 | .contentShape(Rectangle()) 23 | } 24 | .buttonStyle(PlainButtonStyle()) 25 | } 26 | } 27 | 28 | struct ButtonDivider: View { 29 | var body: some View { 30 | Divider() 31 | .frame(height: 24) 32 | } 33 | } 34 | 35 | struct ButtonGroup: View { 36 | let buttons: [(title: String, icon: String, action: () -> Void)] 37 | 38 | var body: some View { 39 | HStack(spacing: 0) { 40 | ForEach(Array(buttons.enumerated()), id: \.offset) { index, button in 41 | if index > 0 { 42 | ButtonDivider() 43 | } 44 | 45 | ToolbarButton( 46 | title: button.title, 47 | icon: button.icon, 48 | action: button.action, 49 | isFirst: index == 0, 50 | isLast: index == buttons.count - 1 51 | ) 52 | } 53 | } 54 | .background(backgroundView) 55 | } 56 | 57 | private var backgroundView: some View { 58 | ZStack { 59 | // Base background 60 | RoundedRectangle(cornerRadius: 12) 61 | .fill(Color(NSColor.windowBackgroundColor).opacity(0.5)) 62 | 63 | // Subtle border 64 | RoundedRectangle(cornerRadius: 12) 65 | .strokeBorder(Color.primary.opacity(0.1), lineWidth: 1) 66 | 67 | // Glass effect overlay 68 | RoundedRectangle(cornerRadius: 12) 69 | .stroke(Color.white.opacity(0.1), lineWidth: 1) 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Figura/Views/LoaderView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct LoaderView: View { 4 | @State private var isAnimating = false 5 | private let duration: Double = 1.5 6 | 7 | var body: some View { 8 | ZStack { 9 | // Backdrop blur 10 | Rectangle() 11 | .fill(.ultraThinMaterial) 12 | .ignoresSafeArea() 13 | 14 | // Modern spinner container 15 | ZStack { 16 | // Background card 17 | RoundedRectangle(cornerRadius: 16) 18 | .fill(Color(NSColor.windowBackgroundColor).opacity(0.7)) 19 | .frame(width: 120, height: 120) 20 | .overlay( 21 | RoundedRectangle(cornerRadius: 16) 22 | .stroke(Color.white.opacity(0.1), lineWidth: 1) 23 | ) 24 | .background( 25 | RoundedRectangle(cornerRadius: 16) 26 | .fill(Color.black.opacity(0.2)) 27 | .blur(radius: 10) 28 | ) 29 | 30 | // Spinner rings 31 | ZStack { 32 | // Outer ring 33 | Circle() 34 | .trim(from: 0.2, to: 0.8) 35 | .stroke( 36 | AngularGradient( 37 | gradient: Gradient(colors: [Color.clear, Color.accentColor.opacity(0.3)]), 38 | center: .center, 39 | startAngle: .degrees(0), 40 | endAngle: .degrees(360) 41 | ), 42 | style: StrokeStyle(lineWidth: 4, lineCap: .round) 43 | ) 44 | .frame(width: 54, height: 54) 45 | .rotationEffect(.degrees(isAnimating ? 360 : 0)) 46 | 47 | // Inner ring 48 | Circle() 49 | .trim(from: 0, to: 0.6) 50 | .stroke( 51 | Color.accentColor, 52 | style: StrokeStyle(lineWidth: 4, lineCap: .round) 53 | ) 54 | .frame(width: 38, height: 38) 55 | .rotationEffect(.degrees(isAnimating ? 360 : 0)) 56 | 57 | // Label 58 | Text("Processing") 59 | .font(.system(size: 13, weight: .medium)) 60 | .foregroundColor(.secondary) 61 | .offset(y: 46) 62 | } 63 | } 64 | } 65 | .onAppear { 66 | withAnimation( 67 | .linear(duration: duration) 68 | .repeatForever(autoreverses: false) 69 | ) { 70 | isAnimating = true 71 | } 72 | } 73 | } 74 | } 75 | 76 | #Preview { 77 | LoaderView() 78 | .frame(width: 400, height: 400) 79 | } 80 | -------------------------------------------------------------------------------- /Figura/Resources/MenuBarController.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import AppKit 3 | 4 | class MenuBarController: NSObject, ObservableObject { 5 | @Published private(set) var updater = UpdateChecker() 6 | private var statusItem: NSStatusItem! 7 | 8 | override init() { 9 | super.init() 10 | 11 | // Initialize status item on main queue 12 | DispatchQueue.main.async { 13 | self.setupMenuBar() 14 | self.updater.checkForUpdates() 15 | } 16 | } 17 | 18 | private func setupMenuBar() { 19 | // Create the status item 20 | statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) 21 | 22 | if let button = statusItem.button { 23 | button.image = NSImage(systemSymbolName: "checkmark.circle", accessibilityDescription: "Update Status") 24 | 25 | // Create the menu 26 | let menu = NSMenu() 27 | menu.delegate = self 28 | 29 | // Set the menu 30 | statusItem.menu = menu 31 | 32 | // Update the button image when the status changes 33 | updater.onStatusChange = { [weak self] newIcon in 34 | guard self != nil else { return } 35 | DispatchQueue.main.async { 36 | button.image = NSImage(systemSymbolName: newIcon, accessibilityDescription: "Update Status") 37 | } 38 | } 39 | } 40 | } 41 | 42 | @objc private func checkForUpdates() { 43 | updater.checkForUpdates() 44 | } 45 | 46 | @objc private func downloadUpdate() { 47 | if let url = updater.downloadURL { 48 | NSWorkspace.shared.open(url) 49 | } 50 | } 51 | } 52 | 53 | extension MenuBarController: NSMenuDelegate { 54 | func menuWillOpen(_ menu: NSMenu) { 55 | // Clear existing items 56 | menu.removeAllItems() 57 | 58 | // Add version 59 | let versionItem = NSMenuItem(title: "Figura v\(Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0.0")", action: nil, keyEquivalent: "") 60 | versionItem.isEnabled = false 61 | menu.addItem(versionItem) 62 | 63 | menu.addItem(NSMenuItem.separator()) 64 | 65 | // Add status 66 | if updater.isChecking { 67 | let checkingItem = NSMenuItem(title: "Checking for updates...", action: nil, keyEquivalent: "") 68 | checkingItem.isEnabled = false 69 | menu.addItem(checkingItem) 70 | } else if updater.updateAvailable { 71 | if let version = updater.latestVersion { 72 | let availableItem = NSMenuItem(title: "Version \(version) Available", action: nil, keyEquivalent: "") 73 | availableItem.isEnabled = false 74 | menu.addItem(availableItem) 75 | } 76 | let downloadItem = NSMenuItem(title: "Download Update", action: #selector(downloadUpdate), keyEquivalent: "") 77 | downloadItem.target = self 78 | menu.addItem(downloadItem) 79 | } else { 80 | let upToDateItem = NSMenuItem(title: "App is up to date", action: nil, keyEquivalent: "") 81 | upToDateItem.isEnabled = false 82 | menu.addItem(upToDateItem) 83 | } 84 | 85 | menu.addItem(NSMenuItem.separator()) 86 | 87 | // Add check for updates item 88 | let checkItem = NSMenuItem(title: "Check for Updates...", action: #selector(checkForUpdates), keyEquivalent: "u") 89 | checkItem.target = self 90 | menu.addItem(checkItem) 91 | } 92 | 93 | func menuDidClose(_ menu: NSMenu) { 94 | // Optional: Handle menu closing 95 | } 96 | 97 | func numberOfItems(in menu: NSMenu) -> Int { 98 | // Let the menu build dynamically 99 | return menu.numberOfItems 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /Figura/Views/MenuBarView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import AppKit 3 | 4 | struct MenuBarView: View { 5 | @ObservedObject var updater: UpdateChecker 6 | @EnvironmentObject var menuBarController: MenuBarController 7 | @Environment(\.dismiss) var dismiss 8 | 9 | private var appIcon: NSImage { 10 | if let bundleIcon = NSImage(named: NSImage.applicationIconName) { 11 | return bundleIcon 12 | } 13 | return NSWorkspace.shared.icon(forFile: Bundle.main.bundlePath) 14 | } 15 | 16 | var body: some View { 17 | VStack(spacing: 16) { 18 | // App Icon and Version 19 | VStack(spacing: 8) { 20 | Image(nsImage: appIcon) 21 | .resizable() 22 | .aspectRatio(contentMode: .fit) 23 | .frame(width: 64, height: 64) 24 | 25 | Text("Version \(Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0.0")") 26 | .font(.subheadline) 27 | .foregroundColor(.secondary) 28 | } 29 | .padding(.top, 16) 30 | 31 | // Status Section 32 | Group { 33 | if updater.isChecking { 34 | VStack(spacing: 8) { 35 | ProgressView() 36 | .scaleEffect(1.2) 37 | Text("Checking for updates...") 38 | .font(.headline) 39 | .foregroundColor(.secondary) 40 | } 41 | } else if let error = updater.error { 42 | VStack(spacing: 8) { 43 | Image(systemName: "xmark.circle.fill") 44 | .font(.system(size: 28)) 45 | .foregroundColor(.red) 46 | Text(error) 47 | .font(.subheadline) 48 | .foregroundColor(.secondary) 49 | .multilineTextAlignment(.center) 50 | } 51 | } else if updater.updateAvailable { 52 | VStack(spacing: 12) { 53 | Image(systemName: "arrow.down.circle.fill") 54 | .font(.system(size: 28)) 55 | .foregroundColor(.blue) 56 | 57 | if let version = updater.latestVersion { 58 | Text("Version \(version) Available") 59 | .font(.headline) 60 | } 61 | 62 | if let notes = updater.releaseNotes { 63 | ScrollView { 64 | Text(notes) 65 | .font(.footnote) 66 | .foregroundColor(.secondary) 67 | .multilineTextAlignment(.center) 68 | .padding(.horizontal) 69 | } 70 | .frame(maxHeight: 80) 71 | } 72 | 73 | Button { 74 | if let url = updater.downloadURL { 75 | NSWorkspace.shared.open(url) 76 | dismiss() 77 | } 78 | } label: { 79 | Text("Download Update") 80 | .frame(maxWidth: 200) 81 | } 82 | .buttonStyle(.borderedProminent) 83 | } 84 | } else { 85 | VStack(spacing: 8) { 86 | Image(systemName: "checkmark.circle.fill") 87 | .font(.system(size: 28)) 88 | .foregroundColor(.green) 89 | Text("Figura is up to date") 90 | .font(.headline) 91 | } 92 | } 93 | } 94 | .frame(maxWidth: .infinity, alignment: .center) 95 | .padding(.vertical, 8) 96 | 97 | Divider() 98 | 99 | // Bottom Buttons 100 | HStack(spacing: 16) { 101 | Button("Check Again") { 102 | updater.checkForUpdates() 103 | } 104 | .buttonStyle(.plain) 105 | .foregroundColor(.blue) 106 | 107 | Button("Close") { 108 | dismiss() 109 | } 110 | .buttonStyle(.plain) 111 | .foregroundColor(.secondary) 112 | } 113 | .padding(.bottom, 16) 114 | 115 | Text("Built by [Nuance](https://nuancedev.vercel.app)") 116 | .font(.footnote) 117 | .foregroundColor(.secondary) 118 | .padding(.bottom, 8) 119 | } 120 | .padding(.horizontal) 121 | .frame(width: 300) 122 | .fixedSize(horizontal: false, vertical: true) 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /Figura/Resources/UpdateChecker.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct GitHubRelease: Codable { 4 | let tagName: String 5 | let name: String 6 | let body: String 7 | let htmlUrl: String 8 | 9 | enum CodingKeys: String, CodingKey { 10 | case tagName = "tag_name" 11 | case name 12 | case body 13 | case htmlUrl = "html_url" 14 | } 15 | } 16 | 17 | class UpdateChecker: ObservableObject { 18 | @Published var updateAvailable = false 19 | @Published var latestVersion: String? 20 | @Published var releaseNotes: String? 21 | @Published var downloadURL: URL? 22 | @Published var isChecking = false 23 | @Published var error: String? 24 | @Published var statusIcon: String = "checkmark.circle" 25 | 26 | var onStatusChange: ((String) -> Void)? 27 | var onUpdateAvailable: (() -> Void)? 28 | 29 | private let currentVersion: String 30 | private let githubRepo: String 31 | private var updateCheckTimer: Timer? 32 | 33 | init() { 34 | self.currentVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0.0" 35 | self.githubRepo = "nuance-dev/Figura" 36 | setupTimer() 37 | updateStatusIcon() 38 | } 39 | 40 | private func setupTimer() { 41 | // Initial check after 2 seconds 42 | DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in 43 | self?.checkForUpdates() 44 | } 45 | 46 | // Periodic check every 24 hours 47 | updateCheckTimer = Timer.scheduledTimer(withTimeInterval: 24 * 60 * 60, repeats: true) { [weak self] _ in 48 | self?.checkForUpdates() 49 | } 50 | } 51 | 52 | private func updateStatusIcon() { 53 | DispatchQueue.main.async { [weak self] in 54 | guard let self = self else { return } 55 | if self.isChecking { 56 | self.statusIcon = "arrow.triangle.2.circlepath" 57 | } else { 58 | self.statusIcon = self.updateAvailable ? "exclamationmark.circle" : "checkmark.circle" 59 | } 60 | self.onStatusChange?(self.statusIcon) 61 | } 62 | } 63 | 64 | func checkForUpdates() { 65 | print("Checking for updates...") 66 | print("Current version: \(currentVersion)") 67 | 68 | isChecking = true 69 | updateStatusIcon() 70 | error = nil 71 | 72 | let baseURL = "https://api.github.com/repos/\(githubRepo)/releases/latest" 73 | guard let url = URL(string: baseURL) else { 74 | error = "Invalid GitHub repository URL" 75 | isChecking = false 76 | updateStatusIcon() 77 | return 78 | } 79 | 80 | var request = URLRequest(url: url) 81 | request.setValue("application/vnd.github.v3+json", forHTTPHeaderField: "Accept") 82 | request.setValue("Figura-App/\(currentVersion)", forHTTPHeaderField: "User-Agent") 83 | 84 | URLSession.shared.dataTask(with: request) { [weak self] data, response, error in 85 | DispatchQueue.main.async { 86 | self?.handleUpdateResponse(data: data, response: response as? HTTPURLResponse, error: error) 87 | } 88 | }.resume() 89 | } 90 | 91 | private func handleUpdateResponse(data: Data?, response: HTTPURLResponse?, error: Error?) { 92 | defer { 93 | isChecking = false 94 | updateStatusIcon() 95 | } 96 | 97 | if let error = error { 98 | print("Network error: \(error)") 99 | self.error = "Network error: \(error.localizedDescription)" 100 | return 101 | } 102 | 103 | guard let response = response else { 104 | print("Invalid response") 105 | self.error = "Invalid response from server" 106 | return 107 | } 108 | 109 | print("Response status code: \(response.statusCode)") 110 | 111 | guard response.statusCode == 200 else { 112 | self.error = "Server error: \(response.statusCode)" 113 | return 114 | } 115 | 116 | guard let data = data else { 117 | self.error = "No data received" 118 | return 119 | } 120 | 121 | do { 122 | 123 | let decoder = JSONDecoder() 124 | let release = try decoder.decode(GitHubRelease.self, from: data) 125 | 126 | let cleanLatestVersion = release.tagName.replacingOccurrences(of: "v", with: "") 127 | print("Latest version: \(cleanLatestVersion)") 128 | print("Current version for comparison: \(currentVersion)") 129 | 130 | updateAvailable = compareVersions(current: currentVersion, latest: cleanLatestVersion) 131 | if updateAvailable { 132 | DispatchQueue.main.async { 133 | self.onUpdateAvailable?() 134 | } 135 | } 136 | 137 | latestVersion = cleanLatestVersion 138 | releaseNotes = release.body 139 | downloadURL = URL(string: release.htmlUrl) 140 | 141 | updateAvailable = compareVersions(current: currentVersion, latest: cleanLatestVersion) 142 | print("Update available: \(updateAvailable)") 143 | 144 | } catch { 145 | print("Parsing error: \(error)") 146 | self.error = "Failed to parse response: \(error.localizedDescription)" 147 | } 148 | } 149 | 150 | private func compareVersions(current: String, latest: String) -> Bool { 151 | // Clean and split versions 152 | let currentParts = current.replacingOccurrences(of: "v", with: "") 153 | .split(separator: ".") 154 | .compactMap { Int($0) } 155 | 156 | let latestParts = latest.replacingOccurrences(of: "v", with: "") 157 | .split(separator: ".") 158 | .compactMap { Int($0) } 159 | 160 | 161 | // Ensure we have at least 3 components (major.minor.patch) 162 | let paddedCurrent = currentParts + Array(repeating: 0, count: max(3 - currentParts.count, 0)) 163 | let paddedLatest = latestParts + Array(repeating: 0, count: max(3 - latestParts.count, 0)) 164 | 165 | 166 | // Compare each version component 167 | for i in 0.. paddedCurrent[i] { 169 | return true 170 | } else if paddedLatest[i] < paddedCurrent[i] { 171 | return false 172 | } 173 | } 174 | 175 | print("Versions are equal") 176 | return false 177 | } 178 | 179 | deinit { 180 | updateCheckTimer?.invalidate() 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /Figura.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 77; 7 | objects = { 8 | 9 | /* Begin PBXFileReference section */ 10 | C82F044E2CCB3DD20012C07B /* Figura.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Figura.app; sourceTree = BUILT_PRODUCTS_DIR; }; 11 | /* End PBXFileReference section */ 12 | 13 | /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ 14 | C8D40BC42CDB0860000D620E /* Exceptions for "Figura" folder in "Figura" target */ = { 15 | isa = PBXFileSystemSynchronizedBuildFileExceptionSet; 16 | membershipExceptions = ( 17 | Info.plist, 18 | ); 19 | target = C82F044D2CCB3DD20012C07B /* Figura */; 20 | }; 21 | /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ 22 | 23 | /* Begin PBXFileSystemSynchronizedRootGroup section */ 24 | C82F04502CCB3DD20012C07B /* Figura */ = { 25 | isa = PBXFileSystemSynchronizedRootGroup; 26 | exceptions = ( 27 | C8D40BC42CDB0860000D620E /* Exceptions for "Figura" folder in "Figura" target */, 28 | ); 29 | path = Figura; 30 | sourceTree = ""; 31 | }; 32 | /* End PBXFileSystemSynchronizedRootGroup section */ 33 | 34 | /* Begin PBXFrameworksBuildPhase section */ 35 | C82F044B2CCB3DD20012C07B /* Frameworks */ = { 36 | isa = PBXFrameworksBuildPhase; 37 | buildActionMask = 2147483647; 38 | files = ( 39 | ); 40 | runOnlyForDeploymentPostprocessing = 0; 41 | }; 42 | /* End PBXFrameworksBuildPhase section */ 43 | 44 | /* Begin PBXGroup section */ 45 | C82F04452CCB3DD20012C07B = { 46 | isa = PBXGroup; 47 | children = ( 48 | C82F04502CCB3DD20012C07B /* Figura */, 49 | C82F044F2CCB3DD20012C07B /* Products */, 50 | ); 51 | sourceTree = ""; 52 | }; 53 | C82F044F2CCB3DD20012C07B /* Products */ = { 54 | isa = PBXGroup; 55 | children = ( 56 | C82F044E2CCB3DD20012C07B /* Figura.app */, 57 | ); 58 | name = Products; 59 | sourceTree = ""; 60 | }; 61 | /* End PBXGroup section */ 62 | 63 | /* Begin PBXNativeTarget section */ 64 | C82F044D2CCB3DD20012C07B /* Figura */ = { 65 | isa = PBXNativeTarget; 66 | buildConfigurationList = C82F045D2CCB3DD40012C07B /* Build configuration list for PBXNativeTarget "Figura" */; 67 | buildPhases = ( 68 | C82F044A2CCB3DD20012C07B /* Sources */, 69 | C82F044B2CCB3DD20012C07B /* Frameworks */, 70 | C82F044C2CCB3DD20012C07B /* Resources */, 71 | ); 72 | buildRules = ( 73 | ); 74 | dependencies = ( 75 | ); 76 | fileSystemSynchronizedGroups = ( 77 | C82F04502CCB3DD20012C07B /* Figura */, 78 | ); 79 | name = Figura; 80 | packageProductDependencies = ( 81 | ); 82 | productName = Figura; 83 | productReference = C82F044E2CCB3DD20012C07B /* Figura.app */; 84 | productType = "com.apple.product-type.application"; 85 | }; 86 | /* End PBXNativeTarget section */ 87 | 88 | /* Begin PBXProject section */ 89 | C82F04462CCB3DD20012C07B /* Project object */ = { 90 | isa = PBXProject; 91 | attributes = { 92 | BuildIndependentTargetsInParallel = 1; 93 | LastSwiftUpdateCheck = 1600; 94 | LastUpgradeCheck = 1610; 95 | TargetAttributes = { 96 | C82F044D2CCB3DD20012C07B = { 97 | CreatedOnToolsVersion = 16.0; 98 | }; 99 | }; 100 | }; 101 | buildConfigurationList = C82F04492CCB3DD20012C07B /* Build configuration list for PBXProject "Figura" */; 102 | developmentRegion = en; 103 | hasScannedForEncodings = 0; 104 | knownRegions = ( 105 | en, 106 | Base, 107 | ); 108 | mainGroup = C82F04452CCB3DD20012C07B; 109 | minimizedProjectReferenceProxies = 1; 110 | preferredProjectObjectVersion = 77; 111 | productRefGroup = C82F044F2CCB3DD20012C07B /* Products */; 112 | projectDirPath = ""; 113 | projectRoot = ""; 114 | targets = ( 115 | C82F044D2CCB3DD20012C07B /* Figura */, 116 | ); 117 | }; 118 | /* End PBXProject section */ 119 | 120 | /* Begin PBXResourcesBuildPhase section */ 121 | C82F044C2CCB3DD20012C07B /* Resources */ = { 122 | isa = PBXResourcesBuildPhase; 123 | buildActionMask = 2147483647; 124 | files = ( 125 | ); 126 | runOnlyForDeploymentPostprocessing = 0; 127 | }; 128 | /* End PBXResourcesBuildPhase section */ 129 | 130 | /* Begin PBXSourcesBuildPhase section */ 131 | C82F044A2CCB3DD20012C07B /* Sources */ = { 132 | isa = PBXSourcesBuildPhase; 133 | buildActionMask = 2147483647; 134 | files = ( 135 | ); 136 | runOnlyForDeploymentPostprocessing = 0; 137 | }; 138 | /* End PBXSourcesBuildPhase section */ 139 | 140 | /* Begin XCBuildConfiguration section */ 141 | C82F045B2CCB3DD40012C07B /* Debug */ = { 142 | isa = XCBuildConfiguration; 143 | buildSettings = { 144 | ALWAYS_SEARCH_USER_PATHS = NO; 145 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 146 | CLANG_ANALYZER_NONNULL = YES; 147 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 148 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 149 | CLANG_ENABLE_MODULES = YES; 150 | CLANG_ENABLE_OBJC_ARC = YES; 151 | CLANG_ENABLE_OBJC_WEAK = YES; 152 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 153 | CLANG_WARN_BOOL_CONVERSION = YES; 154 | CLANG_WARN_COMMA = YES; 155 | CLANG_WARN_CONSTANT_CONVERSION = YES; 156 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 157 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 158 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 159 | CLANG_WARN_EMPTY_BODY = YES; 160 | CLANG_WARN_ENUM_CONVERSION = YES; 161 | CLANG_WARN_INFINITE_RECURSION = YES; 162 | CLANG_WARN_INT_CONVERSION = YES; 163 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 164 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 165 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 166 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 167 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 168 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 169 | CLANG_WARN_STRICT_PROTOTYPES = YES; 170 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 171 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 172 | CLANG_WARN_UNREACHABLE_CODE = YES; 173 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 174 | COPY_PHASE_STRIP = NO; 175 | DEAD_CODE_STRIPPING = YES; 176 | DEBUG_INFORMATION_FORMAT = dwarf; 177 | ENABLE_STRICT_OBJC_MSGSEND = YES; 178 | ENABLE_TESTABILITY = YES; 179 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 180 | GCC_C_LANGUAGE_STANDARD = gnu17; 181 | GCC_DYNAMIC_NO_PIC = NO; 182 | GCC_NO_COMMON_BLOCKS = YES; 183 | GCC_OPTIMIZATION_LEVEL = 0; 184 | GCC_PREPROCESSOR_DEFINITIONS = ( 185 | "DEBUG=1", 186 | "$(inherited)", 187 | ); 188 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 189 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 190 | GCC_WARN_UNDECLARED_SELECTOR = YES; 191 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 192 | GCC_WARN_UNUSED_FUNCTION = YES; 193 | GCC_WARN_UNUSED_VARIABLE = YES; 194 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 195 | MACOSX_DEPLOYMENT_TARGET = 15.0; 196 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 197 | MTL_FAST_MATH = YES; 198 | ONLY_ACTIVE_ARCH = YES; 199 | SDKROOT = macosx; 200 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; 201 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 202 | }; 203 | name = Debug; 204 | }; 205 | C82F045C2CCB3DD40012C07B /* Release */ = { 206 | isa = XCBuildConfiguration; 207 | buildSettings = { 208 | ALWAYS_SEARCH_USER_PATHS = NO; 209 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 210 | CLANG_ANALYZER_NONNULL = YES; 211 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 212 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 213 | CLANG_ENABLE_MODULES = YES; 214 | CLANG_ENABLE_OBJC_ARC = YES; 215 | CLANG_ENABLE_OBJC_WEAK = YES; 216 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 217 | CLANG_WARN_BOOL_CONVERSION = YES; 218 | CLANG_WARN_COMMA = YES; 219 | CLANG_WARN_CONSTANT_CONVERSION = YES; 220 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 221 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 222 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 223 | CLANG_WARN_EMPTY_BODY = YES; 224 | CLANG_WARN_ENUM_CONVERSION = YES; 225 | CLANG_WARN_INFINITE_RECURSION = YES; 226 | CLANG_WARN_INT_CONVERSION = YES; 227 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 228 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 229 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 230 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 231 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 232 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 233 | CLANG_WARN_STRICT_PROTOTYPES = YES; 234 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 235 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 236 | CLANG_WARN_UNREACHABLE_CODE = YES; 237 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 238 | COPY_PHASE_STRIP = NO; 239 | DEAD_CODE_STRIPPING = YES; 240 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 241 | ENABLE_NS_ASSERTIONS = NO; 242 | ENABLE_STRICT_OBJC_MSGSEND = YES; 243 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 244 | GCC_C_LANGUAGE_STANDARD = gnu17; 245 | GCC_NO_COMMON_BLOCKS = YES; 246 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 247 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 248 | GCC_WARN_UNDECLARED_SELECTOR = YES; 249 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 250 | GCC_WARN_UNUSED_FUNCTION = YES; 251 | GCC_WARN_UNUSED_VARIABLE = YES; 252 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 253 | MACOSX_DEPLOYMENT_TARGET = 15.0; 254 | MTL_ENABLE_DEBUG_INFO = NO; 255 | MTL_FAST_MATH = YES; 256 | SDKROOT = macosx; 257 | SWIFT_COMPILATION_MODE = wholemodule; 258 | }; 259 | name = Release; 260 | }; 261 | C82F045E2CCB3DD40012C07B /* Debug */ = { 262 | isa = XCBuildConfiguration; 263 | buildSettings = { 264 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 265 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 266 | ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; 267 | CODE_SIGN_ENTITLEMENTS = Figura/Figura.entitlements; 268 | CODE_SIGN_STYLE = Automatic; 269 | COMBINE_HIDPI_IMAGES = YES; 270 | CURRENT_PROJECT_VERSION = 20; 271 | DEAD_CODE_STRIPPING = YES; 272 | DEVELOPMENT_ASSET_PATHS = "\"Figura/Preview Content\""; 273 | DEVELOPMENT_TEAM = YYMLDY74QZ; 274 | ENABLE_HARDENED_RUNTIME = YES; 275 | ENABLE_PREVIEWS = YES; 276 | GENERATE_INFOPLIST_FILE = YES; 277 | INFOPLIST_FILE = Figura/Info.plist; 278 | INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; 279 | INFOPLIST_KEY_NSHumanReadableCopyright = ""; 280 | LD_RUNPATH_SEARCH_PATHS = ( 281 | "$(inherited)", 282 | "@executable_path/../Frameworks", 283 | ); 284 | MACOSX_DEPLOYMENT_TARGET = 14.0; 285 | MARKETING_VERSION = 1.4.2; 286 | PRODUCT_BUNDLE_IDENTIFIER = Minimal.Figura; 287 | PRODUCT_NAME = "$(TARGET_NAME)"; 288 | SWIFT_EMIT_LOC_STRINGS = YES; 289 | SWIFT_VERSION = 5.0; 290 | }; 291 | name = Debug; 292 | }; 293 | C82F045F2CCB3DD40012C07B /* Release */ = { 294 | isa = XCBuildConfiguration; 295 | buildSettings = { 296 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 297 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 298 | ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; 299 | CODE_SIGN_ENTITLEMENTS = Figura/Figura.entitlements; 300 | CODE_SIGN_STYLE = Automatic; 301 | COMBINE_HIDPI_IMAGES = YES; 302 | CURRENT_PROJECT_VERSION = 20; 303 | DEAD_CODE_STRIPPING = YES; 304 | DEVELOPMENT_ASSET_PATHS = "\"Figura/Preview Content\""; 305 | DEVELOPMENT_TEAM = YYMLDY74QZ; 306 | ENABLE_HARDENED_RUNTIME = YES; 307 | ENABLE_PREVIEWS = YES; 308 | GENERATE_INFOPLIST_FILE = YES; 309 | INFOPLIST_FILE = Figura/Info.plist; 310 | INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; 311 | INFOPLIST_KEY_NSHumanReadableCopyright = ""; 312 | LD_RUNPATH_SEARCH_PATHS = ( 313 | "$(inherited)", 314 | "@executable_path/../Frameworks", 315 | ); 316 | MACOSX_DEPLOYMENT_TARGET = 14.0; 317 | MARKETING_VERSION = 1.4.2; 318 | PRODUCT_BUNDLE_IDENTIFIER = Minimal.Figura; 319 | PRODUCT_NAME = "$(TARGET_NAME)"; 320 | SWIFT_EMIT_LOC_STRINGS = YES; 321 | SWIFT_VERSION = 5.0; 322 | }; 323 | name = Release; 324 | }; 325 | /* End XCBuildConfiguration section */ 326 | 327 | /* Begin XCConfigurationList section */ 328 | C82F04492CCB3DD20012C07B /* Build configuration list for PBXProject "Figura" */ = { 329 | isa = XCConfigurationList; 330 | buildConfigurations = ( 331 | C82F045B2CCB3DD40012C07B /* Debug */, 332 | C82F045C2CCB3DD40012C07B /* Release */, 333 | ); 334 | defaultConfigurationIsVisible = 0; 335 | defaultConfigurationName = Release; 336 | }; 337 | C82F045D2CCB3DD40012C07B /* Build configuration list for PBXNativeTarget "Figura" */ = { 338 | isa = XCConfigurationList; 339 | buildConfigurations = ( 340 | C82F045E2CCB3DD40012C07B /* Debug */, 341 | C82F045F2CCB3DD40012C07B /* Release */, 342 | ); 343 | defaultConfigurationIsVisible = 0; 344 | defaultConfigurationName = Release; 345 | }; 346 | /* End XCConfigurationList section */ 347 | }; 348 | rootObject = C82F04462CCB3DD20012C07B /* Project object */; 349 | } 350 | -------------------------------------------------------------------------------- /Figura/Views/ContentView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Vision 3 | import CoreImage.CIFilterBuiltins 4 | import UniformTypeIdentifiers 5 | import AppKit 6 | 7 | class BackgroundRemovalManager: ObservableObject { 8 | @Published var isLoading = false 9 | @Published var inputImage: NSImage? 10 | @Published var processedImage: NSImage? 11 | @Published var uploadState: UploadState = .idle 12 | 13 | enum UploadState { 14 | case idle 15 | case uploading 16 | case processing 17 | case completed 18 | case error(String) 19 | } 20 | 21 | func handleImageSelection(_ image: NSImage) { 22 | print("Image received for processing") 23 | Task { @MainActor in 24 | self.uploadState = .uploading 25 | self.inputImage = image 26 | self.processImage(image) 27 | } 28 | } 29 | 30 | func clearImages() { 31 | inputImage = nil 32 | processedImage = nil 33 | uploadState = .idle 34 | isLoading = false 35 | } 36 | 37 | func processImage(_ image: NSImage) { 38 | print("Starting image processing") 39 | guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { 40 | print("Failed to create CGImage") 41 | Task { @MainActor in 42 | self.uploadState = .error("Failed to process image") 43 | } 44 | return 45 | } 46 | 47 | Task { @MainActor in 48 | self.isLoading = true 49 | self.uploadState = .processing 50 | } 51 | 52 | let ciImage = CIImage(cgImage: cgImage) 53 | let imageSize = image.size 54 | 55 | Task.detached(priority: .userInitiated) { 56 | print("Processing image on background thread") 57 | if let maskImage = await self.createMaskImage(from: ciImage), 58 | let outputImage = self.apply(mask: maskImage, to: ciImage), 59 | let cgOutput = CIContext().createCGImage(outputImage, from: outputImage.extent) { 60 | print("Image processing successful") 61 | await MainActor.run { 62 | self.processedImage = NSImage(cgImage: cgOutput, size: imageSize) 63 | self.isLoading = false 64 | self.uploadState = .completed 65 | } 66 | } else { 67 | print("Image processing failed") 68 | await MainActor.run { 69 | self.isLoading = false 70 | self.uploadState = .error("Failed to process image") 71 | } 72 | } 73 | } 74 | } 75 | 76 | private func createMaskImage(from inputImage: CIImage) async -> CIImage? { 77 | let handler = VNImageRequestHandler(ciImage: inputImage) 78 | let request = VNGenerateForegroundInstanceMaskRequest() 79 | 80 | do { 81 | try handler.perform([request]) 82 | guard let result = request.results?.first else { return nil } 83 | let maskPixel = try result.generateScaledMaskForImage(forInstances: result.allInstances, from: handler) 84 | return CIImage(cvPixelBuffer: maskPixel) 85 | } catch { 86 | print("Error creating mask: \(error)") 87 | return nil 88 | } 89 | } 90 | 91 | private func apply(mask: CIImage, to image: CIImage) -> CIImage? { 92 | let filter = CIFilter.blendWithMask() 93 | filter.inputImage = image 94 | filter.maskImage = mask 95 | filter.backgroundImage = CIImage.empty() 96 | return filter.outputImage 97 | } 98 | } 99 | 100 | struct ContentView: View { 101 | @StateObject private var manager = BackgroundRemovalManager() 102 | @State private var isDragging = false 103 | 104 | var body: some View { 105 | ZStack { 106 | VisualEffectBlur(material: .headerView, blendingMode: .behindWindow) 107 | .ignoresSafeArea() 108 | 109 | VStack(spacing: 20) { 110 | ZStack { 111 | if let image = manager.processedImage { 112 | Image(nsImage: image) 113 | .resizable() 114 | .scaledToFit() 115 | .frame(maxWidth: 400, maxHeight: 400) 116 | .transition(.opacity) 117 | .contextMenu { 118 | Button("Copy Image") { 119 | copyImageToPasteboard(image) 120 | } 121 | Button("Save Image") { 122 | saveProcessedImage() 123 | } 124 | Button("Clear") { 125 | manager.clearImages() 126 | } 127 | } 128 | } else if let image = manager.inputImage { 129 | Image(nsImage: image) 130 | .resizable() 131 | .scaledToFit() 132 | .frame(maxWidth: 400, maxHeight: 400) 133 | .transition(.opacity) 134 | } else { 135 | DropZoneView(isDragging: $isDragging, onTap: handleImageSelection) 136 | .overlay( 137 | Text("⌘V to paste") 138 | .font(.system(size: 13)) 139 | .foregroundColor(.secondary) 140 | .padding(8) 141 | .background( 142 | RoundedRectangle(cornerRadius: 6) 143 | .fill(Color.primary.opacity(0.05)) 144 | ) 145 | .padding(.bottom, 40), 146 | alignment: .bottom 147 | ) 148 | } 149 | 150 | if manager.isLoading { 151 | LoaderView() 152 | } 153 | 154 | if case .error(let message) = manager.uploadState { 155 | Text(message) 156 | .foregroundColor(.red) 157 | .padding() 158 | .background(Color.black.opacity(0.7)) 159 | .cornerRadius(8) 160 | } 161 | } 162 | .animation(.easeInOut, value: manager.processedImage != nil) 163 | 164 | if manager.processedImage != nil { 165 | ButtonGroup(buttons: [ 166 | ( 167 | title: "Copy", 168 | icon: "doc.on.doc", 169 | action: { 170 | if let image = manager.processedImage { 171 | copyImageToPasteboard(image) 172 | } 173 | } 174 | ), 175 | ( 176 | title: "Save", 177 | icon: "arrow.down.circle", 178 | action: saveProcessedImage 179 | ), 180 | ( 181 | title: "Clear", 182 | icon: "trash", 183 | action: manager.clearImages 184 | ) 185 | ]) 186 | .disabled(manager.isLoading) 187 | } 188 | } 189 | .padding(30) 190 | } 191 | .frame(minWidth: 600, minHeight: 700) 192 | .onDrop(of: [.image, .fileURL], isTargeted: $isDragging) { providers in 193 | loadFirstProvider(from: providers) 194 | return true 195 | } 196 | .onAppear { 197 | NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in 198 | if event.modifierFlags.contains(.command) && event.charactersIgnoringModifiers == "v" { 199 | handlePaste() 200 | return nil 201 | } 202 | return event 203 | } 204 | } 205 | } 206 | 207 | private func handlePaste() { 208 | let pasteboard = NSPasteboard.general 209 | if let image = pasteboard.getImageFromPasteboard() { 210 | manager.handleImageSelection(image) 211 | } 212 | } 213 | 214 | // Add shortcut keys to the menu bar 215 | init() { 216 | let pasteMenuItem = NSMenuItem( 217 | title: "Paste Image", 218 | action: #selector(NSApplication.sendAction(_:to:from:)), 219 | keyEquivalent: "v" 220 | ) 221 | pasteMenuItem.target = NSApp 222 | pasteMenuItem.representedObject = handlePaste 223 | 224 | if let editMenu = NSApp.mainMenu?.item(withTitle: "Edit")?.submenu { 225 | editMenu.addItem(NSMenuItem.separator()) 226 | editMenu.addItem(pasteMenuItem) 227 | } 228 | } 229 | 230 | private func loadFirstProvider(from providers: [NSItemProvider]) { 231 | guard let provider = providers.first else { return } 232 | 233 | // Try loading as file URL first 234 | if provider.canLoadObject(ofClass: URL.self) { 235 | _ = provider.loadObject(ofClass: URL.self) { url, error in 236 | if let error = error { 237 | print("Error loading URL: \(error)") 238 | Task { @MainActor in 239 | self.manager.uploadState = .error("Failed to load dropped file") 240 | } 241 | return 242 | } 243 | 244 | if let url = url { 245 | self.loadImage(from: url) 246 | } 247 | } 248 | } 249 | // Then try loading as image 250 | else if provider.canLoadObject(ofClass: NSImage.self) { 251 | _ = provider.loadObject(ofClass: NSImage.self) { image, error in 252 | if let error = error { 253 | print("Error loading image: \(error)") 254 | Task { @MainActor in 255 | self.manager.uploadState = .error("Failed to load dropped image") 256 | } 257 | return 258 | } 259 | 260 | if let image = image as? NSImage { 261 | Task { @MainActor in 262 | self.manager.handleImageSelection(image) 263 | } 264 | } 265 | } 266 | } 267 | } 268 | 269 | private func loadImage(from url: URL) { 270 | guard url.startAccessingSecurityScopedResource() else { return } 271 | defer { url.stopAccessingSecurityScopedResource() } 272 | 273 | if let image = NSImage(contentsOf: url) { 274 | Task { @MainActor in 275 | manager.handleImageSelection(image) 276 | } 277 | } 278 | } 279 | 280 | private func handleImageSelection() { 281 | let panel = NSOpenPanel() 282 | panel.allowsMultipleSelection = false 283 | panel.canChooseDirectories = false 284 | panel.canChooseFiles = true 285 | panel.allowedContentTypes = [.image] 286 | 287 | panel.begin { response in 288 | if response == .OK, let url = panel.url { 289 | print("Image selected from panel: \(url)") 290 | loadImage(from: url) 291 | } 292 | } 293 | } 294 | 295 | private func handleDrop(providers: [NSItemProvider]) { 296 | print("Handling drop with \(providers.count) providers") 297 | 298 | for provider in providers { 299 | // First try loading as file URL 300 | if provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) { 301 | provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier, options: nil) { urlData, error in 302 | if let error = error { 303 | print("Error loading file URL: \(error)") 304 | return 305 | } 306 | 307 | if let urlData = urlData as? Data, 308 | let url = URL(dataRepresentation: urlData, relativeTo: nil) { 309 | print("Loading image from URL: \(url)") 310 | loadImage(from: url) 311 | } 312 | } 313 | } 314 | // Then try loading as image data 315 | else if provider.hasItemConformingToTypeIdentifier(UTType.image.identifier) { 316 | provider.loadItem(forTypeIdentifier: UTType.image.identifier, options: nil) { imageData, error in 317 | if let error = error { 318 | print("Error loading image data: \(error)") 319 | return 320 | } 321 | 322 | if let imageData = imageData as? Data, 323 | let image = NSImage(data: imageData) { 324 | print("Loading image from data") 325 | Task { @MainActor in 326 | manager.handleImageSelection(image) 327 | } 328 | } 329 | } 330 | } 331 | } 332 | } 333 | 334 | private func copyImageToPasteboard(_ image: NSImage) { 335 | let pasteboard = NSPasteboard.general 336 | pasteboard.clearContents() 337 | pasteboard.writeObjects([image]) 338 | } 339 | 340 | private func saveProcessedImage() { 341 | print("Starting save process...") 342 | guard let processedImage = manager.processedImage else { 343 | print("No processed image available") 344 | return 345 | } 346 | 347 | let savePanel = NSSavePanel() 348 | savePanel.allowedContentTypes = [.png] 349 | savePanel.canCreateDirectories = true 350 | savePanel.isExtensionHidden = false 351 | savePanel.title = "Save Processed Image" 352 | savePanel.message = "Choose a location to save the processed image" 353 | savePanel.nameFieldStringValue = "processed_image.png" 354 | 355 | print("Running save panel...") 356 | let response = savePanel.runModal() 357 | print("Save panel response: \(response == .OK ? "OK" : "Cancel")") 358 | 359 | if response == .OK, 360 | let url = savePanel.url { 361 | print("Save location selected: \(url.path)") 362 | 363 | if let tiffData = processedImage.tiffRepresentation { 364 | print("TIFF representation created") 365 | if let bitmap = NSBitmapImageRep(data: tiffData) { 366 | print("Bitmap created") 367 | if let pngData = bitmap.representation(using: .png, properties: [:]) { 368 | print("PNG data created") 369 | do { 370 | try pngData.write(to: url) 371 | print("Image saved successfully") 372 | } catch { 373 | print("Failed to save image: \(error.localizedDescription)") 374 | manager.uploadState = .error("Failed to save image: \(error.localizedDescription)") 375 | } 376 | } else { 377 | print("Failed to create PNG data") 378 | manager.uploadState = .error("Failed to create PNG data") 379 | } 380 | } else { 381 | print("Failed to create bitmap from TIFF data") 382 | manager.uploadState = .error("Failed to create bitmap from TIFF data") 383 | } 384 | } else { 385 | print("Failed to create TIFF representation") 386 | manager.uploadState = .error("Failed to create TIFF representation") 387 | } 388 | } 389 | } 390 | } 391 | --------------------------------------------------------------------------------