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