diff --git a/Canvas.xcodeproj/project.pbxproj b/Canvas.xcodeproj/project.pbxproj index 65879b4..92df401 100644 --- a/Canvas.xcodeproj/project.pbxproj +++ b/Canvas.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 0A03693D2B4D6FBB00D7DAD4 /* SettingsModule in Frameworks */ = {isa = PBXBuildFile; productRef = 0A03693C2B4D6FBB00D7DAD4 /* SettingsModule */; }; 0A20744D2B3CD3E5008516D1 /* ModelPricingModule in Frameworks */ = {isa = PBXBuildFile; productRef = 0A20744C2B3CD3E5008516D1 /* ModelPricingModule */; }; 0A809A402B39752200C9A015 /* CanvasApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A809A3F2B39752200C9A015 /* CanvasApp.swift */; }; 0A809A462B39752300C9A015 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0A809A452B39752300C9A015 /* Assets.xcassets */; }; @@ -32,6 +33,7 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 0A03693B2B4D6F7600D7DAD4 /* SettingsModule */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = SettingsModule; sourceTree = ""; }; 0A20744B2B3CD385008516D1 /* ModelPricingModule */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = ModelPricingModule; sourceTree = ""; }; 0A809A3C2B39752200C9A015 /* Canvas.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Canvas.app; sourceTree = BUILT_PRODUCTS_DIR; }; 0A809A3F2B39752200C9A015 /* CanvasApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CanvasApp.swift; sourceTree = ""; }; @@ -66,6 +68,7 @@ 0A809A632B397C3B00C9A015 /* ImageEditModule in Frameworks */, 0A809A612B397C3B00C9A015 /* CoreViews in Frameworks */, 0A809A652B397C3B00C9A015 /* ImageGenerationModule in Frameworks */, + 0A03693D2B4D6FBB00D7DAD4 /* SettingsModule in Frameworks */, 0A809A5B2B397B6100C9A015 /* CoreViewModels in Frameworks */, 0A809A672B397C3B00C9A015 /* ImageVariationModule in Frameworks */, 0A809A752B398AEE00C9A015 /* ImagePreferencesModule in Frameworks */, @@ -82,7 +85,6 @@ 0A809A332B39752200C9A015 = { isa = PBXGroup; children = ( - 0A20744B2B3CD385008516D1 /* ModelPricingModule */, 0A809A7D2B39D65000C9A015 /* CoreExtensions */, 0A809A762B398F1600C9A015 /* CoreModels */, 0A809A592B397B2E00C9A015 /* CoreViewModels */, @@ -91,6 +93,8 @@ 0A809A5C2B397B8800C9A015 /* ImageGenerationModule */, 0A809A732B398AB200C9A015 /* ImagePreferencesModule */, 0A809A5E2B397BE800C9A015 /* ImageVariationModule */, + 0A20744B2B3CD385008516D1 /* ModelPricingModule */, + 0A03693B2B4D6F7600D7DAD4 /* SettingsModule */, 0A809A3E2B39752200C9A015 /* Canvas */, 0A809A522B39772800C9A015 /* Frameworks */, 0A809A3D2B39752200C9A015 /* Products */, @@ -208,6 +212,7 @@ 0A809A832B39EB0100C9A015 /* AppInfo */, 0AE400632B3AD5F800307732 /* Sparkle */, 0A20744C2B3CD3E5008516D1 /* ModelPricingModule */, + 0A03693C2B4D6FBB00D7DAD4 /* SettingsModule */, ); productName = Canvas; productReference = 0A809A3C2B39752200C9A015 /* Canvas.app */; @@ -221,7 +226,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1510; - LastUpgradeCheck = 1510; + LastUpgradeCheck = 1520; TargetAttributes = { 0A809A3B2B39752200C9A015 = { CreatedOnToolsVersion = 15.1; @@ -316,6 +321,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -380,6 +386,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -411,6 +418,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 3; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"Canvas/Preview Content\""; DEVELOPMENT_TEAM = 84ZM7K56B5; ENABLE_HARDENED_RUNTIME = YES; @@ -441,6 +449,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 3; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"Canvas/Preview Content\""; DEVELOPMENT_TEAM = 84ZM7K56B5; ENABLE_HARDENED_RUNTIME = YES; @@ -513,6 +522,10 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 0A03693C2B4D6FBB00D7DAD4 /* SettingsModule */ = { + isa = XCSwiftPackageProductDependency; + productName = SettingsModule; + }; 0A20744C2B3CD3E5008516D1 /* ModelPricingModule */ = { isa = XCSwiftPackageProductDependency; productName = ModelPricingModule; diff --git a/Canvas/App/CanvasApp.swift b/Canvas/App/CanvasApp.swift index 6eb2868..bc1d195 100644 --- a/Canvas/App/CanvasApp.swift +++ b/Canvas/App/CanvasApp.swift @@ -7,6 +7,7 @@ import AppInfo import CoreViewModels +import SettingsModule import Sparkle import SwiftUI @@ -14,12 +15,16 @@ import SwiftUI struct CanvasApp: App { @State private var appUpdater: AppUpdater private var updater: SPUUpdater + + @State private var settingsManager: SettingsManager @State private var apiKeyViewModel: APIKeyViewModel @State private var dalleViewModel: DalleViewModel @State private var dalleModelInfoViewModel: DalleModelInfoViewModel - + init() { + self._settingsManager = State(initialValue: SettingsManager()) + self._apiKeyViewModel = State(initialValue: APIKeyViewModel()) self._dalleViewModel = State(initialValue: DalleViewModel()) self._dalleModelInfoViewModel = State(initialValue: DalleModelInfoViewModel()) @@ -34,6 +39,7 @@ struct CanvasApp: App { var body: some Scene { WindowGroup { AppView() + .environment(settingsManager) .environment(apiKeyViewModel) .environment(dalleViewModel) .environment(dalleModelInfoViewModel) @@ -52,5 +58,10 @@ struct CanvasApp: App { } } } + + Settings { + SettingsView() + .environment(settingsManager) + } } } diff --git a/Canvas/Resources/Canvas.entitlements b/Canvas/Resources/Canvas.entitlements index d363e1c..82f2f5a 100644 --- a/Canvas/Resources/Canvas.entitlements +++ b/Canvas/Resources/Canvas.entitlements @@ -2,8 +2,6 @@ - com.apple.security.app-sandbox - com.apple.security.files.user-selected.read-write com.apple.security.network.client diff --git a/CoreExtensions/Sources/CoreExtensions/NSImage+Write.swift b/CoreExtensions/Sources/CoreExtensions/NSImage+Write.swift new file mode 100644 index 0000000..cbbc069 --- /dev/null +++ b/CoreExtensions/Sources/CoreExtensions/NSImage+Write.swift @@ -0,0 +1,19 @@ +// +// NSImage.swift +// +// +// Created by Kevin Hermawan on 09/01/24. +// + +import Foundation +import SwiftUI + +public extension NSImage { + func write(to url: URL) throws -> Void { + guard let tiff = self.tiffRepresentation else { return } + guard let bitmap = NSBitmapImageRep(data: tiff) else { return } + guard let data = bitmap.representation(using: .png, properties: [:]) else { return } + + try data.write(to: url) + } +} diff --git a/CoreViews/Sources/CoreViews/ImageResult/ImageResultContextMenu.swift b/CoreViews/Sources/CoreViews/ImageResult/ImageResultContextMenu.swift index 47288aa..c8a4bce 100644 --- a/CoreViews/Sources/CoreViews/ImageResult/ImageResultContextMenu.swift +++ b/CoreViews/Sources/CoreViews/ImageResult/ImageResultContextMenu.swift @@ -5,6 +5,7 @@ // Created by Kevin Hermawan on 26/12/23. // +import CoreExtensions import Nuke import NukeUI import SwiftUI @@ -44,11 +45,8 @@ struct ImageResultContextMenu: View { savePanel.begin { response in guard response == .OK, let url = savePanel.url else { return } - guard let tiffData = nsImage.tiffRepresentation else { return } - guard let bitmapImage = NSBitmapImageRep(data: tiffData) else { return } - guard let pngData = bitmapImage.representation(using: .png, properties: [:]) else { return } - - try? pngData.write(to: url) + + try? nsImage.write(to: url) } } } diff --git a/CoreViews/Sources/CoreViews/ImageResult/ImageResultListItemView.swift b/CoreViews/Sources/CoreViews/ImageResult/ImageResultListItemView.swift index 767e73b..f9ae695 100644 --- a/CoreViews/Sources/CoreViews/ImageResult/ImageResultListItemView.swift +++ b/CoreViews/Sources/CoreViews/ImageResult/ImageResultListItemView.swift @@ -5,10 +5,14 @@ // Created by Kevin Hermawan on 25/12/23. // +import CoreExtensions +import SettingsModule import NukeUI import SwiftUI public struct ImageResultListItemView: View { + @Environment(SettingsManager.self) private var settingsManager + private let url: URL public init(url: URL) { @@ -29,6 +33,9 @@ public struct ImageResultListItemView: View { } else if let image = state.image, let imageContainer = state.imageContainer { image.resizable() .aspectRatio(contentMode: .fit) + .onAppear { + autosaveAction(for: imageContainer.image) + } .contextMenu { ImageResultContextMenu( name: url.lastPathComponent, @@ -43,4 +50,24 @@ public struct ImageResultListItemView: View { .background(Color(nsColor: .secondarySystemFill)) .clipShape(.rect(cornerRadius: 8, style: .continuous)) } + + private func autosaveAction(for image: NSImage) { + let fileManager = FileManager.default + + if settingsManager.autosaveEnabled { + var isDir: ObjCBool = false + var location = settingsManager.autosaveLocation + + if fileManager.fileExists(atPath: location.path, isDirectory: &isDir) { + location.append(path: url.lastPathComponent) + + try? image.write(to: location) + } else { + try? fileManager.createDirectory(atPath: location.path, withIntermediateDirectories: true, attributes: nil) + location.append(path: url.lastPathComponent) + + try? image.write(to: location) + } + } + } } diff --git a/CoreViews/Sources/CoreViews/PromptField/PromptFieldFooterText.swift b/CoreViews/Sources/CoreViews/PromptField/PromptFieldFooterText.swift index c5424c7..01a19a2 100644 --- a/CoreViews/Sources/CoreViews/PromptField/PromptFieldFooterText.swift +++ b/CoreViews/Sources/CoreViews/PromptField/PromptFieldFooterText.swift @@ -5,18 +5,29 @@ // Created by Kevin Hermawan on 26/12/23. // +import SettingsModule import SwiftUI import ViewState struct PromptFieldFooterText: View { + @Environment(SettingsManager.self) private var settingsManager + private var viewState: ViewState? init(viewState: ViewState? = nil) { self.viewState = viewState } + private var message: String { + if settingsManager.autosaveEnabled { + return "A good prompt is essential for the best possible image generation results." + } + + return "Auto-save is not enabled; remember to manually save the results." + } + var body: some View { - Text("A good prompt is essential for the best possible image generation results.") + Text(message) .when(viewState, is: .loading) { Text("Generating image...") } diff --git a/SettingsModule/.gitignore b/SettingsModule/.gitignore new file mode 100644 index 0000000..0023a53 --- /dev/null +++ b/SettingsModule/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/SettingsModule/Package.swift b/SettingsModule/Package.swift new file mode 100644 index 0000000..d4bee17 --- /dev/null +++ b/SettingsModule/Package.swift @@ -0,0 +1,25 @@ +// swift-tools-version: 5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "SettingsModule", + platforms: [.macOS(.v14)], + products: [ + .library( + name: "SettingsModule", + targets: ["SettingsModule"]), + ], + dependencies: [ + .package(url: "https://github.com/sindresorhus/Defaults.git", .upToNextMajor(from: "7.3.1")) + ], + targets: [ + .target( + name: "SettingsModule", + dependencies: ["Defaults"]), + .testTarget( + name: "SettingsModuleTests", + dependencies: ["SettingsModule"]), + ] +) diff --git a/SettingsModule/Sources/SettingsModule/Extensions/Defaults+Keys.swift b/SettingsModule/Sources/SettingsModule/Extensions/Defaults+Keys.swift new file mode 100644 index 0000000..5b86635 --- /dev/null +++ b/SettingsModule/Sources/SettingsModule/Extensions/Defaults+Keys.swift @@ -0,0 +1,17 @@ +// +// Defaults+Keys.swift +// +// +// Created by Kevin Hermawan on 09/01/24. +// + +import Defaults +import Foundation + +let fileManager = FileManager.default +let homeDirectory = fileManager.homeDirectoryForCurrentUser + +public extension Defaults.Keys { + static let autosaveEnabled = Key("autosaveEnabled", default: true) + static let autosaveLocation = Key("autosaveLocation", default: homeDirectory.appending(path: "Canvas")) +} diff --git a/SettingsModule/Sources/SettingsModule/Managers/SettingsManager.swift b/SettingsModule/Sources/SettingsModule/Managers/SettingsManager.swift new file mode 100644 index 0000000..5d4216c --- /dev/null +++ b/SettingsModule/Sources/SettingsModule/Managers/SettingsManager.swift @@ -0,0 +1,26 @@ +// +// SettingsManager.swift +// +// +// Created by Kevin Hermawan on 09/01/24. +// + +import Defaults +import Foundation + +@Observable +public final class SettingsManager { + public var autosaveEnabled: Bool = Defaults[.autosaveEnabled] { + didSet { + Defaults[.autosaveEnabled] = autosaveEnabled + } + } + + public var autosaveLocation: URL = Defaults[.autosaveLocation] { + didSet { + Defaults[.autosaveLocation] = autosaveLocation + } + } + + public init() {} +} diff --git a/SettingsModule/Sources/SettingsModule/Views/GeneralView.swift b/SettingsModule/Sources/SettingsModule/Views/GeneralView.swift new file mode 100644 index 0000000..454e014 --- /dev/null +++ b/SettingsModule/Sources/SettingsModule/Views/GeneralView.swift @@ -0,0 +1,42 @@ +// +// GeneralView.swift +// +// +// Created by Kevin Hermawan on 09/01/24. +// + +import SwiftUI +import ViewCondition + +struct GeneralView: View { + @Environment(SettingsManager.self) private var manager + + var body: some View { + @Bindable var manager = manager + + VStack(alignment: .leading, spacing: 16) { + GroupBox { + AutosavePicker( + enabled: $manager.autosaveEnabled, + location: $manager.autosaveLocation, + action: chooseAutosaveDirectoryAction + ) + } + } + } + + private func chooseAutosaveDirectoryAction() { + let openPanel = NSOpenPanel() + openPanel.canChooseFiles = false + openPanel.canChooseDirectories = true + openPanel.canCreateDirectories = true + openPanel.allowsMultipleSelection = false + openPanel.prompt = "Choose" + + openPanel.begin { response in + if response == .OK, let url = openPanel.urls.first { + manager.autosaveLocation = url + } + } + } +} diff --git a/SettingsModule/Sources/SettingsModule/Views/SettingsView.swift b/SettingsModule/Sources/SettingsModule/Views/SettingsView.swift new file mode 100644 index 0000000..6d0c2bf --- /dev/null +++ b/SettingsModule/Sources/SettingsModule/Views/SettingsView.swift @@ -0,0 +1,25 @@ +// +// SettingsView.swift +// +// +// Created by Kevin Hermawan on 09/01/24. +// + +import SwiftUI + +public struct SettingsView: View { + public init() {} + + public var body: some View { + VStack { + TabView { + GeneralView() + .tabItem { + Label("General", systemImage: "gearshape") + } + } + } + .padding() + .frame(width: 450) + } +} diff --git a/SettingsModule/Sources/SettingsModule/Views/Subviews/AutosavePicker.swift b/SettingsModule/Sources/SettingsModule/Views/Subviews/AutosavePicker.swift new file mode 100644 index 0000000..fbf363b --- /dev/null +++ b/SettingsModule/Sources/SettingsModule/Views/Subviews/AutosavePicker.swift @@ -0,0 +1,56 @@ +// +// AutosavePicker.swift +// +// +// Created by Kevin Hermawan on 09/01/24. +// + +import SwiftUI +import ViewCondition + +struct AutosavePicker: View { + @Binding private var enabled: Bool + @Binding private var location: URL + private var action: () -> Void + + public init(enabled: Binding, location: Binding, action: @escaping () -> Void) { + self._enabled = enabled + self._location = location + self.action = action + } + + private var disabled: Bool { + enabled == false + } + + var body: some View { + VStack(spacing: 16) { + HStack { + Text("Auto-save Results") + .font(.headline.weight(.semibold)) + + Spacer() + + Toggle("", isOn: $enabled) + .toggleStyle(.switch) + .labelsHidden() + } + + HStack { + Text(location.path) + .if(disabled) { view in + view.foregroundStyle(.tertiary) + } + + Spacer() + + Button(action: action) { + Image(systemName: "folder") + } + .disabled(disabled) + .help("Choose directory") + } + } + .padding(4) + } +} diff --git a/SettingsModule/Tests/SettingsModuleTests/SettingsModuleTests.swift b/SettingsModule/Tests/SettingsModuleTests/SettingsModuleTests.swift new file mode 100644 index 0000000..07c59c5 --- /dev/null +++ b/SettingsModule/Tests/SettingsModuleTests/SettingsModuleTests.swift @@ -0,0 +1,12 @@ +import XCTest +@testable import SettingsModule + +final class SettingsModuleTests: XCTestCase { + func testExample() throws { + // XCTest Documentation + // https://developer.apple.com/documentation/xctest + + // Defining Test Cases and Test Methods + // https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods + } +}