diff --git a/Informant-Tests/Informant_Tests.swift b/Informant-Tests/Informant_Tests.swift new file mode 100644 index 0000000..654bac0 --- /dev/null +++ b/Informant-Tests/Informant_Tests.swift @@ -0,0 +1,82 @@ +// +// Informant_Tests.swift +// Informant-Tests +// +// Created by Ty Irvine on 2022-04-05. +// + +@testable import Informant +import SwiftUI +import XCTest + +class Informant_Tests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + print("--------------- TEST START ๐ŸŸข ----------------") + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + print("--------------- TEST FINISH ๐Ÿ”ด ----------------") + } + + /* + func testExample() throws { + // This is an example of a functional test case. + // Use XCTAssert and related functions to verify your tests produce the correct results. + // Any test you write for XCTest can be annotated as throws and async. + // Mark your test throws to produce an unexpected failure when your test encounters an uncaught error. + // Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards. + } + + func testPerformanceExample() throws { + // This is an example of a performance test case. + measure { + // Put the code you want to measure the time of here. + } + } + */ + + // MARK: - Test Helpers + + func printTestName(name: String) { + print("๐Ÿ›ƒ TEST - \(name)") + } + + // MARK: - Tests + + // Tests to make sure the correct metadata can be found for the provided path + func testStandardSelection() throws { + + printTestName(name: "Standard Selection") + + // Provided selection + let path = "/Users/tyirvine/Files/Archive/Testing/SF-Display.otf" + + // Measures total execution time + // Get current app delegate + let appDelegate = AppDelegate.current() + + // Path bundle + let paths = Paths(paths: [path], state: .PathAvailable) + + // Get the selection with the provided path + guard let data = appDelegate.dataController.getSelection(paths)?.data else { + return XCTAssertEqual(true, false, "Selection is not valid โŒ") + } + + // ---------------- Confirm data is valid ---------------- + + let failureMessage = """ + + + Selection is not valid โŒ + + It's possible size is disabled in settings. Make sure it's enabled and try again. + + """ + + XCTAssertEqual(data.data[String.keyShowSize], "2.3 MB", failureMessage) + } +} diff --git a/Informant.xcodeproj/project.pbxproj b/Informant.xcodeproj/project.pbxproj new file mode 100644 index 0000000..e9c6c76 --- /dev/null +++ b/Informant.xcodeproj/project.pbxproj @@ -0,0 +1,868 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 55; + objects = { + +/* Begin PBXBuildFile section */ + 9A09C2B327C3F455007C1FE7 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A09C2B227C3F455007C1FE7 /* AppDelegate.swift */; }; + 9A09C2B727C3F457007C1FE7 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 9A09C2B627C3F457007C1FE7 /* Assets.xcassets */; }; + 9A09C2BA27C3F457007C1FE7 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 9A09C2B827C3F457007C1FE7 /* Main.storyboard */; }; + 9A16715827C7F8DD00FA8C7D /* MathHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A16715727C7F8DD00FA8C7D /* MathHelper.swift */; }; + 9A16715A27C816D800FA8C7D /* MiscellaneousHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A16715927C816D800FA8C7D /* MiscellaneousHelper.swift */; }; + 9A23103F27C825EB0092080A /* DataRetrieval.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A23103E27C825EB0092080A /* DataRetrieval.swift */; }; + 9A23104127C826040092080A /* DataFormatting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A23104027C826040092080A /* DataFormatting.swift */; }; + 9A23104327C8264E0092080A /* DataUtility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A23104227C8264E0092080A /* DataUtility.swift */; }; + 9A23104627C9398B0092080A /* StatusDisplay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A23104527C9398B0092080A /* StatusDisplay.swift */; }; + 9A23104827C939930092080A /* FloatDisplay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A23104727C939930092080A /* FloatDisplay.swift */; }; + 9A2CFE9927C7CC3B00DD0EF9 /* CacheHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A2CFE9827C7CC3B00DD0EF9 /* CacheHelper.swift */; }; + 9A333BAC281213EC00507859 /* FloatDisplayFieldLoaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A333BAB281213EC00507859 /* FloatDisplayFieldLoaderView.swift */; }; + 9A36096D2805EB9300539713 /* FloatDisplayFieldCopyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A36096C2805EB9300539713 /* FloatDisplayFieldCopyView.swift */; }; + 9A36096F2805ED7F00539713 /* TrackingAreaHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A36096E2805ED7F00539713 /* TrackingAreaHelper.swift */; }; + 9A3609712805F6D800539713 /* InactiveWindowTapHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3609702805F6D800539713 /* InactiveWindowTapHelper.swift */; }; + 9A36097328060C8D00539713 /* FloatDisplayCloseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A36097228060C8D00539713 /* FloatDisplayCloseView.swift */; }; + 9A41B02827FCD88A00E0C089 /* Informant_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A41B02727FCD88A00E0C089 /* Informant_Tests.swift */; }; + 9A41B03A27FCEF2800E0C089 /* NotificationDirector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A41B03927FCEF2800E0C089 /* NotificationDirector.swift */; }; + 9A4BC8FD27C7D9B200530188 /* DiskAllocationHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A4BC8FC27C7D9B200530188 /* DiskAllocationHelper.swift */; }; + 9A6C9BB027C433C90053F696 /* LaunchAtLogin in Frameworks */ = {isa = PBXBuildFile; productRef = 9A6C9BAF27C433C90053F696 /* LaunchAtLogin */; }; + 9A7726F627F8C2070020CB10 /* DataDirector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A7726F527F8C2070020CB10 /* DataDirector.swift */; }; + 9A905A1927C57ADA000C2EA6 /* Style.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A905A1827C57ADA000C2EA6 /* Style.swift */; }; + 9A94710D27C4244700F38A34 /* StatusController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A94710C27C4244700F38A34 /* StatusController.swift */; }; + 9A9A635B27C41F14007E9552 /* ContentManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A9A635A27C41F14007E9552 /* ContentManager.swift */; }; + 9AA5607B2810894C00753BE9 /* FloatDisplayFieldDividerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AA5607A2810894C00753BE9 /* FloatDisplayFieldDividerView.swift */; }; + 9AAA8C4C27C6B7FF00E7565C /* Selection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AAA8C4B27C6B7FF00E7565C /* Selection.swift */; }; + 9ABC48882804B1EB0003D553 /* FloatDisplayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9ABC48872804B1EB0003D553 /* FloatDisplayView.swift */; }; + 9AC14EFB27C68CD000734379 /* pause.png in Resources */ = {isa = PBXBuildFile; fileRef = 9AC14EF927C68CD000734379 /* pause.png */; }; + 9AC14EFD27C68D9E00734379 /* resume.png in Resources */ = {isa = PBXBuildFile; fileRef = 9AC14EFC27C68D9E00734379 /* resume.png */; }; + 9AC14EFF27C690CA00734379 /* lock.png in Resources */ = {isa = PBXBuildFile; fileRef = 9AC14EFE27C690CA00734379 /* lock.png */; }; + 9AC14F0127C6926C00734379 /* DisplayController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AC14F0027C6926C00734379 /* DisplayController.swift */; }; + 9AC14F0327C69A7400734379 /* PathController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AC14F0227C69A7400734379 /* PathController.swift */; }; + 9AC14F0727C6A3E000734379 /* Paths.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AC14F0627C6A3E000734379 /* Paths.swift */; }; + 9AC27F0727D2EAA6001A05BF /* SettingsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AC27F0627D2EAA6001A05BF /* SettingsController.swift */; }; + 9AC27F0927D3DEC7001A05BF /* AlertController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AC27F0827D3DEC7001A05BF /* AlertController.swift */; }; + 9AC397F8280753B20027F6DC /* WindowDragHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AC397F7280753B20027F6DC /* WindowDragHelper.swift */; }; + 9AC397FC280897D80027F6DC /* SnapPoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AC397FB280897D80027F6DC /* SnapPoint.swift */; }; + 9AC397FE28089CF60027F6DC /* ConnectedMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AC397FD28089CF60027F6DC /* ConnectedMonitor.swift */; }; + 9AC398002808F42C0027F6DC /* TestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AC397FF2808F42C0027F6DC /* TestView.swift */; }; + 9AC398022809ECF20027F6DC /* FramePoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AC398012809ECF20027F6DC /* FramePoint.swift */; }; + 9ADC33C227CFD62F0060AAAC /* Components.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9ADC33C127CFD62F0060AAAC /* Components.swift */; }; + 9ADC33C627D023280060AAAC /* Material.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9ADC33C527D023280060AAAC /* Material.swift */; }; + 9ADC33C827D13CD90060AAAC /* UpdateController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9ADC33C727D13CD90060AAAC /* UpdateController.swift */; }; + 9ADC33CA27D147D70060AAAC /* LinkHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9ADC33C927D147D70060AAAC /* LinkHelper.swift */; }; + 9ADD3DA3280B0C5000F6930A /* DisplayDetached.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9ADD3DA2280B0C5000F6930A /* DisplayDetached.swift */; }; + 9ADD3DA5280B40D400F6930A /* Testing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9ADD3DA4280B40D400F6930A /* Testing.swift */; }; + 9AE7340F27C4009900827C51 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE7340E27C4009900827C51 /* SettingsView.swift */; }; + 9AE7341127C400A500827C51 /* WelcomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE7341027C400A500827C51 /* WelcomeView.swift */; }; + 9AE7341327C400AB00827C51 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE7341227C400AB00827C51 /* AboutView.swift */; }; + 9AE7341527C400B100827C51 /* PaymentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE7341427C400B100827C51 /* PaymentView.swift */; }; + 9AE7341727C400BB00827C51 /* AccessibilityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE7341627C400BB00827C51 /* AccessibilityView.swift */; }; + 9AE7341927C400D800827C51 /* InteractionController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE7341827C400D800827C51 /* InteractionController.swift */; }; + 9AE7341B27C4010B00827C51 /* DataController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE7341A27C4010B00827C51 /* DataController.swift */; }; + 9AE7341D27C4011400827C51 /* InterfaceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE7341C27C4011400827C51 /* InterfaceController.swift */; }; + 9AE7341F27C4011B00827C51 /* WindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE7341E27C4011B00827C51 /* WindowController.swift */; }; + 9AE7342127C4012300827C51 /* MenuController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE7342027C4012300827C51 /* MenuController.swift */; }; + 9AE7342327C4012900827C51 /* Settings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE7342227C4012900827C51 /* Settings.swift */; }; + 9AE7342527C4059000827C51 /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE7342427C4059000827C51 /* Extensions.swift */; }; + 9AE7342727C4097800827C51 /* Controller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE7342627C4097800827C51 /* Controller.swift */; }; + 9AE7342B27C40B8000827C51 /* EventMonitorHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE7342A27C40B8000827C51 /* EventMonitorHelper.swift */; }; + 9AE7609927C93ECC004D3D7A /* Display.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE7609827C93ECC004D3D7A /* Display.swift */; }; + 9AE7609D27C97E07004D3D7A /* DebugHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE7609C27C97E07004D3D7A /* DebugHelper.swift */; }; + 9AF246BF2829A66B008A7169 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 9AF246BE2829A66B008A7169 /* Sparkle */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 9A41B02927FCD88A00E0C089 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 9A09C2A727C3F455007C1FE7 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 9A09C2AE27C3F455007C1FE7; + remoteInfo = Informant; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 9A09C2AF27C3F455007C1FE7 /* Informant.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Informant.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 9A09C2B227C3F455007C1FE7 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 9A09C2B627C3F457007C1FE7 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 9A09C2B927C3F457007C1FE7 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 9A09C2BB27C3F457007C1FE7 /* Informant.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Informant.entitlements; sourceTree = ""; }; + 9A16715727C7F8DD00FA8C7D /* MathHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MathHelper.swift; sourceTree = ""; }; + 9A16715927C816D800FA8C7D /* MiscellaneousHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MiscellaneousHelper.swift; sourceTree = ""; }; + 9A23103E27C825EB0092080A /* DataRetrieval.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataRetrieval.swift; sourceTree = ""; }; + 9A23104027C826040092080A /* DataFormatting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataFormatting.swift; sourceTree = ""; }; + 9A23104227C8264E0092080A /* DataUtility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataUtility.swift; sourceTree = ""; }; + 9A23104527C9398B0092080A /* StatusDisplay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusDisplay.swift; sourceTree = ""; }; + 9A23104727C939930092080A /* FloatDisplay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloatDisplay.swift; sourceTree = ""; }; + 9A2CFE9827C7CC3B00DD0EF9 /* CacheHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheHelper.swift; sourceTree = ""; }; + 9A333BAB281213EC00507859 /* FloatDisplayFieldLoaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloatDisplayFieldLoaderView.swift; sourceTree = ""; }; + 9A36096C2805EB9300539713 /* FloatDisplayFieldCopyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloatDisplayFieldCopyView.swift; sourceTree = ""; }; + 9A36096E2805ED7F00539713 /* TrackingAreaHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackingAreaHelper.swift; sourceTree = ""; }; + 9A3609702805F6D800539713 /* InactiveWindowTapHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InactiveWindowTapHelper.swift; sourceTree = ""; }; + 9A36097228060C8D00539713 /* FloatDisplayCloseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloatDisplayCloseView.swift; sourceTree = ""; }; + 9A41B02527FCD88A00E0C089 /* Informant-Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Informant-Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; + 9A41B02727FCD88A00E0C089 /* Informant_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Informant_Tests.swift; sourceTree = ""; }; + 9A41B03927FCEF2800E0C089 /* NotificationDirector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationDirector.swift; sourceTree = ""; }; + 9A4BC8FC27C7D9B200530188 /* DiskAllocationHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiskAllocationHelper.swift; sourceTree = ""; }; + 9A6C9BAD27C433130053F696 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + 9A7726F527F8C2070020CB10 /* DataDirector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataDirector.swift; sourceTree = ""; }; + 9A905A1827C57ADA000C2EA6 /* Style.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Style.swift; sourceTree = ""; }; + 9A94710C27C4244700F38A34 /* StatusController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusController.swift; sourceTree = ""; }; + 9A9A635A27C41F14007E9552 /* ContentManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentManager.swift; sourceTree = ""; }; + 9AA5607A2810894C00753BE9 /* FloatDisplayFieldDividerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloatDisplayFieldDividerView.swift; sourceTree = ""; }; + 9AAA8C4B27C6B7FF00E7565C /* Selection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Selection.swift; sourceTree = ""; }; + 9ABC48872804B1EB0003D553 /* FloatDisplayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloatDisplayView.swift; sourceTree = ""; }; + 9AC14EF927C68CD000734379 /* pause.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = pause.png; sourceTree = ""; }; + 9AC14EFC27C68D9E00734379 /* resume.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = resume.png; sourceTree = ""; }; + 9AC14EFE27C690CA00734379 /* lock.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = lock.png; sourceTree = ""; }; + 9AC14F0027C6926C00734379 /* DisplayController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayController.swift; sourceTree = ""; }; + 9AC14F0227C69A7400734379 /* PathController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PathController.swift; sourceTree = ""; }; + 9AC14F0627C6A3E000734379 /* Paths.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Paths.swift; sourceTree = ""; }; + 9AC27F0627D2EAA6001A05BF /* SettingsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsController.swift; sourceTree = ""; }; + 9AC27F0827D3DEC7001A05BF /* AlertController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertController.swift; sourceTree = ""; }; + 9AC397F7280753B20027F6DC /* WindowDragHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowDragHelper.swift; sourceTree = ""; }; + 9AC397FB280897D80027F6DC /* SnapPoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnapPoint.swift; sourceTree = ""; }; + 9AC397FD28089CF60027F6DC /* ConnectedMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectedMonitor.swift; sourceTree = ""; }; + 9AC397FF2808F42C0027F6DC /* TestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestView.swift; sourceTree = ""; }; + 9AC398012809ECF20027F6DC /* FramePoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FramePoint.swift; sourceTree = ""; }; + 9ADC33C127CFD62F0060AAAC /* Components.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Components.swift; sourceTree = ""; }; + 9ADC33C527D023280060AAAC /* Material.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Material.swift; sourceTree = ""; }; + 9ADC33C727D13CD90060AAAC /* UpdateController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateController.swift; sourceTree = ""; }; + 9ADC33C927D147D70060AAAC /* LinkHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkHelper.swift; sourceTree = ""; }; + 9ADD3DA2280B0C5000F6930A /* DisplayDetached.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayDetached.swift; sourceTree = ""; }; + 9ADD3DA4280B40D400F6930A /* Testing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Testing.swift; sourceTree = ""; }; + 9AE7340E27C4009900827C51 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + 9AE7341027C400A500827C51 /* WelcomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeView.swift; sourceTree = ""; }; + 9AE7341227C400AB00827C51 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; + 9AE7341427C400B100827C51 /* PaymentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentView.swift; sourceTree = ""; }; + 9AE7341627C400BB00827C51 /* AccessibilityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessibilityView.swift; sourceTree = ""; }; + 9AE7341827C400D800827C51 /* InteractionController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InteractionController.swift; sourceTree = ""; }; + 9AE7341A27C4010B00827C51 /* DataController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataController.swift; sourceTree = ""; }; + 9AE7341C27C4011400827C51 /* InterfaceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InterfaceController.swift; sourceTree = ""; }; + 9AE7341E27C4011B00827C51 /* WindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowController.swift; sourceTree = ""; }; + 9AE7342027C4012300827C51 /* MenuController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuController.swift; sourceTree = ""; }; + 9AE7342227C4012900827C51 /* Settings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Settings.swift; sourceTree = ""; }; + 9AE7342427C4059000827C51 /* Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Extensions.swift; sourceTree = ""; }; + 9AE7342627C4097800827C51 /* Controller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Controller.swift; sourceTree = ""; }; + 9AE7342A27C40B8000827C51 /* EventMonitorHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventMonitorHelper.swift; sourceTree = ""; }; + 9AE7609827C93ECC004D3D7A /* Display.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Display.swift; sourceTree = ""; }; + 9AE7609C27C97E07004D3D7A /* DebugHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugHelper.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 9A09C2AC27C3F455007C1FE7 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 9A6C9BB027C433C90053F696 /* LaunchAtLogin in Frameworks */, + 9AF246BF2829A66B008A7169 /* Sparkle in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 9A41B02227FCD88A00E0C089 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 9A09C2A627C3F455007C1FE7 = { + isa = PBXGroup; + children = ( + 9A09C2B127C3F455007C1FE7 /* Informant */, + 9A41B02627FCD88A00E0C089 /* Informant-Tests */, + 9A09C2B027C3F455007C1FE7 /* Products */, + ); + sourceTree = ""; + }; + 9A09C2B027C3F455007C1FE7 /* Products */ = { + isa = PBXGroup; + children = ( + 9A09C2AF27C3F455007C1FE7 /* Informant.app */, + 9A41B02527FCD88A00E0C089 /* Informant-Tests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 9A09C2B127C3F455007C1FE7 /* Informant */ = { + isa = PBXGroup; + children = ( + 9A6C9BAD27C433130053F696 /* Info.plist */, + 9A09C2B227C3F455007C1FE7 /* AppDelegate.swift */, + 9A9A635A27C41F14007E9552 /* ContentManager.swift */, + 9A905A1827C57ADA000C2EA6 /* Style.swift */, + 9A09C2C327C3FEC1007C1FE7 /* Views */, + 9A23104427C9395B0092080A /* Displays */, + 9A09C2C527C3FED2007C1FE7 /* Controllers */, + 9A23103D27C825D40092080A /* Data */, + 9AC14F0427C6A3C600734379 /* Models */, + 9AE7342927C40B6B00827C51 /* Helpers */, + 9A905A1B27C57F6A000C2EA6 /* Assets */, + 9A41B03B27FCEFEB00E0C089 /* Directors */, + 9AC27F0527D2EA96001A05BF /* Settings */, + 9AE7342427C4059000827C51 /* Extensions.swift */, + 9ADD3DA4280B40D400F6930A /* Testing.swift */, + 9A09C2B627C3F457007C1FE7 /* Assets.xcassets */, + 9A09C2B827C3F457007C1FE7 /* Main.storyboard */, + 9A09C2BB27C3F457007C1FE7 /* Informant.entitlements */, + ); + path = Informant; + sourceTree = ""; + }; + 9A09C2C327C3FEC1007C1FE7 /* Views */ = { + isa = PBXGroup; + children = ( + 9AE7340E27C4009900827C51 /* SettingsView.swift */, + 9AE7341027C400A500827C51 /* WelcomeView.swift */, + 9AE7341227C400AB00827C51 /* AboutView.swift */, + 9AE7341427C400B100827C51 /* PaymentView.swift */, + 9AE7341627C400BB00827C51 /* AccessibilityView.swift */, + 9ADC33C127CFD62F0060AAAC /* Components.swift */, + 9ADC33C527D023280060AAAC /* Material.swift */, + 9AC397FF2808F42C0027F6DC /* TestView.swift */, + ); + path = Views; + sourceTree = ""; + }; + 9A09C2C527C3FED2007C1FE7 /* Controllers */ = { + isa = PBXGroup; + children = ( + 9AC14F0227C69A7400734379 /* PathController.swift */, + 9AE7341827C400D800827C51 /* InteractionController.swift */, + 9AC14F0027C6926C00734379 /* DisplayController.swift */, + 9AE7341A27C4010B00827C51 /* DataController.swift */, + 9AE7341C27C4011400827C51 /* InterfaceController.swift */, + 9AE7341E27C4011B00827C51 /* WindowController.swift */, + 9AE7342027C4012300827C51 /* MenuController.swift */, + 9A94710C27C4244700F38A34 /* StatusController.swift */, + 9AE7342627C4097800827C51 /* Controller.swift */, + 9ADC33C727D13CD90060AAAC /* UpdateController.swift */, + 9AC27F0827D3DEC7001A05BF /* AlertController.swift */, + ); + path = Controllers; + sourceTree = ""; + }; + 9A23103D27C825D40092080A /* Data */ = { + isa = PBXGroup; + children = ( + 9A23103E27C825EB0092080A /* DataRetrieval.swift */, + 9A23104027C826040092080A /* DataFormatting.swift */, + 9A23104227C8264E0092080A /* DataUtility.swift */, + ); + path = Data; + sourceTree = ""; + }; + 9A23104427C9395B0092080A /* Displays */ = { + isa = PBXGroup; + children = ( + 9ABC48862804B1D50003D553 /* Display Views */, + 9A23104527C9398B0092080A /* StatusDisplay.swift */, + 9A23104727C939930092080A /* FloatDisplay.swift */, + 9AE7609827C93ECC004D3D7A /* Display.swift */, + 9ADD3DA2280B0C5000F6930A /* DisplayDetached.swift */, + ); + path = Displays; + sourceTree = ""; + }; + 9A41B02627FCD88A00E0C089 /* Informant-Tests */ = { + isa = PBXGroup; + children = ( + 9A41B02727FCD88A00E0C089 /* Informant_Tests.swift */, + ); + path = "Informant-Tests"; + sourceTree = ""; + }; + 9A41B03B27FCEFEB00E0C089 /* Directors */ = { + isa = PBXGroup; + children = ( + 9A41B03927FCEF2800E0C089 /* NotificationDirector.swift */, + 9A7726F527F8C2070020CB10 /* DataDirector.swift */, + ); + path = Directors; + sourceTree = ""; + }; + 9A905A1B27C57F6A000C2EA6 /* Assets */ = { + isa = PBXGroup; + children = ( + 9AC14EFE27C690CA00734379 /* lock.png */, + 9AC14EFC27C68D9E00734379 /* resume.png */, + 9AC14EF927C68CD000734379 /* pause.png */, + ); + path = Assets; + sourceTree = ""; + }; + 9ABC48862804B1D50003D553 /* Display Views */ = { + isa = PBXGroup; + children = ( + 9ABC48872804B1EB0003D553 /* FloatDisplayView.swift */, + 9A36097228060C8D00539713 /* FloatDisplayCloseView.swift */, + 9A36096C2805EB9300539713 /* FloatDisplayFieldCopyView.swift */, + 9AA5607A2810894C00753BE9 /* FloatDisplayFieldDividerView.swift */, + 9A333BAB281213EC00507859 /* FloatDisplayFieldLoaderView.swift */, + ); + path = "Display Views"; + sourceTree = ""; + }; + 9AC14F0427C6A3C600734379 /* Models */ = { + isa = PBXGroup; + children = ( + 9AAA8C4B27C6B7FF00E7565C /* Selection.swift */, + 9AC14F0627C6A3E000734379 /* Paths.swift */, + 9AC397FB280897D80027F6DC /* SnapPoint.swift */, + 9AC398012809ECF20027F6DC /* FramePoint.swift */, + 9AC397FD28089CF60027F6DC /* ConnectedMonitor.swift */, + ); + path = Models; + sourceTree = ""; + }; + 9AC27F0527D2EA96001A05BF /* Settings */ = { + isa = PBXGroup; + children = ( + 9AE7342227C4012900827C51 /* Settings.swift */, + 9AC27F0627D2EAA6001A05BF /* SettingsController.swift */, + ); + path = Settings; + sourceTree = ""; + }; + 9AE7342927C40B6B00827C51 /* Helpers */ = { + isa = PBXGroup; + children = ( + 9AE7342A27C40B8000827C51 /* EventMonitorHelper.swift */, + 9A2CFE9827C7CC3B00DD0EF9 /* CacheHelper.swift */, + 9A4BC8FC27C7D9B200530188 /* DiskAllocationHelper.swift */, + 9A16715727C7F8DD00FA8C7D /* MathHelper.swift */, + 9A16715927C816D800FA8C7D /* MiscellaneousHelper.swift */, + 9AE7609C27C97E07004D3D7A /* DebugHelper.swift */, + 9ADC33C927D147D70060AAAC /* LinkHelper.swift */, + 9A36096E2805ED7F00539713 /* TrackingAreaHelper.swift */, + 9A3609702805F6D800539713 /* InactiveWindowTapHelper.swift */, + 9AC397F7280753B20027F6DC /* WindowDragHelper.swift */, + ); + path = Helpers; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 9A09C2AE27C3F455007C1FE7 /* Informant */ = { + isa = PBXNativeTarget; + buildConfigurationList = 9A09C2BE27C3F457007C1FE7 /* Build configuration list for PBXNativeTarget "Informant" */; + buildPhases = ( + 9A09C2AB27C3F455007C1FE7 /* Sources */, + 9A09C2AC27C3F455007C1FE7 /* Frameworks */, + 9A09C2AD27C3F455007C1FE7 /* Resources */, + 9A6C9BB127C4367A0053F696 /* Launch at Login Helper */, + 9A6C9BB227C436950053F696 /* Set Build Number */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Informant; + packageProductDependencies = ( + 9A6C9BAF27C433C90053F696 /* LaunchAtLogin */, + 9AF246BE2829A66B008A7169 /* Sparkle */, + ); + productName = Informant; + productReference = 9A09C2AF27C3F455007C1FE7 /* Informant.app */; + productType = "com.apple.product-type.application"; + }; + 9A41B02427FCD88A00E0C089 /* Informant-Tests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 9A41B02D27FCD88A00E0C089 /* Build configuration list for PBXNativeTarget "Informant-Tests" */; + buildPhases = ( + 9A41B02127FCD88A00E0C089 /* Sources */, + 9A41B02227FCD88A00E0C089 /* Frameworks */, + 9A41B02327FCD88A00E0C089 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 9A41B02A27FCD88A00E0C089 /* PBXTargetDependency */, + ); + name = "Informant-Tests"; + productName = "Informant-Tests"; + productReference = 9A41B02527FCD88A00E0C089 /* Informant-Tests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 9A09C2A727C3F455007C1FE7 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1330; + LastUpgradeCheck = 1310; + TargetAttributes = { + 9A09C2AE27C3F455007C1FE7 = { + CreatedOnToolsVersion = 13.1; + }; + 9A41B02427FCD88A00E0C089 = { + CreatedOnToolsVersion = 13.3; + TestTargetID = 9A09C2AE27C3F455007C1FE7; + }; + }; + }; + buildConfigurationList = 9A09C2AA27C3F455007C1FE7 /* Build configuration list for PBXProject "Informant" */; + compatibilityVersion = "Xcode 13.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 9A09C2A627C3F455007C1FE7; + packageReferences = ( + 9A6C9BAE27C433C90053F696 /* XCRemoteSwiftPackageReference "LaunchAtLogin" */, + 9AF246BD2829A66B008A7169 /* XCRemoteSwiftPackageReference "Sparkle" */, + ); + productRefGroup = 9A09C2B027C3F455007C1FE7 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 9A09C2AE27C3F455007C1FE7 /* Informant */, + 9A41B02427FCD88A00E0C089 /* Informant-Tests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 9A09C2AD27C3F455007C1FE7 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 9A09C2B727C3F457007C1FE7 /* Assets.xcassets in Resources */, + 9AC14EFF27C690CA00734379 /* lock.png in Resources */, + 9A09C2BA27C3F457007C1FE7 /* Main.storyboard in Resources */, + 9AC14EFB27C68CD000734379 /* pause.png in Resources */, + 9AC14EFD27C68D9E00734379 /* resume.png in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 9A41B02327FCD88A00E0C089 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 9A6C9BB127C4367A0053F696 /* Launch at Login Helper */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Launch at Login Helper"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "# Type a script or drag a script file from your workspace to insert its path.\n\"${BUILT_PRODUCTS_DIR}/LaunchAtLogin_LaunchAtLogin.bundle/Contents/Resources/copy-helper-swiftpm.sh\"\n"; + }; + 9A6C9BB227C436950053F696 /* Set Build Number */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Set Build Number"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "#!/bin/bash\n\ngit=$(sh /etc/profile; which git)\nnumber_of_commits=$(\"$git\" rev-list HEAD --count)\ngit_release_version=$(\"$git\" describe --tags --always --abbrev=0)\n\ntarget_plist=\"$TARGET_BUILD_DIR/$INFOPLIST_PATH\"\ndsym_plist=\"$DWARF_DSYM_FOLDER_PATH/$DWARF_DSYM_FILE_NAME/Contents/Info.plist\"\n\nfor plist in \"$target_plist\" \"$dsym_plist\"; do\n if [ -f \"$plist\" ]; then\n /usr/libexec/PlistBuddy -c \"Set :CFBundleVersion $number_of_commits\" \"$plist\"\n /usr/libexec/PlistBuddy -c \"Set :CFBundleShortVersionString ${git_release_version#*v}\" \"$plist\"\n fi\ndone\n"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 9A09C2AB27C3F455007C1FE7 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 9ADC33C627D023280060AAAC /* Material.swift in Sources */, + 9A7726F627F8C2070020CB10 /* DataDirector.swift in Sources */, + 9A4BC8FD27C7D9B200530188 /* DiskAllocationHelper.swift in Sources */, + 9AAA8C4C27C6B7FF00E7565C /* Selection.swift in Sources */, + 9AE7342327C4012900827C51 /* Settings.swift in Sources */, + 9A905A1927C57ADA000C2EA6 /* Style.swift in Sources */, + 9AC14F0727C6A3E000734379 /* Paths.swift in Sources */, + 9AE7342727C4097800827C51 /* Controller.swift in Sources */, + 9ADC33CA27D147D70060AAAC /* LinkHelper.swift in Sources */, + 9AE7341327C400AB00827C51 /* AboutView.swift in Sources */, + 9A23104127C826040092080A /* DataFormatting.swift in Sources */, + 9AE7341F27C4011B00827C51 /* WindowController.swift in Sources */, + 9AE7341727C400BB00827C51 /* AccessibilityView.swift in Sources */, + 9A333BAC281213EC00507859 /* FloatDisplayFieldLoaderView.swift in Sources */, + 9AA5607B2810894C00753BE9 /* FloatDisplayFieldDividerView.swift in Sources */, + 9A23104327C8264E0092080A /* DataUtility.swift in Sources */, + 9AC27F0927D3DEC7001A05BF /* AlertController.swift in Sources */, + 9A09C2B327C3F455007C1FE7 /* AppDelegate.swift in Sources */, + 9AE7341127C400A500827C51 /* WelcomeView.swift in Sources */, + 9ADD3DA5280B40D400F6930A /* Testing.swift in Sources */, + 9A94710D27C4244700F38A34 /* StatusController.swift in Sources */, + 9AE7342127C4012300827C51 /* MenuController.swift in Sources */, + 9A41B03A27FCEF2800E0C089 /* NotificationDirector.swift in Sources */, + 9AE7341527C400B100827C51 /* PaymentView.swift in Sources */, + 9AE7342527C4059000827C51 /* Extensions.swift in Sources */, + 9AC398022809ECF20027F6DC /* FramePoint.swift in Sources */, + 9AE7341B27C4010B00827C51 /* DataController.swift in Sources */, + 9ADD3DA3280B0C5000F6930A /* DisplayDetached.swift in Sources */, + 9A2CFE9927C7CC3B00DD0EF9 /* CacheHelper.swift in Sources */, + 9A23103F27C825EB0092080A /* DataRetrieval.swift in Sources */, + 9AC14F0327C69A7400734379 /* PathController.swift in Sources */, + 9AC398002808F42C0027F6DC /* TestView.swift in Sources */, + 9AC397FE28089CF60027F6DC /* ConnectedMonitor.swift in Sources */, + 9A16715827C7F8DD00FA8C7D /* MathHelper.swift in Sources */, + 9A36096F2805ED7F00539713 /* TrackingAreaHelper.swift in Sources */, + 9AE7342B27C40B8000827C51 /* EventMonitorHelper.swift in Sources */, + 9ABC48882804B1EB0003D553 /* FloatDisplayView.swift in Sources */, + 9AE7609D27C97E07004D3D7A /* DebugHelper.swift in Sources */, + 9AE7340F27C4009900827C51 /* SettingsView.swift in Sources */, + 9A3609712805F6D800539713 /* InactiveWindowTapHelper.swift in Sources */, + 9A23104827C939930092080A /* FloatDisplay.swift in Sources */, + 9AC14F0127C6926C00734379 /* DisplayController.swift in Sources */, + 9A36097328060C8D00539713 /* FloatDisplayCloseView.swift in Sources */, + 9AE7341927C400D800827C51 /* InteractionController.swift in Sources */, + 9AE7609927C93ECC004D3D7A /* Display.swift in Sources */, + 9A9A635B27C41F14007E9552 /* ContentManager.swift in Sources */, + 9A23104627C9398B0092080A /* StatusDisplay.swift in Sources */, + 9ADC33C827D13CD90060AAAC /* UpdateController.swift in Sources */, + 9AC27F0727D2EAA6001A05BF /* SettingsController.swift in Sources */, + 9AC397F8280753B20027F6DC /* WindowDragHelper.swift in Sources */, + 9AC397FC280897D80027F6DC /* SnapPoint.swift in Sources */, + 9A16715A27C816D800FA8C7D /* MiscellaneousHelper.swift in Sources */, + 9AE7341D27C4011400827C51 /* InterfaceController.swift in Sources */, + 9ADC33C227CFD62F0060AAAC /* Components.swift in Sources */, + 9A36096D2805EB9300539713 /* FloatDisplayFieldCopyView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 9A41B02127FCD88A00E0C089 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 9A41B02827FCD88A00E0C089 /* Informant_Tests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 9A41B02A27FCD88A00E0C089 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 9A09C2AE27C3F455007C1FE7 /* Informant */; + targetProxy = 9A41B02927FCD88A00E0C089 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 9A09C2B827C3F457007C1FE7 /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 9A09C2B927C3F457007C1FE7 /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 9A09C2BC27C3F457007C1FE7 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 11.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 9A09C2BD27C3F457007C1FE7 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 11.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 9A09C2BF27C3F457007C1FE7 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = Informant/Informant.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = B2VHRNJV5H; + ENABLE_HARDENED_RUNTIME = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Informant/Info.plist; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; + INFOPLIST_KEY_NSAppleEventsUsageDescription = "Do you allow automation for this app?"; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSMainStoryboardFile = Main; + INFOPLIST_KEY_NSPrincipalClass = NSApplication; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 11.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.tyirvine.Informant; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 9A09C2C027C3F457007C1FE7 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = Informant/Informant.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = B2VHRNJV5H; + ENABLE_HARDENED_RUNTIME = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Informant/Info.plist; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; + INFOPLIST_KEY_NSAppleEventsUsageDescription = "Do you allow automation for this app?"; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSMainStoryboardFile = Main; + INFOPLIST_KEY_NSPrincipalClass = NSApplication; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 11.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.tyirvine.Informant; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 9A41B02B27FCD88A00E0C089 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = B2VHRNJV5H; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 12.2; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.tyirvine.Informant-Tests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Informant.app/Contents/MacOS/Informant"; + }; + name = Debug; + }; + 9A41B02C27FCD88A00E0C089 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = B2VHRNJV5H; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 12.2; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.tyirvine.Informant-Tests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Informant.app/Contents/MacOS/Informant"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 9A09C2AA27C3F455007C1FE7 /* Build configuration list for PBXProject "Informant" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 9A09C2BC27C3F457007C1FE7 /* Debug */, + 9A09C2BD27C3F457007C1FE7 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 9A09C2BE27C3F457007C1FE7 /* Build configuration list for PBXNativeTarget "Informant" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 9A09C2BF27C3F457007C1FE7 /* Debug */, + 9A09C2C027C3F457007C1FE7 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 9A41B02D27FCD88A00E0C089 /* Build configuration list for PBXNativeTarget "Informant-Tests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 9A41B02B27FCD88A00E0C089 /* Debug */, + 9A41B02C27FCD88A00E0C089 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 9A6C9BAE27C433C90053F696 /* XCRemoteSwiftPackageReference "LaunchAtLogin" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/sindresorhus/LaunchAtLogin"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 4.0.0; + }; + }; + 9AF246BD2829A66B008A7169 /* XCRemoteSwiftPackageReference "Sparkle" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/sparkle-project/Sparkle"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.0.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 9A6C9BAF27C433C90053F696 /* LaunchAtLogin */ = { + isa = XCSwiftPackageProductDependency; + package = 9A6C9BAE27C433C90053F696 /* XCRemoteSwiftPackageReference "LaunchAtLogin" */; + productName = LaunchAtLogin; + }; + 9AF246BE2829A66B008A7169 /* Sparkle */ = { + isa = XCSwiftPackageProductDependency; + package = 9AF246BD2829A66B008A7169 /* XCRemoteSwiftPackageReference "Sparkle" */; + productName = Sparkle; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 9A09C2A727C3F455007C1FE7 /* Project object */; +} diff --git a/Informant.xcodeproj/xcshareddata/xcschemes/Informant.xcscheme b/Informant.xcodeproj/xcshareddata/xcschemes/Informant.xcscheme new file mode 100644 index 0000000..333ac13 --- /dev/null +++ b/Informant.xcodeproj/xcshareddata/xcschemes/Informant.xcscheme @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Informant/AppDelegate.swift b/Informant/AppDelegate.swift new file mode 100644 index 0000000..2512c0a --- /dev/null +++ b/Informant/AppDelegate.swift @@ -0,0 +1,154 @@ +// +// AppDelegate.swift +// Informant +// +// Created by Ty Irvine on 2022-02-21. +// + +import Cocoa +import SwiftUI + +@main +class AppDelegate: NSObject, NSApplicationDelegate { + + // ----------------------------- Universal --------------------------- + + // Settings + var settings: Settings! + + // ----------------------------- Controllers --------------------------- + + // Status controller + var statusController: StatusController! + + // Interaction controller + var interactionController: InteractionController! + + // Data controller + var dataController: DataController! + + // Interface controller + var interfaceController: InterfaceController! + + // Display controller + var displayController: DisplayController! + + // Menu controller + var menuController: MenuController! + + // Path controller + var pathController: PathController! + + // Update controller + var updateController: UpdateController! + + // Settings controller + var settingsController: SettingsController! + + // ------------------------------- Displays ---------------------------- + + // Status display + var statusDisplay: StatusDisplay! + + // Float display + var floatDisplay: FloatDisplay! + + // ------------------------------- Windows ----------------------------- + + // Settings window + var settingsWindowController: WindowController! + + // Welcome window + var welcomeWindowController: WindowController! + + // About window + var aboutWindowController: WindowController! + + // Accessibiility window + var accessibilityWindowController: WindowController! + + // Payment window + var paymentWindowController: WindowController! + + // -------------------------- Directors ------------------------------ + + // Data director + var dataDirector: DataDirector! + + // Notification director + var notificationDirector: NotificationDirector! + + // -------------------------- Items ------------------------------ + + /// We use this to access the menu bar status item. This toggles the panel open and closed. It's the main button. + var statusItem: NSStatusItem? + + /// We use this to access the menu pop up. + var statusMenu: NSMenu? + + // -------------------------- Testing ------------------------------ + + #if DEBUG + var testing: Testing? + #endif + + // ======================== App Start โœณ๏ธ ============================ + + func applicationDidFinishLaunching(_ aNotification: Notification) { + + // MARK: - Initialize + + // Initialize settings + Settings.registerDefaults() + settingsController = SettingsController(appDelegate: self) + settings = settingsController.settings + + // Initialize controllers + menuController = MenuController(appDelegate: self) + statusController = StatusController(appDelegate: self) + interactionController = InteractionController(appDelegate: self) + dataController = DataController(appDelegate: self) + interfaceController = InterfaceController(appDelegate: self) + displayController = DisplayController(appDelegate: self) + pathController = PathController(appDelegate: self) + updateController = UpdateController(appDelegate: self) + + // Initialize directors + dataDirector = DataDirector() + notificationDirector = NotificationDirector(appDelegate: self) + + // Initialize displays + statusDisplay = StatusDisplay(appDelegate: self) + floatDisplay = FloatDisplay(appDelegate: self) + + // Initialize windows + settingsWindowController = WindowController(appDelegate: self, rootView: SettingsView(), fullToolbar: true) + welcomeWindowController = WindowController(appDelegate: self, rootView: WelcomeView()) + aboutWindowController = WindowController(appDelegate: self, rootView: AboutView()) + accessibilityWindowController = WindowController(appDelegate: self, rootView: AccessibilityView()) + paymentWindowController = WindowController(appDelegate: self, rootView: PaymentView()) + + // MARK: - Start up + + // Do an initialization of all displays + interfaceController.updatePause() + interfaceController.hideDisplays() + + #if DEBUG + // Testing + testing = Testing(appDelegate: self) + #endif + } + + // ======================== App Stop ๐Ÿ›‘ ============================ + + func applicationWillTerminate(_ aNotification: Notification) { + interactionController.monitorKeyPress?.stop() + interactionController.monitorMouseDismiss?.stop() + interactionController.monitorDisplayMouseDrag?.stop() + } + + func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/Informant/Assets.xcassets/AccentColor.colorset/Contents.json b/Informant/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/Informant/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Informant/Assets.xcassets/AppIcon.appiconset/Contents.json b/Informant/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..8d344d7 --- /dev/null +++ b/Informant/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "filename" : "informant-icon16x16.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" + }, + { + "filename" : "informant-icon16x16@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" + }, + { + "filename" : "informant-icon32x32.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" + }, + { + "filename" : "informant-icon32x32@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "filename" : "informant-icon128x128.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" + }, + { + "filename" : "informant-icon128x128@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" + }, + { + "filename" : "informant-icon256x256.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" + }, + { + "filename" : "informant-icon256x256@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "filename" : "informant-icon512x512.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "filename" : "informant-icon512x512@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Informant/Assets.xcassets/AppIcon.appiconset/informant-icon128x128.png b/Informant/Assets.xcassets/AppIcon.appiconset/informant-icon128x128.png new file mode 100644 index 0000000..50a675c Binary files /dev/null and b/Informant/Assets.xcassets/AppIcon.appiconset/informant-icon128x128.png differ diff --git a/Informant/Assets.xcassets/AppIcon.appiconset/informant-icon128x128@2x.png b/Informant/Assets.xcassets/AppIcon.appiconset/informant-icon128x128@2x.png new file mode 100644 index 0000000..74fd36e Binary files /dev/null and b/Informant/Assets.xcassets/AppIcon.appiconset/informant-icon128x128@2x.png differ diff --git a/Informant/Assets.xcassets/AppIcon.appiconset/informant-icon16x16.png b/Informant/Assets.xcassets/AppIcon.appiconset/informant-icon16x16.png new file mode 100644 index 0000000..d0999a8 Binary files /dev/null and b/Informant/Assets.xcassets/AppIcon.appiconset/informant-icon16x16.png differ diff --git a/Informant/Assets.xcassets/AppIcon.appiconset/informant-icon16x16@2x.png b/Informant/Assets.xcassets/AppIcon.appiconset/informant-icon16x16@2x.png new file mode 100644 index 0000000..77e3286 Binary files /dev/null and b/Informant/Assets.xcassets/AppIcon.appiconset/informant-icon16x16@2x.png differ diff --git a/Informant/Assets.xcassets/AppIcon.appiconset/informant-icon256x256.png b/Informant/Assets.xcassets/AppIcon.appiconset/informant-icon256x256.png new file mode 100644 index 0000000..74fd36e Binary files /dev/null and b/Informant/Assets.xcassets/AppIcon.appiconset/informant-icon256x256.png differ diff --git a/Informant/Assets.xcassets/AppIcon.appiconset/informant-icon256x256@2x.png b/Informant/Assets.xcassets/AppIcon.appiconset/informant-icon256x256@2x.png new file mode 100644 index 0000000..2f1b250 Binary files /dev/null and b/Informant/Assets.xcassets/AppIcon.appiconset/informant-icon256x256@2x.png differ diff --git a/Informant/Assets.xcassets/AppIcon.appiconset/informant-icon32x32.png b/Informant/Assets.xcassets/AppIcon.appiconset/informant-icon32x32.png new file mode 100644 index 0000000..77e3286 Binary files /dev/null and b/Informant/Assets.xcassets/AppIcon.appiconset/informant-icon32x32.png differ diff --git a/Informant/Assets.xcassets/AppIcon.appiconset/informant-icon32x32@2x.png b/Informant/Assets.xcassets/AppIcon.appiconset/informant-icon32x32@2x.png new file mode 100644 index 0000000..862f60d Binary files /dev/null and b/Informant/Assets.xcassets/AppIcon.appiconset/informant-icon32x32@2x.png differ diff --git a/Informant/Assets.xcassets/AppIcon.appiconset/informant-icon512x512.png b/Informant/Assets.xcassets/AppIcon.appiconset/informant-icon512x512.png new file mode 100644 index 0000000..2f1b250 Binary files /dev/null and b/Informant/Assets.xcassets/AppIcon.appiconset/informant-icon512x512.png differ diff --git a/Informant/Assets.xcassets/AppIcon.appiconset/informant-icon512x512@2x.png b/Informant/Assets.xcassets/AppIcon.appiconset/informant-icon512x512@2x.png new file mode 100644 index 0000000..798f1f8 Binary files /dev/null and b/Informant/Assets.xcassets/AppIcon.appiconset/informant-icon512x512@2x.png differ diff --git a/Informant/Assets.xcassets/Color/Backing.colorset/Contents.json b/Informant/Assets.xcassets/Color/Backing.colorset/Contents.json new file mode 100644 index 0000000..58b8e2e --- /dev/null +++ b/Informant/Assets.xcassets/Color/Backing.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xFF", + "red" : "0xFF" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.200", + "blue" : "0xFF", + "green" : "0xFF", + "red" : "0xFF" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Informant/Assets.xcassets/Color/Contents.json b/Informant/Assets.xcassets/Color/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Informant/Assets.xcassets/Color/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Informant/Assets.xcassets/Color/Primary.colorset/Contents.json b/Informant/Assets.xcassets/Color/Primary.colorset/Contents.json new file mode 100644 index 0000000..0425637 --- /dev/null +++ b/Informant/Assets.xcassets/Color/Primary.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.000", + "green" : "0.000", + "red" : "0.000" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Informant/Assets.xcassets/Contents.json b/Informant/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Informant/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Informant/Assets.xcassets/Icons/Contents.json b/Informant/Assets.xcassets/Icons/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Informant/Assets.xcassets/Icons/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Informant/Assets.xcassets/Icons/Status Bar/Contents.json b/Informant/Assets.xcassets/Icons/Status Bar/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Informant/Assets.xcassets/Icons/Status Bar/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Informant/Assets.xcassets/Icons/Status Bar/menubar-dark.imageset/Contents.json b/Informant/Assets.xcassets/Icons/Status Bar/menubar-dark.imageset/Contents.json new file mode 100644 index 0000000..e602092 --- /dev/null +++ b/Informant/Assets.xcassets/Icons/Status Bar/menubar-dark.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "menubar-dark.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Informant/Assets.xcassets/Icons/Status Bar/menubar-dark.imageset/menubar-dark.png b/Informant/Assets.xcassets/Icons/Status Bar/menubar-dark.imageset/menubar-dark.png new file mode 100644 index 0000000..48c2543 Binary files /dev/null and b/Informant/Assets.xcassets/Icons/Status Bar/menubar-dark.imageset/menubar-dark.png differ diff --git a/Informant/Assets.xcassets/Icons/Status Bar/menubar-default.imageset/Contents.json b/Informant/Assets.xcassets/Icons/Status Bar/menubar-default.imageset/Contents.json new file mode 100644 index 0000000..940ab52 --- /dev/null +++ b/Informant/Assets.xcassets/Icons/Status Bar/menubar-default.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "menubar-light.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Informant/Assets.xcassets/Icons/Status Bar/menubar-default.imageset/menubar-light.png b/Informant/Assets.xcassets/Icons/Status Bar/menubar-default.imageset/menubar-light.png new file mode 100644 index 0000000..64414fd Binary files /dev/null and b/Informant/Assets.xcassets/Icons/Status Bar/menubar-default.imageset/menubar-light.png differ diff --git a/Informant/Assets.xcassets/Icons/Status Bar/menubar-square.imageset/Contents.json b/Informant/Assets.xcassets/Icons/Status Bar/menubar-square.imageset/Contents.json new file mode 100644 index 0000000..f259eb4 --- /dev/null +++ b/Informant/Assets.xcassets/Icons/Status Bar/menubar-square.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "menubar-square.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Informant/Assets.xcassets/Icons/Status Bar/menubar-square.imageset/menubar-square.png b/Informant/Assets.xcassets/Icons/Status Bar/menubar-square.imageset/menubar-square.png new file mode 100644 index 0000000..45a22b4 Binary files /dev/null and b/Informant/Assets.xcassets/Icons/Status Bar/menubar-square.imageset/menubar-square.png differ diff --git a/Informant/Assets.xcassets/Icons/Status Picker/Contents.json b/Informant/Assets.xcassets/Icons/Status Picker/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Informant/Assets.xcassets/Icons/Status Picker/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Informant/Assets.xcassets/Icons/Status Picker/menubar-dark-picker.imageset/Contents.json b/Informant/Assets.xcassets/Icons/Status Picker/menubar-dark-picker.imageset/Contents.json new file mode 100644 index 0000000..ec45def --- /dev/null +++ b/Informant/Assets.xcassets/Icons/Status Picker/menubar-dark-picker.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "filename" : "menubar-dark-picker.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Informant/Assets.xcassets/Icons/Status Picker/menubar-dark-picker.imageset/menubar-dark-picker.svg b/Informant/Assets.xcassets/Icons/Status Picker/menubar-dark-picker.imageset/menubar-dark-picker.svg new file mode 100644 index 0000000..e4338df --- /dev/null +++ b/Informant/Assets.xcassets/Icons/Status Picker/menubar-dark-picker.imageset/menubar-dark-picker.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/Informant/Assets.xcassets/Icons/Status Picker/menubar-default-picker.imageset/Contents.json b/Informant/Assets.xcassets/Icons/Status Picker/menubar-default-picker.imageset/Contents.json new file mode 100644 index 0000000..a43e27b --- /dev/null +++ b/Informant/Assets.xcassets/Icons/Status Picker/menubar-default-picker.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "filename" : "menubar-light.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Informant/Assets.xcassets/Icons/Status Picker/menubar-default-picker.imageset/menubar-light.svg b/Informant/Assets.xcassets/Icons/Status Picker/menubar-default-picker.imageset/menubar-light.svg new file mode 100644 index 0000000..03df2d5 --- /dev/null +++ b/Informant/Assets.xcassets/Icons/Status Picker/menubar-default-picker.imageset/menubar-light.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/Informant/Assets.xcassets/Icons/Status Picker/menubar-square-picker.imageset/Contents.json b/Informant/Assets.xcassets/Icons/Status Picker/menubar-square-picker.imageset/Contents.json new file mode 100644 index 0000000..d65a3d6 --- /dev/null +++ b/Informant/Assets.xcassets/Icons/Status Picker/menubar-square-picker.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "filename" : "menubar-square-picker.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Informant/Assets.xcassets/Icons/Status Picker/menubar-square-picker.imageset/menubar-square-picker.svg b/Informant/Assets.xcassets/Icons/Status Picker/menubar-square-picker.imageset/menubar-square-picker.svg new file mode 100644 index 0000000..c256a54 --- /dev/null +++ b/Informant/Assets.xcassets/Icons/Status Picker/menubar-square-picker.imageset/menubar-square-picker.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/Informant/Assets.xcassets/Icons/twitter.imageset/Contents.json b/Informant/Assets.xcassets/Icons/twitter.imageset/Contents.json new file mode 100644 index 0000000..e11324f --- /dev/null +++ b/Informant/Assets.xcassets/Icons/twitter.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "icons8-twitter-60.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Informant/Assets.xcassets/Icons/twitter.imageset/icons8-twitter-60.png b/Informant/Assets.xcassets/Icons/twitter.imageset/icons8-twitter-60.png new file mode 100644 index 0000000..caeb2b8 Binary files /dev/null and b/Informant/Assets.xcassets/Icons/twitter.imageset/icons8-twitter-60.png differ diff --git a/Informant/Assets.xcassets/Images/AppIcon-noshadow.imageset/Contents.json b/Informant/Assets.xcassets/Images/AppIcon-noshadow.imageset/Contents.json new file mode 100644 index 0000000..e230024 --- /dev/null +++ b/Informant/Assets.xcassets/Images/AppIcon-noshadow.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "informant-icon 1.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Informant/Assets.xcassets/Images/AppIcon-noshadow.imageset/informant-icon 1.png b/Informant/Assets.xcassets/Images/AppIcon-noshadow.imageset/informant-icon 1.png new file mode 100644 index 0000000..bcd2d2f Binary files /dev/null and b/Informant/Assets.xcassets/Images/AppIcon-noshadow.imageset/informant-icon 1.png differ diff --git a/Informant/Assets.xcassets/Images/Contents.json b/Informant/Assets.xcassets/Images/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Informant/Assets.xcassets/Images/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Informant/Assets.xcassets/Videos/Contents.json b/Informant/Assets.xcassets/Videos/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Informant/Assets.xcassets/Videos/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Informant/Assets/lock.png b/Informant/Assets/lock.png new file mode 100644 index 0000000..4a59a81 Binary files /dev/null and b/Informant/Assets/lock.png differ diff --git a/Informant/Assets/pause.png b/Informant/Assets/pause.png new file mode 100644 index 0000000..3f09b6d Binary files /dev/null and b/Informant/Assets/pause.png differ diff --git a/Informant/Assets/resume.png b/Informant/Assets/resume.png new file mode 100644 index 0000000..8f4c81a Binary files /dev/null and b/Informant/Assets/resume.png differ diff --git a/Informant/Base.lproj/Main.storyboard b/Informant/Base.lproj/Main.storyboard new file mode 100644 index 0000000..8a7e6c2 --- /dev/null +++ b/Informant/Base.lproj/Main.storyboard @@ -0,0 +1,683 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Default + + + + + + + Left to Right + + + + + + + Right to Left + + + + + + + + + + + Default + + + + + + + Left to Right + + + + + + + Right to Left + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Informant/ContentManager.swift b/Informant/ContentManager.swift new file mode 100644 index 0000000..07e0015 --- /dev/null +++ b/Informant/ContentManager.swift @@ -0,0 +1,489 @@ +// +// LocalizableTriage.swift +// Informant +// +// Created by Ty Irvine on 2021-05-23. +// + +import Foundation + +/// This class is used to reduce the use of hard-coded strings in the source +class ContentManager { + + // MARK: - Messages + public enum Messages { + + static let setupAccessibilityNotEnabled = NSLocalizedString("Access Not Enabled", comment: "The message that appears if the user declines accessibility access.") + + static let settingsRootURLDescriptor = NSLocalizedString( + "Required in-order to display additional metadata and the size of a directory. Choose Macintosh HD.", + comment: "" + ) + } + + // MARK: - Labels + public enum Labels { + + static let use = NSLocalizedString("Use", comment: "") + + static let showDetailsIn = NSLocalizedString("Show details in", comment: "") + + static let options = NSLocalizedString("Options", comment: "") + + static let copied = NSLocalizedString("Copied", comment: "") + + static let close = NSLocalizedString("Close", comment: "") + + static let divider = NSLocalizedString(" โ€ข ", comment: "") + + // Volumes + static let volumeCapacity = NSLocalizedString("total", comment: "") + + static let volumeAvailable = NSLocalizedString("available", comment: "") + + static let volumePurgeable = NSLocalizedString("purgeable", comment: "") + + // Alerts + static let alertOK = NSLocalizedString("OK", comment: "") + + static let alertConfirmation = NSLocalizedString("Continue", comment: "") + + static let alertCancel = NSLocalizedString("Cancel", comment: "") + + static let alertWarningTitle = NSLocalizedString("Wait a minute!", comment: "") + + static let alertResetSettingsSuccessTitle = NSLocalizedString("Good to go!", comment: "") + + static let alertResetSettingsMessage = NSLocalizedString("Are you sure you want to do this? This will erase all your settings.", comment: "") + + static let alertResetSettingsMessageFinished = NSLocalizedString("Your settings have been reset to their defaults.", comment: "") + + // Settings + static let settingsResetButton = NSLocalizedString("Reset settings to default", comment: "") + + // Picker + static let displayDetailsIn = NSLocalizedString("Display:", comment: "") + + static let menubarIconDesc = NSLocalizedString("Icon:", comment: "") + + static let displayMenubar = NSLocalizedString("Menu Bar", comment: "") + + static let displayFloat = NSLocalizedString("Float Bar", comment: "") + + static let updateFrequency = NSLocalizedString("Updates:", comment: "") + + static let checkForUpdates = NSLocalizedString("Check for updates", comment: "") + + // Updates + static let updateAutoUpdate = NSLocalizedString("Automatically check for updates", comment: "") + + static let updateJustNotify = NSLocalizedString("Just notify me of updates", comment: "") + + static let updateDontDoAnything = NSLocalizedString("Don't do anything", comment: "") + + // Colors + static let red = NSLocalizedString("Red", comment: "The colour Red") + + static let orange = NSLocalizedString("Orange", comment: "The colour Orange") + + static let yellow = NSLocalizedString("Yellow", comment: "The colour Yellow") + + static let green = NSLocalizedString("Green", comment: "The colour Green") + + static let blue = NSLocalizedString("Blue", comment: "The colour Blue") + + static let purple = NSLocalizedString("Purple", comment: "The colour Purple") + + static let grey = NSLocalizedString("Grey", comment: "The colour Gray") + + // State + static let unavailable = NSLocalizedString("Unavailable", comment: "Used when calculating a property but it's not there") + + static let calculating = NSLocalizedString("Calculating...", comment: "Used when calculating a property") + + static let finding = NSLocalizedString("Finding...", comment: "Used when finding a property") + + static let findingItems = NSLocalizedString("Finding items...", comment: "Used when finding a property") + + static let findingSize = NSLocalizedString("Finding size...", comment: "Used when finding a property") + + static let findingNoPeriods = NSLocalizedString("Finding", comment: "") + + static let finished = NSLocalizedString("Finished", comment: "Used when calculating a property has come to end") + + // Authorization labels + static let authorizeInformant = NSLocalizedString("Allow Informant", comment: "Used on authorization welcome screen") + + static let authorizeNeedPermission = NSLocalizedString("Informant needs your permission to read file metadata.", comment: "Used on the authorize panel, it's the body string") + + // Authorization instruction labels + static let authorizedInstructionSystemPreferences = NSLocalizedString("Open System Preferences", comment: "Used on auth instructions") + + static let authorizedInstructionSystemPreferencesLong = NSLocalizedString("Open System Preferences, Security & Privacy, then Privacy", comment: "Used on auth instructions") + + static let authorizedInstructionSecurity = NSLocalizedString("Click Security & Privacy", comment: "Used on auth instructions") + + static let authorizedInstructionSecurityLong = NSLocalizedString("Go to Security & Privacy, then Privacy, then scroll down and select Accessibility", comment: "Used on auth instructions") + + static let authorizedInstructionPrivacy = NSLocalizedString("Click Privacy", comment: "Used on auth instructions") + + static let authorizedInstructionScrollAndClick = NSLocalizedString("Scroll down and click Accessibility", comment: "Used on auth instructions") + + static let authorizedInstructionCheckInformant = NSLocalizedString("Check Informant", comment: "Used on auth instructions") + + static let authorizedInstructionCheckInformantLong = NSLocalizedString("In Accessibility, make sure Informant is checked", comment: "Used on auth instructions") + + static let authorizedInstructionCheckFullDiskAccess = NSLocalizedString("In Full Disk Access, make sure Informant is checked (optional)", comment: "Used on auth instructions") + + static let authorizedInstructionRestartInformant = NSLocalizedString("Quit Informant and reopen it", comment: "") + + static let authorizedInstructionAutomationCheckFinder = NSLocalizedString("In Automation, under Informant, make sure Finder is checked", comment: "") + + // Auth. Padlock tidbit + static let authorizedInstructionClickLock = NSLocalizedString("If the checkbox is greyed out, click the lock and enter your password.", comment: "Used on authorized welcome panel") + + // Welcome labels + static let welcomeReadyToUse = NSLocalizedString("You're ready to use Informant!", comment: "Used on welcome panel") + + static let welcomeHowToUse = NSLocalizedString("To use Informant, select a file, and its size will appear in the menu bar.", comment: "Used on welcome panel") + + static let openPanelChoose = NSLocalizedString("Choose", comment: "") + + static let panelNoItemsSelected = NSLocalizedString("No selection", comment: "String displayed when no items are selected.") + + static let panelErrorTitle = NSLocalizedString("No access", comment: "The title displayed when the selection errors out") + + static let openPanelGrantAccess = NSLocalizedString("Grant Access", comment: "Open panel's grant access button label") + + static let menubar = NSLocalizedString("Menu Bar Utility", comment: "") + + static let display = NSLocalizedString("Display", comment: "") + + static let details = NSLocalizedString("Details", comment: "") + + static let panel = NSLocalizedString("Panel", comment: "") + + static let system = NSLocalizedString("System", comment: "") + + static let privacyPolicy = NSLocalizedString("Privacy policy", comment: "") + + static let donate = NSLocalizedString("Donate", comment: "") + + static let feedback = NSLocalizedString("Feedback", comment: "") + + static let license = NSLocalizedString("License", comment: "") + + static let about = NSLocalizedString("About", comment: "") + + static let acknowledgements = NSLocalizedString("Acknowledgements", comment: "") + + static let privacy = NSLocalizedString("Privacy", comment: "") + + static let eula = NSLocalizedString("EULA", comment: "") + + static let twitter = NSLocalizedString("Twitter", comment: "") + + static let github = NSLocalizedString("GitHub", comment: "") + + static let releases = NSLocalizedString("Releases", comment: "") + + static let help = NSLocalizedString("Help", comment: "") + + static let none = NSLocalizedString("None", comment: "") + + static let video = NSLocalizedString("Video:", comment: "") + + static let audio = NSLocalizedString("Audio:", comment: "") + + static let bitrate = NSLocalizedString("Bitrate", comment: "") + + // Menu bar sections + + static let menubarSectionsGeneral = NSLocalizedString("General", comment: "") + + static let menubarSectionsImages = NSLocalizedString("Images", comment: "") + + static let menubarSectionsAudio = NSLocalizedString("Audio", comment: "") + + static let menubarSectionsVideo = NSLocalizedString("Video", comment: "") + + static let menubarSectionsAudioVideo = NSLocalizedString("Video & Audio", comment: "") + + static let menubarSectionsMedia = NSLocalizedString("Media", comment: "") + + static let menubarSectionsVolume = NSLocalizedString("Volume", comment: "") + + // Menu bar labels + + static let menubarShowSize = NSLocalizedString("Size", comment: "") + + static let menubarShowKind = NSLocalizedString("Kind", comment: "") + + static let menubarShowName = NSLocalizedString("Name", comment: "") + + static let menubarShowPath = NSLocalizedString("Path", comment: "") + + static let menubarShowItems = NSLocalizedString("Items", comment: "") + + static let menubarShowCreated = NSLocalizedString("Created", comment: "") + + static let menubarShowEdited = NSLocalizedString("Edited", comment: "") + + static let menubarShowVersion = NSLocalizedString("Version", comment: "") + + static let menubarShowDuration = NSLocalizedString("Duration", comment: "") + + static let menubarShowDimensions = NSLocalizedString("Dimensions", comment: "") + + static let menubarShowCodecs = NSLocalizedString("Codecs", comment: "") + + static let menubarShowAudioCodec = NSLocalizedString("Audio Codec", comment: "") + + static let menubarShowVideoCodec = NSLocalizedString("Video Codec", comment: "") + + static let menubarShowColorProfile = NSLocalizedString("Color Profile", comment: "") + + static let menubarShowColorGamut = NSLocalizedString("Gamut", comment: "") + + static let menubarShowVideoBitrate = NSLocalizedString("Video Bitrate", comment: "") + + static let menubarShowSampleRate = NSLocalizedString("Sample Rate", comment: "") + + static let menubarShowAudioBitrate = NSLocalizedString("Audio Bitrate", comment: "") + + static let menubarShowBitrate = NSLocalizedString("Bitrate", comment: "") + + static let menubarShowVolumeTotal = NSLocalizedString("Total", comment: "") + + static let menubarShowVolumeAvailable = NSLocalizedString("Available", comment: "") + + static let menubarShowVolumePurgeable = NSLocalizedString("Purgeable", comment: "") + + static let menubarShowAperture = NSLocalizedString("Aperture", comment: "") + + static let menubarShowISO = NSLocalizedString("ISO", comment: "") + + static let menubarShowFocalLength = NSLocalizedString("Focal Length", comment: "") + + static let menubarShowCamera = NSLocalizedString("Camera", comment: "") + + static let menubarShowShutterspeed = NSLocalizedString("Shutter Speed", comment: "") + + static let menubarShowExtra = NSLocalizedString("Extra", comment: "") + + // System + + static let preferredShell = NSLocalizedString("Preferred Shell:", comment: "The shell application the user chooses to use") + + static let menubarShowiCloudContainer = NSLocalizedString("iCloud Container", comment: "") + + static let menubarCopyPathDescriptor = NSLocalizedString("Click the menu bar utility to copy the path.", comment: "") + + static let menubarIcon = NSLocalizedString("Icon", comment: "") + + static let pickRootURL = NSLocalizedString("Disk Path:", comment: "Pick the root url used to give security access to the app.") + + static let shortcutToActivatePanel = NSLocalizedString("Shortcut to activate panel", comment: "Asks the user what shortcut they want to activate the panel") + + static let launchOnStartup = NSLocalizedString("Launch Informant on system startup", comment: "Asks the user if they want the app to launch on startup") + + static let menubarUtilityShow = NSLocalizedString("Show Menu Bar Utility", comment: "Asks the user if they want the menubar-utility enabled") + + static let menubarUtilityHide = NSLocalizedString("Hide Menu Bar Utility", comment: "Asks the user if they want the menubar-utility disabled") + + static let toggleDetailsPanel = NSLocalizedString("Toggle panel shortcut:", comment: "Asks the user what shortcut they want to toggle the details panel") + + static let showFullPath = NSLocalizedString("Include name of selection in path", comment: "Asks the user if they want to see where the file is located instead of the full path") + + static let skipDirectories = NSLocalizedString("Skip getting size for folders and apps", comment: "Asks the user if they want to prevent the app from sizing directories.") + + static let skipDirectoriesShort = NSLocalizedString("Skip Sizing Directories", comment: "Asks the user if they want to prevent the app from sizing directories.") + + static let hideWhenUsingOtherApps = NSLocalizedString("Hide when using apps besides Finder", comment: "") + + static let hideName = NSLocalizedString("Hide full name", comment: "") + + static let hidePath = NSLocalizedString("Hide path", comment: "") + + static let hideCreated = NSLocalizedString("Hide date created", comment: "") + + static let hideIcon = NSLocalizedString("Hide icon", comment: "") + + static let pause = NSLocalizedString("Pause Informant", comment: "") + + static let resumeInformant = NSLocalizedString("Resume Informant", comment: "") + + static let resume = NSLocalizedString("Resume", comment: "") + + static let paused = NSLocalizedString("Paused", comment: "") + + static let tapToResume = NSLocalizedString("Tap to resume", comment: "") + + // Misc. Labels + static let multiSelectTitle = NSLocalizedString("items selected", comment: "The tag string to go on a multi-selection title in the panel") + + static let multiSelectSize = NSLocalizedString("Total Size:", comment: "The tag string under the title of the multi-selection panel") + + static let panelSnapZoneIndicator = NSLocalizedString("Release to snap", comment: "The indicator label when dragging the panel near the snap zone") + + // Panel Labels + + static let panelKind = NSLocalizedString("Kind", comment: "This is the file's kind displayed in the panel") + + static let panelSize = NSLocalizedString("Size", comment: "This is the file's size displayed in the panel") + + static let panelCreated = NSLocalizedString("Created", comment: "This is the file's creation date displayed in the panel") + + static let panelName = NSLocalizedString("Name", comment: "This is the file's name displayed on the panel") + + static let panelPath = NSLocalizedString("Path", comment: "This is the file's path displayed in the panel") + + static let panelWhere = NSLocalizedString("Where", comment: "This is the file's where path displayed in the panel") + + static let panelExpandedPath = NSLocalizedString("Expanded Path", comment: "This is the label that appears after clicking the path label on the panel") + + static let panelModified = NSLocalizedString("Edited", comment: "The tag string to the date modified on the panel interface") + + static let panelCamera = NSLocalizedString("Camera", comment: "Camera, Used on panel image view and movie") + + static let panelFocalLength = NSLocalizedString("Focal Length", comment: "Focal Length, Used on panel image view and movie") + + static let panelDimensions = NSLocalizedString("Dimensions", comment: "Dimensions, Used on panel image view and movie") + + static let panelColorProfile = NSLocalizedString("Color Profile", comment: "Color Profile, Used on panel image view and movie") + + static let panelAperture = NSLocalizedString("Aperture", comment: "Aperture, Used on panel image view and movie") + + static let panelExposure = NSLocalizedString("Exposure", comment: "Exposure, Used on panel image view and movie") + + static let panelCodecs = NSLocalizedString("Codecs", comment: "Codecs, Used on panel movie view") + + static let panelDuration = NSLocalizedString("Duration", comment: "Duration, Used on panel movie & audio view") + + static let panelSampleRate = NSLocalizedString("Sample Rate", comment: "Sample rate, Used on panel audio view") + + static let panelContains = NSLocalizedString("Contains", comment: "Contains, Used on the panel directory view") + + static let panelVersion = NSLocalizedString("Version", comment: "Version, Used on panel application view") + + static let panelAvailable = NSLocalizedString("Available", comment: "Available, Used on panel volume view") + + static let panelTotal = NSLocalizedString("Total", comment: "Total, Used on volume panel view") + + static let panelPurgeable = NSLocalizedString("Purgeable", comment: "Purgeable, Used on volume panel view") + + static let panelTags = NSLocalizedString("Tags", comment: "Used on tags panel view") + + static let panelHidden = NSLocalizedString("Hidden", comment: "Used on bottom of the panel view to indicate a hidden file") + + static let panelUnauthorized = NSLocalizedString("Unauthorized", comment: "Used on panel when accessibility api is disabled") + + static let panelAuthorize = NSLocalizedString("Authorize", comment: "Used on panel when accessibility api is disabled") + + // Panel Menu Labels + + static let menuAccessibility = NSLocalizedString("Authorize Informant", comment: "") + + static let menuAccessibilitySub = NSLocalizedString("Informant must be authorized to work", comment: "") + + static let menuPause = NSLocalizedString("Pause", comment: "") + + static let menuResume = NSLocalizedString("Resume", comment: "") + + static let menuShow = NSLocalizedString("Show", comment: "") + + static let menuHide = NSLocalizedString("Hide", comment: "") + + static let menuAbout = NSLocalizedString("About Informant...", comment: "About menu item in panel menu") + + static let menuPreferences = NSLocalizedString("Preferences...", comment: "Preferences menu item in panel menu") + + static let menuHelp = NSLocalizedString("Help", comment: "Help menu item in panel menu") + + static let menuQuit = NSLocalizedString("Quit", comment: "Quit menu item in panel menu") + + // Preferences Labels + static let preferencesShortcutsDisplayDetailPanel = NSLocalizedString("Display detail panel", comment: "Shortcut label for displaying panel") + } + + // MARK: - Icons + public enum Icons { + + // Universal + static let rightArrowIcon = "arrow.right" + + static let downArrowIcon = "arrow.down" + + // Panel + static let panelHidden = "eye.slash" + + static let panelCloud = "cloud" + + static let panelAlertCopied = "doc.on.doc" + + static let panelLockSlash = "lock.slash.fill" + + static let panelNoAccess = "minus.circle" + + static let panelHideButton = "xmark" + + static let panelPreferencesButton = "gear" + + static let panelTerminalButton = "arrowupforwardapp" + + static let panelPathIcon = "externaldrive" + + static let panelCopyIcon = "square.on.square" + + // Auth. + static let authLockIcon = "lock.fill" + + static let authUnlockIcon = "lock.open.fill" + + static let pause = "pause.png" + + static let resume = "resume.png" + + static let lock = "lock.png" + + static let noIcon = NSLocalizedString("No Icon", comment: "") + + static let menubarBlank = "menubar-blank" + + static let menubarDefault = "menubar-default" + + static let menubarDoc = "menubar-doc" + + static let menubarDrive = "menubar-drive" + + static let menubarFolder = "menubar-folder" + + static let menubarInfo = "menubar-info" + + static let menubarViewfinder = "menubar-viewfinder" + + static let menubarSquare = "menubar-square" + + static let menubarDark = "menubar-dark" + } + + // MARK: - Images + public enum Images { + + static let appIcon = "AppIcon" + + static let appIconNoShadow = "AppIcon-noshadow" + } + + // MARK: - Extra + public enum Extra { + + static let items = NSLocalizedString("items", comment: "Items, used in the single directory selection") + + static let item = NSLocalizedString("item", comment: "Singular version of items") + + static let popUpCopied = NSLocalizedString("Copied", comment: "Used on the copied popup") + + static let popUpPathCopied = NSLocalizedString("Path Copied", comment: "Used on the copied popup") + } +} diff --git a/Informant/Controllers/AlertController.swift b/Informant/Controllers/AlertController.swift new file mode 100644 index 0000000..0888f32 --- /dev/null +++ b/Informant/Controllers/AlertController.swift @@ -0,0 +1,37 @@ +// +// AlertController.swift +// Informant +// +// Created by Ty Irvine on 2022-03-05. +// + +import AppKit +import Foundation + +class AlertController: Controller, ControllerProtocol { + + required init(appDelegate: AppDelegate) { + super.init(appDelegate: appDelegate) + } + + /// Runs a simple confirmation alert. + static func alertOK(title: String, message: String) { + let alert = NSAlert() + alert.alertStyle = .informational + alert.messageText = title + alert.informativeText = message + alert.addButton(withTitle: ContentManager.Labels.alertOK) + alert.runModal() + } + + /// Runs a simple warning cancel/confirm alert. Confirm is the first button. Cancel is the second. + static func alertWarning(title: String, message: String) -> NSApplication.ModalResponse { + let alert = NSAlert() + alert.alertStyle = .critical + alert.messageText = title + alert.informativeText = message + alert.addButton(withTitle: ContentManager.Labels.alertConfirmation) + alert.addButton(withTitle: ContentManager.Labels.alertCancel) + return alert.runModal() + } +} diff --git a/Informant/Controllers/Controller.swift b/Informant/Controllers/Controller.swift new file mode 100644 index 0000000..21e5272 --- /dev/null +++ b/Informant/Controllers/Controller.swift @@ -0,0 +1,26 @@ +// +// Controlelr.swift +// Informant +// +// Created by Ty Irvine on 2022-02-21. +// + +import Foundation + +/// This is the base class that all controllers adhere to. +class Controller { + + let appDelegate: AppDelegate + + required init(appDelegate: AppDelegate) { + + // Gather refs + self.appDelegate = appDelegate + } +} + +/// This is the protocol that all controllers adhere to. +protocol ControllerProtocol: Controller { + var appDelegate: AppDelegate { get } + init(appDelegate: AppDelegate) +} diff --git a/Informant/Controllers/DataController.swift b/Informant/Controllers/DataController.swift new file mode 100644 index 0000000..77c4b83 --- /dev/null +++ b/Informant/Controllers/DataController.swift @@ -0,0 +1,176 @@ +// +// DataController.swift +// Informant +// +// Created by Ty Irvine on 2022-02-21. +// + +import AVFoundation +import Foundation +import SwiftUI +import UniformTypeIdentifiers + +/// Controls the flow of selection details and provides them to requesting views. +class DataController: Controller, ControllerProtocol { + + /// Keeps copies of recent selections for later use. + let cache: Cache! + + required init(appDelegate: AppDelegate) { + + // Initialize cache + cache = Cache() + + super.init(appDelegate: appDelegate) + } + + // MARK: - Packaging Functions + + /// This packages externally provided data and returns it. + func package(_ data: SelectionData) -> SelectionData? { + + // Filter data for settings + guard let selection = appDelegate.settings.applySettings(data) else { + return nil + } + + return selection + } + + // MARK: - Selection Functions + + /// Determines the selection type and then returns the data for it. + func getSelection(_ paths: Paths? = nil) -> Selection? { + + // Gather paths + let gatheredPaths = paths ?? appDelegate.pathController.getSelectedPaths() + + // Get paths + guard let selectedPaths = gatheredPaths else { + return nil + } + + // Check on the state of the path object + switch selectedPaths.state { + + case .PathUnavailable: + return Selection(type: .None, info: nil, data: nil) + + case .PathDuplicate: + return Selection(type: .Duplicate, info: nil, data: nil) + + case .PathError: + return Selection(type: .Error, info: nil, data: nil) + + default: + break + } + + // Get the paths + guard let paths = selectedPaths.paths else { + return nil + } + + // Get selection type + guard let selectionType = getSelectionType(paths: paths) else { + return Selection(type: .Error, info: nil, data: nil) + } + + // Get utility data + let info = getSelectionInfo(type: selectionType, paths: paths) + + // TODO: Verify that this fix is okay in the future + // โš ๏ธ .iCloud file checking has been removed + + // Get display data + let data = getSelectionData(info: info) + + // Return selection + return Selection( + type: selectionType, + info: info, + data: data + ) + } + + /// Determine selection type. + private func getSelectionType(paths: [String]) -> SelectionType? { + + // Determine if the selection is a single item or multiple items + if paths.count >= 2 { + return .Multi + } + + // Get the selection type for a singal item + return DataUtility.getSelectionTypeSingle(path: paths[0]) + } + + /// Pull in data for the determined selection type. + private func getSelectionData(info: SelectionInfo) -> SelectionData? { + + // Initialize data retrieval object + guard let retriever = DataRetrieval(cache: cache, info: info) else { + return nil + } + + // Make a retrieval of data + return retriever.retrieve() + } + + /// Pull in specifics to the selection. + private func getSelectionInfo(type: SelectionType, paths: [String]) -> SelectionInfo { + + // Abort if it's a multi item selection + if type == .Multi { + + // Get the disk name + let diskNameURL = URL(fileURLWithPath: paths[1]) + let diskName = diskNameURL.pathComponents[1] + + // Filter out the disk name + let urls: [URL] = paths.map { path in + let pathWithoutDisk = path.replacingOccurrences(of: diskName, with: "") + return URL(fileURLWithPath: pathWithoutDisk) + } + + return SelectionInfo( + appDelegate: appDelegate, + urls: urls, + type: type, + isiCloudSyncFile: nil, + isHidden: nil + ) + } + + // Create url + let url = URL(fileURLWithPath: paths[0]) + + // Gather all resources + let resources = DataUtility.getURLResources(url, [.isUbiquitousItemKey, .isHiddenKey]) + + // Exit + return SelectionInfo( + appDelegate: appDelegate, + urls: [url], + type: type, + isiCloudSyncFile: resources?.isUbiquitousItem, + isHidden: resources?.isHidden + ) + } + + // MARK: - Data Functions + + // iCloud Fix + /// Abort if certain requirements are met. For example, the selection ending with an .icloud extension. + /* + func shouldSelectionAbort(info: SelectionInfo) -> Bool? { + + // Abort if it's an iCloud sync file + if info.isiCloudSyncFile == true { + return true + } + + return nil + } + */ +} diff --git a/Informant/Controllers/DisplayController.swift b/Informant/Controllers/DisplayController.swift new file mode 100644 index 0000000..74945f8 --- /dev/null +++ b/Informant/Controllers/DisplayController.swift @@ -0,0 +1,436 @@ +// +// DisplayController.swift +// Informant +// +// Created by Ty Irvine on 2022-02-23. +// + +import AppKit +import Foundation + +/// Controls all available display views and delegates the flow of data to them. +class DisplayController: Controller, ControllerProtocol { + + /// These are all the display types used within the app. + enum Displays: String { + case StatusDisplay + case FloatDisplay + } + + /// This holds all the monitors connected and their dimensions. + var connectedMonitors: [ConnectedMonitor] + + /// This holds all the snap zones from all connected monitors. + var allSnapPoints: [SnapPoint] + + required init(appDelegate: AppDelegate) { + + // Get all connected monitors and snap zones + connectedMonitors = Self.getConnectedMonitors() + allSnapPoints = Self.getAllSnapPoints(connectedMonitors) + + super.init(appDelegate: appDelegate) + } + + // MARK: - Display General Functions + + /// Updates all active display views. + func updateDisplays() { + + // Pull in the current selection + guard let currentSelection = appDelegate.dataController.getSelection() else { + return + } + + // Get the current chosen display + let display = findChosenDisplay() + + // Check the display type before proceeding + switch currentSelection.type { + case .None: + hideDisplay(display) + return + + case .Duplicate: + return + + case .Error: + errorDisplay(display) + return + + default: + break + } + + // Otherwise, nil check the data + guard let data = currentSelection.data else { + hideDisplay(display) + return + } + + guard let info = currentSelection.info else { + hideDisplay(display) + return + } + + // Close non-chosen displays + closeNonChosenDisplays(display) + + // Show or update the chosen display + updateDisplay(display, data, info) + } + + /// Hides all active display views. + func hideDisplays() { + + // Get the current chosen display + let display = findChosenDisplay() + + // Hide the chosen display + hideDisplay(display) + } + + // MARK: - Display Specialized Functions + + /// This allows the currently chosen display to be updated externally. + /// Make sure settings are applied. + func updateDisplayExternally(_ data: SelectionData, _ info: SelectionInfo) { + + // Packages the provided data + guard let packagedData = appDelegate.dataController.package(data) else { + return + } + + // Updates the current display + let chosenDisplay = findChosenDisplay() + chosenDisplay.update(packagedData, info) + } + + /// Updates the currently chosen display. + private func updateDisplay(_ chosenDisplay: Display, _ data: SelectionData, _ info: SelectionInfo) { + chosenDisplay.update(data, info) + } + + /// Wipes all information off visible displays. + private func hideDisplay(_ chosenDisplay: Display) { + chosenDisplay.hide() + } + + /// Displays an error message. + private func errorDisplay(_ chosenDisplay: Display) { + chosenDisplay.error() + } + + /// Returns the appropriate display object. + private func pickDisplay(_ chosenDisplay: DisplayController.Displays) -> Display { + switch chosenDisplay { + case .StatusDisplay: + return appDelegate.statusDisplay + + case .FloatDisplay: + return appDelegate.floatDisplay + } + } + + /// Switches which displays should be shown. + func closeNonChosenDisplays(_ chosenDisplay: Display) { + + // Get all displays + guard let allDisplays = findAllDisplays() else { + return + } + + for display in allDisplays { + + // If display is closed - continue + if display.isClosed == true { + continue + } + + // If only display open is the chosen display - continue + else if display.self === chosenDisplay.self { + continue + } + + // Otherwise, close display because it isn't chosen + else { + display.hide() + } + } + } + + // MARK: - Connected Monitor Functions + + /// This checks the connected monitors and makes sure they match the ones held in memory. + static func getConnectedMonitors() -> [ConnectedMonitor] { + + var monitors: [ConnectedMonitor] = [] + + for screen in NSScreen.screens { + let monitor = ConnectedMonitor(screen) + monitors.append(monitor) + } + + return monitors + } + + /// Determines if there is a change in the connected monitors. + func haveConnectedMonitorsChanged() -> Bool { + + let foundMonitors = Self.getConnectedMonitors() + let connectedMonitors = connectedMonitors + + // Make sure both sets of monitors have the same count + if foundMonitors.count != connectedMonitors.count { + return false + } + + // Compare the monitors held in memory to the ones found + for (index, connectedMonitor) in connectedMonitors.enumerated() { + + // Grab ref. to the found monitor + let foundMonitor = foundMonitors[index] + + // Check to make sure the screens are the same + if (connectedMonitor.screen != foundMonitor.screen) || (connectedMonitor.dimensions != foundMonitor.dimensions) { + return false + } + } + + // Otherwise the monitors held in memory are valid + return true + } + + /// Issues updates to the monitors. + func updateMonitors() { + + // Reassign monitors and snap points + if haveConnectedMonitorsChanged() == false { + print("๐Ÿ–ฅ MONITORS & SNAP ZONES REASSIGNED") + connectedMonitors = Self.getConnectedMonitors() + allSnapPoints = Self.getAllSnapPoints(connectedMonitors) + } + + // Re-snap any detached displays + guard let chosenDetachedDisplay = findChosenDisplay() as? DetachedDisplay else { + return + } + + chosenDetachedDisplay.refresh() + } + + // MARK: - Detached Display Functions + + /// Snaps the currently chosen display. + func snapDisplays(_ event: NSEvent?) { + + // Bail if this event doesn't even have a window + guard let eventWindow = event?.window else { + return + } + + // Get the chosen display and... + // make sure it conforms to the detached display protocol + guard let chosenDetachedDisplay = findChosenDisplay() as? DetachedDisplayProtocol else { + return + } + + // Make sure the drag is being applied to our window + guard eventWindow == chosenDetachedDisplay.window() else { + return + } + + // Snap it + chosenDetachedDisplay.snap() + } + + /// This generates a set of snap zones for any given physical monitor. + static func getAllSnapPoints(_ connectedMonitors: [ConnectedMonitor]) -> [SnapPoint] { + + // Make sure we have the connected monitors + let monitors = connectedMonitors + + var snapPoints: [SnapPoint] = [] + + // Collects all snap zone collections from each monitor + for monitor in monitors { + let monitorsSnapPoints = monitor.getSnapPoints() + snapPoints.append(contentsOf: monitorsSnapPoints) + } + + return snapPoints + } + + /// This finds the closest snap point to the display. + /// Returns nil if no point is found. + func closestSnapPointSearch(_ frame: NSRect) -> SnapPointSearch? { + + let snapPoints = appDelegate.displayController.allSnapPoints + + // Collect display's points + let displayPoints = FramePoint.getPoints(frame: frame) + + // This will hold our sort information + var closestPoint: SnapPointSearch? + + // Cycle snap zones and see which zone the display is in + for snapPoint in snapPoints { + + // Find the corresponding point + guard let displayPoint = FramePoint.getCorrespondingPoint(displayPoints, snapPoint.position) else { + return nil + } + + // Find the distance + let foundDistance = snapPoint.distance(displayPoint.point) + + // Determine if the shortest distance ref. has been filled + if closestPoint == nil { + closestPoint = SnapPointSearch(snapPoint, displayPoints, foundDistance) + } + + // Otherwise see if there is a new shortest distance + else if let _shortestDistance = closestPoint?.distance, foundDistance < _shortestDistance { + closestPoint = SnapPointSearch(snapPoint, displayPoints, foundDistance) + } + } + + // Save the snap point for future snaps or sessions + closestPoint?.snapPointFound.save() + + return closestPoint + } + + /// Find the saved snap point. + func findSavedSnapPoint() -> SnapPoint { + + // Get the snap point - If the snap point is nil, return the default value + guard let snapPoint = SnapPoint.read() else { + return getDefaultSnapPoint() + } + + // Validate that the snap point exists + guard allSnapPoints.contains(where: { $0.origin == snapPoint.origin }) == true else { + return getDefaultSnapPoint() + } + + // Return the snap point + return snapPoint + } + + /// Gets the default snap point. + func getDefaultSnapPoint() -> SnapPoint { + + // Get the first monitor and use that to get the default point + let primaryMonitor = connectedMonitors[0] + + // Get the default point + return primaryMonitor.getDefaultPoint() + } + + // MARK: - Universal Display Functions + + + + /// This pulls in the selection data from the chosen display and returns it. + func findCurrentSelection() -> SelectionData? { + let chosenDisplay = findChosenDisplay() + return chosenDisplay.selectionData + } + + /// Picks out the chosen display (float, panel, etc.) using the setting held in memory. + func findChosenDisplay() -> Display { + let chosenDisplay = appDelegate.settings.settingChosenDisplay + return pickDisplay(chosenDisplay) + } + + /// These are all the displays used within the app. + func findAllDisplays() -> [Display]? { + + guard let statusDisplay = appDelegate.statusDisplay else { + return nil + } + + guard let floatDisplay = appDelegate.floatDisplay else { + return nil + } + + return [ + statusDisplay, + floatDisplay, + ] + } + + /// Simply lets you know the open status of the window. + func isDisplayOpen() -> Bool? { + + guard let chosenDisplay = findChosenDisplay() as? DetachedDisplay else { + return false + } + + return chosenDisplay.isOpen + } + + /// Formats selection data in a line. + func formatSelectionAsFields(_ data: SelectionData, _ info: SelectionInfo) -> [SelectionField]? { + + // Convert the data to a list + let selection = data.toListOfFields() + + // If the selection is empty abort + if selection.isEmpty { + return nil + } + + // Holds formatted selection + var line: [SelectionField] = [] + + // Otherwise build the line + for (index, item) in selection.enumerated() { + + // Place item + line.append(item) + + // Then divider if this isn't the last item + if (selection.count - 1) != index { + line.append(SelectionField(value: ContentManager.Labels.divider, type: .Divider)) + } + } + + // Add in a loading indicator if the selection is still loading + if appDelegate.dataDirector.isSelectionLoading(info.id) { + line.append(SelectionField(value: nil, type: .LoadingIndicator)) + } + + // Return the built line + return line + } + + /// Formats the selection data in a one line string. + func formatSelectionAsString(_ data: SelectionData, _ info: SelectionInfo, spaceAtEnd: Bool) -> String? { + + // Convert the data to a list of strings + var selection = data.toListOfStrings() + + // If the selection is empty abort + if selection.isEmpty { + return nil + } + + // Add in a loading indicator if the selection is still loading + if appDelegate.dataDirector.isSelectionLoading(info.id) { + selection.append(ContentManager.Labels.findingItems) + } + + // Add in the dividers + var line = selection.joined(separator: ContentManager.Labels.divider) + + // Adds a space at the end if required + if spaceAtEnd == true { + line.append(" ") + } + + // Return the built line + return line + } +} diff --git a/Informant/Controllers/InteractionController.swift b/Informant/Controllers/InteractionController.swift new file mode 100644 index 0000000..b14289c --- /dev/null +++ b/Informant/Controllers/InteractionController.swift @@ -0,0 +1,143 @@ +// +// InteractionController.swift +// Informant +// +// Created by Ty Irvine on 2022-02-21. +// + +import Foundation +import SwiftUI + +class InteractionController: Controller, ControllerProtocol { + + // Universal interaction monitors + public var monitorMouseDismiss: EventMonitorHelper? + public var monitorKeyPress: EventMonitorHelper? + + // Display interaction monitors + public var monitorDisplayMouseDrag: EventMonitorHelper? + + // ----------- Initialization -------------- + + required init(appDelegate: AppDelegate) { + super.init(appDelegate: appDelegate) + + // Monitors mouse events + monitorMouseDismiss = EventMonitorHelper(mask: [.leftMouseDown, .rightMouseDown, .leftMouseUp, .rightMouseUp], handler: handlerMouseDismiss) + + // Monitors key events + monitorKeyPress = EventMonitorHelper(mask: [.keyDown, .keyUp], handler: handlerArrowKeys) + + // Monitor drags + monitorDisplayMouseDrag = EventMonitorHelper(mask: [.leftMouseUp, .rightMouseUp, .otherMouseUp], handler: handlerDisplayMouseDrag) + + // These get stopped when the application is torn down + monitorMouseDismiss?.start() + monitorKeyPress?.start() + monitorDisplayMouseDrag?.start() + } + + // MARK: - Interface Functions + + /// Tells the display controller we want to hide the displayed views. + func hideDisplays() { + appDelegate.interfaceController.hideDisplays() + } + + /// Tells the display controller we want to update the displayed views. + func updateDisplays() { + appDelegate.interfaceController.updateDisplays() + + // TODO: Try and come up with a better solution for this. + // Selections are being found faster than Finder can provide the selection, therefore, + // a double-check is needed to confirm the correct selection was made. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + self.appDelegate.interfaceController.updateDisplays() + } + } + + // MARK: - Display Monitor Functions + + /// Helper function to activate a snap check for the currently selected display. + func handlerDisplayMouseDrag(event: NSEvent?) { + appDelegate.interfaceController.snapDisplays(event) + } + + // MARK: - Monitor Functions + + /// Helper function to let us know what type of event the provided one is + func eventTypeCheck(_ event: NSEvent?, types: [NSEvent.EventType]) -> Bool { + for type in types { + if event?.type == type { + return true + } + } + + // All checks down with no positives found + return false + } + + // MARK: Mouse Dismiss + // Hides interface if no finder items are selected. Otherwise update the interface - based on left and right clicks + func handlerMouseDismiss(event: NSEvent?) { + + // A selection is intended so send an update to the display controller + if eventTypeCheck(event, types: [.leftMouseUp, .rightMouseUp, .otherMouseUp]) { + updateDisplays() + } + } + + // MARK: Key Detection + /// Used by the keyedWindowHandler to decide how many updates to the interface to do + var keyCounter = 0 + + /// Used by the key down & up monitor, this updates the interface if it's an arrow press and closes it with any other press + func handlerArrowKeys(event: NSEvent?) { + + /// If it's a repeating key, update the interface every other key instead + /// Once the user lifts the key this function is called again - that key lift doesn't count as a repeating key. + /// So that means that this block โคต๏ธŽ is skipped when the user lifts their held keypress meaning that + /// the interface will get updated immediately with the selected file. + + /// Finder uses simillar functionallity with it's quicklook + if event!.isARepeat { + + // Adds to count and will only update when the threshold below is reached + keyCounter += 1 + + // Checks every 10 items. A good blend between performance and power consumption + if keyCounter >= 10 { + updateDisplays() + keyCounter = 0 + return + } + else { + return + } + } + + // ---------------------- Start execution --------------------- + let key = event?.keyCode + + switch key { + + // If esc key press is detected on down press then hide the interface + case 53: + if event?.type == NSEvent.EventType.keyDown { + hideDisplays() + } + break + + // if โŒ˜ + del is pressed, then hide all interfaces + case 51: + if event?.modifierFlags.contains(.command) == true { + hideDisplays() + } + + // Otherwise, update the displays + default: + updateDisplays() + break + } + } +} diff --git a/Informant/Controllers/InterfaceController.swift b/Informant/Controllers/InterfaceController.swift new file mode 100644 index 0000000..22a01c5 --- /dev/null +++ b/Informant/Controllers/InterfaceController.swift @@ -0,0 +1,130 @@ +// +// DisplayController.swift +// Informant +// +// Created by Ty Irvine on 2022-02-21. +// + +import Foundation +import SwiftUI + +/// Controls all interfaces in the app and is responsible for issuing app wide updates. +/// Used to check all system settings as well. +class InterfaceController: Controller, ControllerProtocol { + + required init(appDelegate: AppDelegate) { + super.init(appDelegate: appDelegate) + } + + // MARK: - Update Functions + + /// Updates all interfaces. + func updateAllInterfaces(reset: Bool = false) { + if reset { resetAppState() } + updateDisplays() + appDelegate.menuController.updateMenu() + } + + /// Updates active displays while checking for settings that need updates. + func updateDisplays() { + + // Is app paused? + if appDelegate.settings.settingPauseApp == true { + return hideDisplays() + } + + // Is the display still shown when not using Finder? + else if appDelegate.settings.settingHideWhenViewingOtherApps == true { + + // If active app is not finder then abort execution + if utilityIsActiveAppFinder() == false { + return hideDisplays() + } + } + + // Exit + appDelegate.displayController.updateDisplays() + } + + /// Changes the status bar icon. + func updateIcon() { + StatusController.updateIcon() + updateAllInterfaces(reset: true) + } + + /// Checks on the pause state of the app. + func updatePause() { + + // See if the app is still paused + if appDelegate.settings.settingPauseApp == true { + hideAllInterfaces() + StatusController.pauseIcon(paused: true) + } + + // Otherwise resume the app + else { + updateAllInterfaces(reset: true) + StatusController.pauseIcon(paused: false) + } + } + + // MARK: - Hide Functions + + /// Hides all interfaces. + func hideAllInterfaces() { + hideDisplays() + appDelegate.menuController.updateMenu() + } + + /// Hides active displays. + func hideDisplays() { + appDelegate.displayController.hideDisplays() + } + + // MARK: - Snap Functions + + /// Snaps the display. + func snapDisplays(_ event: NSEvent?) { + appDelegate.displayController.snapDisplays(event) + } + + // MARK: - Window Functions + + #warning("Add in interface controls") + func openAboutWindow() { + appDelegate.aboutWindowController.open() + } + + func openAccessibilityWindow() { + appDelegate.accessibilityWindowController.open() + } + + func openWelcomeWindow() { + appDelegate.welcomeWindowController.open() + } + + func openLicensePanel() { + } + + // MARK: - Utility Functions + + /// Resets state fields across the app. + private func resetAppState() { + appDelegate.pathController.oldSelection = nil + } + + /// Checks to see if the active app is Finder + private func utilityIsActiveAppFinder() -> Bool? { + + // Get the bundle id + let bundleID = NSWorkspace.shared.frontmostApplication?.bundleIdentifier + guard let appBundleID = NSRunningApplication.current.bundleIdentifier else { return nil } + + // If we're not interacting with Finder then hide the interface + if bundleID != appBundleID, bundleID != "com.apple.finder" { + return false + } + + return true + } +} diff --git a/Informant/Controllers/MenuController.swift b/Informant/Controllers/MenuController.swift new file mode 100644 index 0000000..d382b38 --- /dev/null +++ b/Informant/Controllers/MenuController.swift @@ -0,0 +1,143 @@ +// +// MenuController.swift +// Informant +// +// Created by Ty Irvine on 2022-02-21. +// + +import AppKit +import Foundation + +/// Controls the NSMenu instance for the app. +class MenuController: Controller, ControllerProtocol { + + let menu: NSMenu! + + // Standard menu items + let menuItemAccessibility: NSMenuItem! + let menuItemPause: NSMenuItem! + let menuItemAbout: NSMenuItem! + let menuItemPreferences: NSMenuItem! + let menuItemQuit: NSMenuItem! + + required init(appDelegate: AppDelegate) { + + // MARK: - Setup menu bar items + + // Create NSMenu instance + appDelegate.statusMenu = NSMenu() + + // Assign NSMenu + menu = appDelegate.statusMenu + + // Create NSMenuItem instances + menuItemAccessibility = NSMenuItem(title: ContentManager.Labels.menuAccessibility, action: #selector(menuAccessibility), keyEquivalent: "") + menuItemPause = NSMenuItem(title: ContentManager.Labels.menuPause, action: #selector(menuPauseApp), keyEquivalent: "") + menuItemAbout = NSMenuItem(title: ContentManager.Labels.menuAbout, action: #selector(menuAbout), keyEquivalent: "") + menuItemPreferences = NSMenuItem(title: ContentManager.Labels.menuPreferences, action: #selector(menuPreferences), keyEquivalent: ",") + menuItemQuit = NSMenuItem(title: ContentManager.Labels.menuQuit, action: #selector(menuQuitApp), keyEquivalent: "") + + super.init(appDelegate: appDelegate) + + // Assign menu item target's to this instance of this class + menuItemAccessibility.target = self + menuItemPause.target = self + menuItemAbout.target = self + menuItemPreferences.target = self + menuItemQuit.target = self + + // Assign images to the menu items + menuItemAccessibility.setupImage(ContentManager.Icons.lock) + menuItemPause.setupImage(ContentManager.Icons.pause) + + // Setup the NSMenu + updateMenu() + + // Set the size of the NSMenu + menu.minimumWidth = 225 + } + + // MARK: - Menu Functions + + /// This updates the state of the menu and adds, removes, or disables menu items depending on the settings controller. + func updateMenu() { + + print("๐Ÿฏ Accessibility Status: \(String(describing: appDelegate.settings.statePrivacyAccessibilityEnabled))") + + // Remove authorization menu item if authorized and restore all other items + if appDelegate.settings.statePrivacyAccessibilityEnabled == true { + menu.removeAllItems() + setupMenuDefault() + } + + // Add authroization menu item if not authorized and hide all other items except for the quit button + else { + menu.removeAllItems() + setupMenuAccessibility() + } + + // Exit + updateMenuItems() + appDelegate.statusItem?.menu = menu + } + + /// Updates menu item titles based on the state of the settings controller. + func updateMenuItems() { + + // Pause app + if appDelegate.settings.settingPauseApp == true { + menuItemPause.title = ContentManager.Labels.menuResume + menuItemPause.setupImage(ContentManager.Icons.resume) + } else { + menuItemPause.title = ContentManager.Labels.menuPause + menuItemPause.setupImage(ContentManager.Icons.pause) + } + } + + /// This is the default set up for the NSMenu. + func setupMenuDefault() { + menu.addItem(menuItemPause) + menu.addItem(NSMenuItem.separator()) + menu.addItem(menuItemAbout) + menu.addItem(menuItemPreferences) + menu.addItem(NSMenuItem.separator()) + menu.addItem(menuItemQuit) + } + + /// This is the accessibility set up for the NSMenu. + func setupMenuAccessibility() { + menu.addItem(menuItemAccessibility) + menu.addItem(NSMenuItem.separator()) + menu.addItem(menuItemAbout) + menu.addItem(menuItemPreferences) + menu.addItem(NSMenuItem.separator()) + menu.addItem(menuItemQuit) + } + + // MARK: - Menu Item Functions + + /// This opens up the accessibility window. + @objc func menuAccessibility() { + appDelegate.accessibilityWindowController.open() + } + + /// This simply pauses the app so no further selections can be made. + @objc func menuPauseApp() { + appDelegate.settings.settingPauseApp.toggle() + } + + /// This opens up the about window. + @objc func menuAbout() { + appDelegate.aboutWindowController.open() + } + + /// This opens up the preferences window. + @objc func menuPreferences() { + appDelegate.settingsWindowController.open() + } + + /// This quits the application. + @objc func menuQuitApp() { + NSApp.terminate(nil) + } +} diff --git a/Informant/Controllers/PathController.swift b/Informant/Controllers/PathController.swift new file mode 100644 index 0000000..9a74dc9 --- /dev/null +++ b/Informant/Controllers/PathController.swift @@ -0,0 +1,74 @@ +// +// AppleScripts.swift +// Informant +// +// Created by Ty Irvine on 2021-04-13. +// + +import Foundation + +// MARK: - Apple Scripts +/// Used to store and use any apple scripts + +class PathController: Controller, ControllerProtocol { + + /// This stores the previously selected url. + var oldSelection: [String]? + + required init(appDelegate: AppDelegate) { + super.init(appDelegate: appDelegate) + } + + // Find the currently selected Finder files in a string format with line breaks. + func getSelectedPaths() -> Paths? { + + // Find selected items as a list with line breaks + var errorInformation: NSDictionary? + + /// Apple script that tells Finder to give us the file paths + let script = NSAppleScript(source: """ + set AppleScript's text item delimiters to linefeed + tell application "Finder" to POSIX path of (selection as text) + """) + + // Check for errors before using + guard let scriptExecuted = script?.executeAndReturnError(&errorInformation) else { + + // Erase old selection + oldSelection = nil + + // If error is present then let the user know that we're unable to get the selection + if errorInformation != nil { + return Paths(paths: nil, state: .PathError) + } + + return nil + } + + // Parse list for colons and replace with slashes + guard let selectedItems = scriptExecuted.stringValue else { + return nil + } + + // Convert list with line breaks to string array + let selectedItemsAsArray = selectedItems.components(separatedBy: .newlines) + + // Check for duplicates + if let oldSelection = oldSelection, selectedItemsAsArray.areStringsEqual(oldSelection) { + return Paths(paths: nil, state: .PathDuplicate) + } else { + oldSelection = selectedItemsAsArray + } + + // Check for an empty selection + if selectedItemsAsArray[0].isEmpty { + return Paths(paths: nil, state: .PathUnavailable) + } + + #warning("Remove from production") + print("๐Ÿฅ’ Path Found: \(selectedItemsAsArray)") + + // Exit + return Paths(paths: selectedItemsAsArray, state: .PathAvailable) + } +} diff --git a/Informant/Controllers/StatusController.swift b/Informant/Controllers/StatusController.swift new file mode 100644 index 0000000..6fda215 --- /dev/null +++ b/Informant/Controllers/StatusController.swift @@ -0,0 +1,82 @@ +// +// StatusController.swift +// Informant +// +// Created by Ty Irvine on 2022-02-21. +// + +import Foundation +import SwiftUI + +class StatusController: Controller, ControllerProtocol { + + private let statusBar: NSStatusBar + + // ----------- Initialization -------------- + + required init(appDelegate: AppDelegate) { + + // Initialize local fields + statusBar = NSStatusBar.system + + // Initialize inherited fields + super.init(appDelegate: appDelegate) + + // Creates a status bar item with a fixed length + appDelegate.statusItem = statusBar.statusItem(withLength: NSStatusItem.variableLength) + + // Intializes the menu bar button + if let panelBarButton = appDelegate.statusItem?.button { + + // Status bar icon image + panelBarButton.image = NSImage(named: appDelegate.settings.settingMenubarIcon) + + // Status bar icon image size + panelBarButton.image?.size = NSSize(width: 17.5, height: 17.5) + + // Decides whether or not the icon follows the macOS menubar colouring + panelBarButton.image?.isTemplate = true + + panelBarButton.imagePosition = .imageTrailing + panelBarButton.imageHugsTitle = false + + // Updates constraint keeping the image in mind + panelBarButton.updateConstraints() + + // This is the button's action it executes upon activation + panelBarButton.sendAction(on: [.leftMouseUp, .rightMouseUp]) + panelBarButton.target = self + + // Updates size and position + StatusController.updateIcon() + } + + // Assign app's status menu to status item + appDelegate.statusItem?.menu = appDelegate.statusMenu + } + + // MARK: - Misc. Functions + + /// This function simply updates the menu bar icon with the current one stored in userdefaults + static func updateIcon() { + + let appDelegate = AppDelegate.current() + let statusItemButton = appDelegate.statusItem?.button + let icon = appDelegate.settings.settingMenubarIcon + + statusItemButton?.image = NSImage(named: icon) + statusItemButton?.image?.isTemplate = true + statusItemButton?.image?.size = NSSize(width: 17.5, height: 17.5) + + statusItemButton?.updateConstraints() + } + + /// Simply makes the icon look disabled. + static func pauseIcon(paused: Bool) { + + let appDelegate = AppDelegate.current() + let statusItemButton = appDelegate.statusItem?.button + + statusItemButton?.appearsDisabled = paused + } +} diff --git a/Informant/Controllers/UpdateController.swift b/Informant/Controllers/UpdateController.swift new file mode 100644 index 0000000..61bbf03 --- /dev/null +++ b/Informant/Controllers/UpdateController.swift @@ -0,0 +1,51 @@ +// +// UpdateController.swift +// Informant +// +// Created by Ty Irvine on 2022-03-03. +// + +import Foundation +import Sparkle + +public enum UpdateFrequency: String { + case AutoUpdate + case None +} + +/// Controls flow and initiation of updates. +class UpdateController: Controller, ControllerProtocol { + + let updater: Updater + + required init(appDelegate: AppDelegate) { + updater = Updater() + super.init(appDelegate: appDelegate) + + // Check to see for updates + if appDelegate.settings.settingUpdateFrequency == .AutoUpdate { + updater.checkForUpdates() + } + } + + /// This initiates a check for updates. + func checkForUpdates() { + updater.checkForUpdates() + } +} + +// This view model class manages Sparkle's updater and publishes when new updates are allowed to be checked +final class Updater { + + private let updaterController: SPUStandardUpdaterController + + init() { + // If you want to start the updater manually, pass false to startingUpdater and call .startUpdater() later + // This is where you can also pass an updater delegate if you need one + updaterController = SPUStandardUpdaterController(startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil) + } + + func checkForUpdates() { + updaterController.checkForUpdates(nil) + } +} diff --git a/Informant/Controllers/WindowController.swift b/Informant/Controllers/WindowController.swift new file mode 100644 index 0000000..11f08ff --- /dev/null +++ b/Informant/Controllers/WindowController.swift @@ -0,0 +1,73 @@ +// +// WindowController.swift +// Informant +// +// Created by Ty Irvine on 2022-02-21. +// + +import AppKit +import Foundation +import SwiftUI + +/// This is a generalized controller for all windows in the app. +class WindowController: Controller, ControllerProtocol { + + let window: NSWindow! + + required init(appDelegate: AppDelegate) { + + window = NSWindow() + window.contentViewController = NSHostingController(rootView: TestView()) + window.backgroundColor = .white + window.titlebarAppearsTransparent = true + window.styleMask = [.borderless, .fullSizeContentView] + + super.init(appDelegate: appDelegate) + + print("โ€ผ๏ธ WindowController - This initializer isn't meant to be used and is only for TESTING. Please check which initializer you're using to instantiate this object.") + } + + required init(appDelegate: AppDelegate, rootView: Content, fullToolbar: Bool = false) { + + window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 100, height: 100), + styleMask: [.closable, .unifiedTitleAndToolbar, .fullSizeContentView, .titled], + backing: .buffered, + defer: false + ) + + super.init(appDelegate: appDelegate) + + // Setup window + window.titlebarAppearsTransparent = true + + // Hide buttons if it's not a full toolbar + if fullToolbar { + window.styleMask.insert(.miniaturizable) + } else { + window.standardWindowButton(.miniaturizeButton)?.isHidden = true + window.standardWindowButton(.zoomButton)?.isHidden = true + } + + // Misc. + window.isReleasedWhenClosed = false + + // Animation + window.animationBehavior = .default + + // Connect view + window.contentViewController = NSHostingController(rootView: rootView) + } + + /// Opens up the window + func open() { + window.center() + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } + + /// Closes down the window + func close() { + window.close() + } +} diff --git a/Informant/Data/DataFormatting.swift b/Informant/Data/DataFormatting.swift new file mode 100644 index 0000000..766433b --- /dev/null +++ b/Informant/Data/DataFormatting.swift @@ -0,0 +1,113 @@ +// +// DataFormatting.swift +// Informant +// +// Created by Ty Irvine on 2022-02-24. +// + +import Foundation + +class DataFormatting { + + /// Takes in a directory item count and formats it accordingly + static func formatDirectoryItemCount(_ itemCount: Int) -> String { + return String(itemCount) + " " + (itemCount > 1 ? ContentManager.Extra.items : ContentManager.Extra.item) + } + + /// Formats a raw hertz reading into hertz or kilohertz + static func formatSampleRate(_ hertz: Any?) -> String? { + guard let contentHertz = hertz as? Double else { return nil } + + // Format as hertz + if contentHertz < 1000 { + return String(format: "%.0f", contentHertz) + " Hz" + } + + // Format as kilohertz + else { + let kHz = contentHertz / 1000 + return String(format: "%.1f", kHz) + " kHz" + } + } + + /// Formats raw byte size into kbps + static func formatBitrate(_ bitCount: Float) -> String? { + + let bits = bitCount + + let formattedBits: NSNumber + let unitDescription: String + + // Figure out the size + // Bits per second + if bits < 1000 { + formattedBits = NSNumber(value: bits) + unitDescription = "bps" + } + + // Kilobits per second + else if bits < 1000000 { + formattedBits = NSNumber(value: bits / 1000.0) + unitDescription = "kbps" + } + + // Megabits per second + else if bits < 1000000000 { + formattedBits = NSNumber(value: bits / 1000.0 / 1000.0) + unitDescription = "Mbps" + } + + // Gigabits per second + else { + formattedBits = NSNumber(value: bits / 1000.0 / 1000.0 / 1000.0) + unitDescription = "Gbps" + } + + let formatter = NumberFormatter() + formatter.maximumFractionDigits = 2 + + guard let bitsPerSecond = formatter.string(from: formattedBits) else { + return nil + } + + return "\(bitsPerSecond) \(unitDescription)" + } + + /// Formats seconds into a DD:HH:MM:SS format (days, hours, minutes, seconds) + static func formatDuration(_ duration: Any?) -> String? { + + guard let contentDuration = duration as? Double else { return nil } + + // Round duration by casting to int + let roundedDuration = contentDuration.rounded(.toNearestOrAwayFromZero) + + let interval = TimeInterval(roundedDuration) + + // Setup formattter + let formatter = DateComponentsFormatter() + formatter.zeroFormattingBehavior = [.pad] + + // If the duration is under an hour then use a shorter formatter + if contentDuration > 3599.0 { + formatter.allowedUnits = [.hour, .minute, .second] + } + + // Otherwise use the expanded one + else { + formatter.allowedUnits = [.minute, .second] + } + + return formatter.string(from: interval) + } + + /// Simply formats the dimensions for the resource. + static func formatDimensions(x: Any?, y: Any?) -> String? { + guard let pixelwidth = x as? Int else { return nil } + guard let pixelheight = y as? Int else { return nil } + + let xStr = String(describing: pixelwidth) + let yStr = String(describing: pixelheight) + + return xStr + " ร— " + yStr + } +} diff --git a/Informant/Data/DataRetrieval.swift b/Informant/Data/DataRetrieval.swift new file mode 100644 index 0000000..c44f3ed --- /dev/null +++ b/Informant/Data/DataRetrieval.swift @@ -0,0 +1,685 @@ +// +// DataRetrieval.swift +// Informant +// +// Created by Ty Irvine on 2022-02-24. +// + +import AVFoundation +import Foundation +import SwiftUI +import UniformTypeIdentifiers + +class DataRetrieval { + + private let cache: Cache + private let metadata: [CFString: Any]? + private let avasset: AVAsset? + private var selection: SelectionData + private let info: SelectionInfo + + /// Main initializer. + init?(cache: Cache, info: SelectionInfo) { + + // Assign cache ref. + self.cache = cache + + // Create a temporary mutable holder for the constant metadata + var metaTemp: [CFString: Any]? + + // Retrieve and assign metadata for file + if let metadata = DataUtility.getURLMetadata(info.url) { + metaTemp = metadata + } + + // This way the metadata we work with is immutable + metadata = metaTemp + + // Initialize the player object + avasset = AVAsset(url: info.url) + + // Initialize selection object + selection = SelectionData() + + // Assign info ref. + self.info = info + } + + // MARK: - Retrieval Functions + + /// This packages all data and returns it. + func retrieve() -> SelectionData? { + + print("-------------------- NEW SELECTION โ†“ -------------------") + + // Cancel all async jobs + info.appDelegate.dataDirector.resetAll() + + // TODO: Make this process right here async + // Get initial single selection data. + guard let singleData = returnInitialData() else { + return nil + } + + // Attempt to grab remaining data asynchronously. + guard let remainingData = returnRemainingData(singleData) else { + return nil + } + + // Attempt to merge the data with any new data found. + guard let merged = mergeData(singleData, remainingData) else { + return nil + } + + // Apply any long duration async items found (like total size). + guard let asyncSelection = applyLargeAsyncItems(merged) else { + return nil + } + + // Filter data for settings + guard let selection = applySettingsToData(asyncSelection) else { + return nil + } + + return selection + } + + // MARK: - Async Packaging Functions + + /// This retrieves the data in an async manner. + private func returnRemainingData(_ selection: SelectionData) -> SelectionData? { + + // Create a mutable copy of the selection + var selection = selection + + // Put data retrieval on a background thread + info.appDelegate.dataDirector.findRemainingData(info, selection) { + self.returnData() + } + + // Sleep main thread, this way we can wait a certain duration for the background thread to complete + do { + // usleep takes microseconds so we just need to get milliseconds by multiplying by 1000 + usleep(20 * 1000) + } + + // After the main thread has slept, attempt to get the selection from the cache + // - avoids poor looking double updates. + // --------------------------------- + // Grab ref. to the director + guard let dataDirector = info.appDelegate.dataDirector else { + return selection + } + + // Attempts to get a selection + guard let newSelection = dataDirector.fastCacheQueueGetSelection(jobID: info.jobIDMain) else { + return selection + } + + // We've found a new selection + return newSelection + } + + /// This allows the selection to return as one without awkward updates. + /// When sizing small items, they size quickly but they still make the menu bar jump + /// because they're loading in asynchronously. This prevents that from happening. + private func applyLargeAsyncItems(_ selection: SelectionData) -> SelectionData? { + + // Create a mutable copy of the selection + var selection = selection + + // Make a fetch for async data + let size = info.appDelegate.dataDirector.findSize(info, selection, cache: cache) + + // Assign the fetch value to size + selection.data[.keyShowSize] = size + + // Sleep the main thread for x milliseconds. The delay is in microseconds + // ... if it's a large size search like a directory type. + // Size must not exist for this delay to occur as well. + if (info.type == .Directory || info.type == .Application) && (size == nil) { + do { + // usleep takes microseconds so we just need to get milliseconds by multiplying by 1000 + usleep(50 * 1000) + } + } + + // Then we attempt to get the size again. This way we can get everything synchronously + // and avoid poor looking double updates. + // --------------------------------- + // Grab ref. to the director + guard let dataDirector = info.appDelegate.dataDirector else { + return selection + } + + // Attempts to get a size + guard let size = dataDirector.fastCacheQueueGetSelection(jobID: info.jobIDSize)?.data[.keyShowSize] else { + return selection + } + + // Wait until the loader delay is finished before returning. + // This way we can return the selection if it's short enough as one whole. + selection.data[.keyShowSize] = size + print("โœณ๏ธ SIZE: \(size)") + + return selection + } + + // MARK: - Packaging Functions + + /// Applies all display settings from the settings controller to the data. + private func applySettingsToData(_ selection: SelectionData) -> SelectionData? { + let selectionWithSettings = info.appDelegate.settings.applySettings(selection) + return selectionWithSettings + } + + /// Picks out appropriate data to return. + private func returnData() -> SelectionData? { + + // Pick out the appropriate data to return + switch info.type { + + case .Multi: + return displayDataMulti() + + case .Single: + return displayDataSingle() + + case .Directory: + return displayDataDirectory() + + case .Application: + return displayDataApplication() + + case .Volume: + return displayDataVolume() + + case .Image: + return displayDataImage() + + case .Movie: + return displayDataMovie() + + case .Audio: + return displayDataAudio() + + default: + break + } + + return nil + } + + /// This returns an initial selection if the selection is a single selection. + private func returnInitialData() -> SelectionData? { + + // abort if the selection type is not appropriate to merge with + if info.type == .Multi { + return SelectionData() + } + + // Otherwise, proceed with merging + guard let displayDataSingle = displayDataSingle() else { + return nil + } + + return displayDataSingle + } + + /// Merges specific display data with generalized display data to reduce function complexity. + private func mergeData(_ old: SelectionData, _ new: SelectionData) -> SelectionData? { + + // abort if the selection type is not appropriate to merge with + if info.type == .Multi { + return old + } + + return old.merge(with: new) + } + + // MARK: - General Display Data Functions + + /// Gets data for selections involving multiple files and directories. + private func displayDataMulti() -> SelectionData? { + + let urls = info.urls + + // Get count of all URL items + let totalCount = urls.count + + // Format fields + let totalCountFormatted = DataFormatting.formatDirectoryItemCount(totalCount) + + // Assign fields and exit + selection.data[.keyShowItems] = "\(totalCountFormatted)" + + return selection + } + + /// Gets data for a single file or directory selected. + private func displayDataSingle() -> SelectionData? { + + let keys: Set = [ + .pathKey, + .localizedNameKey, + .localizedTypeDescriptionKey, + .creationDateKey, + .contentModificationDateKey, + .ubiquitousItemContainerDisplayNameKey, + ] + + // Get resources + let resources = DataUtility.getURLResources(info.url, keys) + + // Get and assign created date + if let created = resources?.creationDate { + selection.data[.keyShowCreated] = created.formatDate() + } + else { + selection.data[.keyShowCreated] = nil + } + + // Get and assign modified date + if let modified = resources?.contentModificationDate { + selection.data[.keyShowModified] = modified.formatDate() + } + else { + selection.data[.keyShowModified] = nil + } + + // Format and assign the path + if let path = resources?.path { + let url = URL(fileURLWithPath: path).deletingLastPathComponent() + selection.data[.keyShowPath] = url.path + } + + // Format and assign the cloud container + if let cloudContainer = resources?.ubiquitousItemContainerDisplayName { + let cloudContainerFormatted = "๔€Œ‹ \(cloudContainer)" + selection.data[.keyShowiCloudContainerName] = cloudContainerFormatted + } + + // Assign remaining fields and exit + selection.data[.keyShowName] = resources?.localizedName + selection.data[.keyShowKind] = resources?.localizedTypeDescription + + return selection + } + + // MARK: - Specialized Display Data Functions + + private func displayDataImage() -> SelectionData? { + + // Get image metadata + let imageMetadata = DataUtility.getURLImageMetadata(info.url) + + // Image information + if let exifDict = imageMetadata?[kCGImagePropertyExifDictionary] as? [CFString: Any] { + + if let focalLength = exifDict[kCGImagePropertyExifFocalLength] { + selection.data[.keyShowFocalLength] = String(describing: focalLength) + " mm" + } + + if let aperture = exifDict[kCGImagePropertyExifFNumber] { + selection.data[.keyShowAperture] = "f/" + String(describing: aperture) + } + + if let shutter = exifDict[kCGImagePropertyExifExposureTime] { + let fraction = Rational(approximating: shutter as! Double) + selection.data[.keyShowShutterSpeed] = String(fraction.numerator.description + "/" + fraction.denominator.description) + } + + if let iso = (exifDict[kCGImagePropertyExifISOSpeedRatings] as? NSArray) { + selection.data[.keyShowISO] = String(describing: iso[0]) + } + } + + // Get color gamut + var gamut: String? + let image = NSImage(byReferencing: info.url).cgImage(forProposedRect: nil, context: nil, hints: nil) + if let isWideGamutRGB = image?.colorSpace?.isWideGamutRGB { + gamut = isWideGamutRGB ? "Wide Gamut" : "sRGB" + } + + // Retrieve and format remaining fields + selection.data[.keyShowColorGamut] = gamut + selection.data[.keyShowCamera] = retrieveCamera(imageMetadata) + selection.data[.keyShowColorProfile] = retrieveColorProfile() + selection.data[.keyShowDimensions] = retrieveDimensions() + + return selection + } + + private func displayDataMovie() -> SelectionData? { + + selection.data[.keyShowCodecs] = retrieveCodecs() + selection.data[.keyShowDuration] = retrieveDuration() + selection.data[.keyShowColorProfile] = retrieveColorProfile() + selection.data[.keyShowDimensions] = retrieveDimensionsMovie(info) + selection.data[.keyShowTotalBitrate] = retrieveTotalBitrate() + selection.data[.keyShowSampleRate] = retrieveSampleRate() + + // TODO: Add in separate audio/video bitrates +// selection.data[.keyShowAudioBitrate] = retrieveBitrate(type: .audio) +// selection.data[.keyShowVideoBitrate] = retrieveBitrate(type: .video) + + return selection + } + + private func displayDataAudio() -> SelectionData? { + + selection.data[.keyShowCodecs] = retrieveCodecs() + selection.data[.keyShowSampleRate] = retrieveSampleRate() + selection.data[.keyShowDuration] = retrieveDuration() + selection.data[.keyShowTotalBitrate] = retrieveTotalBitrate() + + return selection + } + + private func displayDataDirectory() -> SelectionData? { + + // Get access to directory + if info.isiCloudSyncFile != true { + + // Get # of items in the directory + if let itemCount = FileManager.default.shallowCountOfItemsInDirectory(at: info.url) { + selection.data[.keyShowItems] = DataFormatting.formatDirectoryItemCount(itemCount) + } + } + + // Otherwise we have no permission to view or it's an iCloud sync file + return selection + } + + private func displayDataApplication() -> SelectionData? { + + // Get version # + if let version = metadata?[kMDItemVersion] { + let versionString = String(describing: "ฮฝ\(version)") + if versionString.count > 1 { + selection.data[.keyShowVersion] = versionString + } + } + + return selection + } + + private func displayDataVolume() -> SelectionData? { + + let keys: Set = [ + .volumeTotalCapacityKey, + .volumeAvailableCapacityKey, + .volumeAvailableCapacityForImportantUsageKey, + ] + + var volumeTotal = "", volumeAvailable = "", volumePurgeable = "" + + // Get resources + guard let resources = DataUtility.getURLResources(info.url, keys) else { + return nil + } + + // Get important usage + guard let importantUsage = resources.volumeAvailableCapacityForImportantUsage else { + return nil + } + + // Get total capacity + guard let totalCapacity = resources.volumeTotalCapacity else { + return nil + } + + // Get available capacity + guard let availableCapacity = resources.volumeAvailableCapacity else { + return nil + } + + // Get purgeable capacity if usage is 0 + if importantUsage != 0 { + let purged = abs(importantUsage - Int64(availableCapacity)) + volumePurgeable = purged.formatBytes() + volumeAvailable = importantUsage.formatBytes() + } + + // Otherwise use regular available capacity + else { + volumeAvailable = availableCapacity.formatBytes() + } + + volumeTotal = totalCapacity.formatBytes() + + // Assign remaining values and exit + selection.data[.keyShowVolumeTotal] = volumeTotal + selection.data[.keyShowVolumeAvailable] = volumeAvailable + " " + ContentManager.Labels.volumeAvailable + selection.data[.keyShowVolumePurgeable] = volumePurgeable + " " + ContentManager.Labels.volumePurgeable + + return selection + } + + // MARK: - Specific Data Gathering Functions + + /// Formats and returns dimensions for a video. + private func retrieveDimensionsMovie(_ info: SelectionInfo) -> String? { + + guard let track = AVURLAsset(url: info.url).tracks(withMediaType: AVMediaType.video).first else { + return nil + } + + let size = track.naturalSize.applying(track.preferredTransform) + + let width = size.width.rounded(.towardZero) + let height = size.height.rounded(.towardZero) + + return DataFormatting.formatDimensions(x: Int(width), y: Int(height)) + } + + /// Formats and returns dimensions for an image. + private func retrieveDimensions() -> String? { + + let width = metadata?[kMDItemPixelWidth] + let height = metadata?[kMDItemPixelHeight] + + guard let dimensions = DataFormatting.formatDimensions(x: width, y: height) else { + return nil + } + + return dimensions + } + + /// Retrieves the color profile. + private func retrieveColorProfile() -> String? { + + let profile = metadata?[kMDItemProfileName] as? String + + return profile + } + + /// Retrieves the color profile. + private func retrieveColorSpace() -> String? { + + let profile = metadata?[kMDItemColorSpace] as? String + + return profile + } + + /// Retrieves the camera model. + private func retrieveCamera(_ imageMetadata: NSDictionary?) -> String? { + + guard let tiffMetadata = imageMetadata?[kCGImagePropertyTIFFDictionary] as? [CFString: Any] else { + return nil + } + + guard let camera = tiffMetadata[kCGImagePropertyTIFFModel] as? String else { + return nil + } + + return camera + } + + /// Retrieves the duration model. + private func retrieveDuration() -> String? { + + let duration = metadata?[kMDItemDurationSeconds] + + guard let durationFormatted = DataFormatting.formatDuration(duration) else { + return nil + } + + return durationFormatted + } + + /// Retrieves the codecs. + private func retrieveCodecs() -> String? { + + // Try to get it this way first + if let codecs = metadata?[kMDItemCodecs] as? [String] { + return codecs.reversed().joined(separator: ", ") + } + + // Otherwise get it this way + var codecArray: [String] = [] + + // Unwrap tracks + guard let tracks = avasset?.tracks else { + return nil + } + + // Loop through and look for codecs + for track in tracks { + let codec = track.mediaFormat + codecArray.append(codec) + } + + return codecArray.joined(separator: ", ") + } + + /// Retrieves the sample rate. + private func retrieveSampleRate() -> String? { + + // Try and get it the clean way first + if let track = avasset?.tracks(withMediaType: .audio).first { + let sampleUInt32 = track.naturalTimeScale.magnitude + let samples = Double(sampleUInt32) + return DataFormatting.formatSampleRate(samples) + } + + // Then try the less accurate way + else if let sampleRate = metadata?[kMDItemAudioSampleRate] { + return DataFormatting.formatSampleRate(sampleRate) + } + + return nil + } + + /// Retrieves the total bitrate. + private func retrieveTotalBitrate() -> String? { + + // Grab bitrate and size first + guard let metadata = DataUtility.getURLMetadata(info.url, [kMDItemFSSize!, kMDItemTotalBitRate!]) else { + return nil + } + + // Used to get the bitrate + var bitrateFormatted: String? + + // Attempt to retrieve bitrates the normal way + let videoBitrate = retrieveBitrate(type: .video) + let audioBitrate = retrieveBitrate(type: .audio) + + if videoBitrate != nil && audioBitrate != nil { + let totalBitrate = videoBitrate! + audioBitrate! + if totalBitrate != 0 { + bitrateFormatted = DataFormatting.formatBitrate(totalBitrate) + } + } + + // Otherwise use the crappy way + else if let bitrate = metadata[kMDItemTotalBitRate] as? Float { + // Bitrate comes as kbps so we need to just convert it to bps + let bitrateConverted = bitrate * 1000.0 + bitrateFormatted = DataFormatting.formatBitrate(bitrateConverted) + } + + // If all else fails calculate it manually + else { + // GB / (minutes * 0.0075) + guard let size = metadata[kMDItemFSSize] as? Double else { + return nil + } + + guard let duration = avasset?.duration else { + return nil + } + + let durationFormatted = CMTimeGetSeconds(duration) + + let bitrateCalculated = (size / 1000000000) / ((durationFormatted / 60.0) * 0.0075) + let bitrateAsBits = Float(bitrateCalculated * 1000000) + + guard let bitrateEstimate = DataFormatting.formatBitrate(bitrateAsBits) else { + return nil + } + + bitrateFormatted = "~" + bitrateEstimate + } + + // Nil check and exit + guard let bitrateFormatted = bitrateFormatted else { + return nil + } + + return bitrateFormatted + } + + /// Retrieves the raw bitrate using a type. + private func retrieveBitrate(type: AVMediaType) -> Float? { + + guard let av = avasset else { + return nil + } + + guard let track = av.tracks(withMediaType: type).first else { + return nil + } + + let bitrate = track.estimatedDataRate + + return bitrate + } + + // TODO: Add in bitrate icon type thing + /// Retrieves the bitrate with the appropriate icon attached. + /* + private func retrieveBitrateWithIcon(type: AVMediaType) -> String? { + + guard let track = avasset.tracks(withMediaType: type).first else { + return nil + } + + let bitrate = track.estimatedDataRate + + guard let bitrateFormatted = DataFormatting.formatBitrate(bitrate) else { + return nil + } + + // Find the correct icon to pair with the bitrate type + let icon: String + + switch type { + case .audio: + icon = "๔€ซ€" + break + + case .video: + icon = "๔€Žถ" + break + + default: + icon = "" + break + } + + return bitrateFormatted + " \(icon)" + } + */ +} diff --git a/Informant/Data/DataUtility.swift b/Informant/Data/DataUtility.swift new file mode 100644 index 0000000..ff576f8 --- /dev/null +++ b/Informant/Data/DataUtility.swift @@ -0,0 +1,141 @@ +// +// DataUtility.swift +// Informant +// +// Created by Ty Irvine on 2022-02-24. +// + +import Foundation +import SwiftUI +import UniformTypeIdentifiers + +class DataUtility { + + /// This function will take in a url string and provide a url resource values object which can be + /// then used to grab info, such as name, size, etc. Returns nil if nothing is found + static func getURLResources(_ url: URL, _ keys: Set) -> URLResourceValues? { + do { + return try url.resourceValues(forKeys: keys) + } + catch { + return nil + } + } + + /// Used to grab the metadata for an image on a security scoped url + static func getURLImageMetadata(_ url: URL) -> NSDictionary? { + if let source = CGImageSourceCreateWithURL(url as CFURL, nil) { + if let dictionary = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) { + return dictionary as NSDictionary + } + } + + return nil + } + + /// Used to grab all metadata for a movie on a security scoped url + static func getURLMetadata( + _ url: URL, + _ keys: NSArray = [ + kMDItemCodecs!, + kMDItemDurationSeconds!, + kMDItemProfileName!, + kMDItemColorSpace!, + kMDItemPixelWidth!, + kMDItemPixelHeight!, + kMDItemAudioSampleRate!, + kMDItemVersion!, + ] + ) -> [CFString: Any]? { + + if let mdItem = MDItemCreateWithURL(kCFAllocatorDefault, url as CFURL) { + if let metadata = MDItemCopyAttributes(mdItem, keys) as? [CFString: Any] { + return metadata + } + } + + return nil + } + + /// Determines the selection type of a single item. + static func getSelectionTypeSingle( + path: String, + types: [UTType] = [ + .image, + .movie, + .audio, + .application, + .volume, + .directory, + ] + ) -> SelectionType? { + + // TODO: I'd consider removing this section +// // Finds the root volume path and removes it in order to get a volume selection. +// // For some reason a normal volume path with the root drive isn't accepted. +// guard let rootDrivePath = FileManager.default.getRootVolumeAsPath else { +// return nil +// } +// +// if path == rootDrivePath { +// url = URL(fileURLWithPath: "/") +// } +// +// // Otherwise the url is assigned normally +// else { +// url = URL(fileURLWithPath: path) +// } + + /// This is the selection's URL + let url = URL(fileURLWithPath: path) + + // The resources we want to find + let keys: Set = [ + .contentTypeKey, + ] + + // Grabs the UTI type id and compares that to all the types we want to identify + guard let resources: URLResourceValues = Self.getURLResources(url, keys) else { + return nil + } + + // Cast the file's uniform type identifier + guard let uti = resources.contentType else { + return nil + } + + /// Stores the uti this selection conforms to + var selectionType: UTType? + + // Cycle all the types and identify the file's type + for type in types { + if type == uti || type.isSupertype(of: uti) { + selectionType = type + break + } + } + + // Confirm a valid UTType was found. Otherwise just return .Single + guard let selectionType = selectionType else { + return .Single + } + + // Now that we have the uniform type identifier, let's return the matching type + switch selectionType { + + case .directory: return .Directory + + case .application: return .Application + + case .image: return .Image + + case .movie: return .Movie + + case .audio: return .Audio + + case .volume: return .Volume + + default: return .Single + } + } +} diff --git a/Informant/Directors/DataDirector.swift b/Informant/Directors/DataDirector.swift new file mode 100644 index 0000000..402929d --- /dev/null +++ b/Informant/Directors/DataDirector.swift @@ -0,0 +1,453 @@ +// +// DataSizing.swift +// Informant +// +// Created by Ty Irvine on 2022-04-02. +// + +import Foundation + +class DataDirector { + + /// Keeps track of all job statuses. + /// BOOL: Job status + /// False = cancelled + /// True = active + private var jobs: [String: Bool] = [:] + private let jobQueue = DispatchQueue(label: "dataDirectorJobQueue") + + /// Keeps track of all sizes so they can be accessed by other controllers. + private var fastCache: [String: SelectionData] = [:] + private let fastCacheQueue = DispatchQueue(label: "dataDirectorFastCacheQueue") + + /// This is the global loader delay. + /// This is the loader delay in milliseconds. Meaning the loader won't appear till this delay is reached. + let loaderDelayMilliseconds: Double = 10 + var loaderDelaySeconds: Double { + loaderDelayMilliseconds * 0.001 + } + + // MARK: - Universal Functions + + /// Resets all globals. + func resetAll() { + fastCacheQueueEmpty() + cancelAllJobs() + } + + // MARK: - Fast Cache Functions + + /// Retrieves from the fast cache synchronously! + func fastCacheQueueGetSelection(jobID: String) -> SelectionData? { + fastCacheQueue.sync { + if self.fastCache.keys.contains(jobID) { + + // If the cached selection is valid, close the job + guard let cachedSelection = self.fastCache[jobID] else { + return nil + } + + endJob(jobID) + + return cachedSelection + } + return nil + } + } + + /// Empties all fast cache properties. + private func fastCacheQueueEmpty() { + fastCacheQueue.sync { + self.fastCache.removeAll() + } + } + + /// Makes sure the size gets updated synchronously. + private func fastCacheQueueUpdate(jobID: String, selection: SelectionData?) { + fastCacheQueue.sync { + self.fastCache[jobID] = selection + } + } + + // MARK: - Director Functions + + /// Checks to see if the entire selection is loading or not. + func isSelectionLoading(_ id: String) -> Bool { + jobQueue.sync { + for job in self.jobs { + if job.value == true { + return true + } + } + return false + } + } + + /// Checks to see if the job is not cancelled. + func isJobActive(_ id: String) -> Bool? { + jobQueue.sync { + guard jobs.keys.contains(id), let jobStatus = jobs[id] else { return nil } + return jobStatus + } + } + + /// Cancel all jobs. + private func cancelAllJobs() { + jobQueue.sync { + for job in self.jobs { + self.jobs[job.key] = false + } + } + } + + /// Removes the job. This will be done only after it's been CONFIRMED cancelled. + /// This must be done to prevent memory leaks. + private func removeJob(_ id: String) { + jobQueue.sync { + if self.jobs.keys.contains(id), self.jobs[id] == false { + self.jobs.removeValue(forKey: id) + } + } + } + + /// This is the clean up process when done finding a size. + private func endJob(_ id: String) { + jobQueue.sync { + self.jobs[id] = false + } + } + + /// Creates a new job. Returns the job id. + private func addJob(_ id: String) { + jobQueue.sync { + self.jobs[id] = true + } + } + + /// Simply aggregates clean up functions. + private func cleanUpJob(jobID: String) { + endJob(jobID) + removeJob(jobID) + } + + /// This updates the current display with the given selection. + private func updateDisplay(_ info: SelectionInfo, _ jobID: String, _ selectionData: SelectionData) { + + // This lets us know that the update has been queued on the main thread + print("UPDATING...") + + // This update has to occur on the main thread because that's where the UI gets its updates from + DispatchQueue.main.async { + + // Double check to make sure the job is still active + // If so, update the display + if self.isJobActive(jobID) == true { + + // The job is no longer needed regardless of the state + self.cleanUpJob(jobID: jobID) + + // Get the display controller + let displayController = info.appDelegate.displayController + + // Merge the data from the display + if let merged = displayController?.findCurrentSelection()?.merge(with: selectionData) { + displayController?.updateDisplayExternally(merged, info) + } + + // Otherwise update the display with what we found + else { + displayController?.updateDisplayExternally(selectionData, info) + } + + print("UPDATE MADE โ›ณ๏ธ") + } + } + } + + // MARK: - Directors + + /// Grabs the remaining data asynchronously. + func findRemainingData(_ info: SelectionInfo, _ selection: SelectionData, data: @escaping () -> SelectionData?) { + findRemainingDataAsynchronously(info, selection) { + data() + } + } + + /// Decides if a size should be found synchronously or not. + func findSize(_ info: SelectionInfo, _ selection: SelectionData, cache: Cache) -> String? { + + // Abort if this is a non-size type + if info.type == .Volume { + return nil + } + + // Used to build the total size + var totalSize: Int64? = 0 + + let type = info.type + + // Loop through all urls + for url in info.urls { + + // Check cache first + if let size = cache.get(url: url)?.bytes { + totalSize? += size + continue + } + + // Check type and bail if it's a directory type โ€” if no size is found in the cache + else if type == .Directory || type == .Application || type == .Multi { + totalSize = nil + break + } + + // At this point it's safe to just collect the data synchronously on a single file + else if let size = getBytes(info) { + totalSize? += Int64(size) + } + } + + // Check to see if the total size is valid + if let size = totalSize { + return size.formatBytes() + } + + // Otherwise just get the data asynchronously + findSizeAsynchronously(info, selection, cache: cache) + + // Exit + return nil + } + + // MARK: - Asynchronous Directors + + /// Asynchronously gets the remaining data. + private func findRemainingDataAsynchronously(_ info: SelectionInfo, _ selection: SelectionData, _ data: @escaping () -> SelectionData?) { + + // Start getting the data + findSelectionAsynchronously(info: info, jobID: info.jobIDMain, name: "Remaining Data") { + + // Return the new selection found merged with the old selection + let newSelection = data() + let merged = newSelection?.merge(with: selection) + return merged + } + } + + /// Asynchronously gets the size. + private func findSizeAsynchronously(_ info: SelectionInfo, _ selection: SelectionData, cache: Cache) { + + // Start getting the data + findSelectionAsynchronously(info: info, jobID: info.jobIDSize, name: "Sizing") { + + // Create ref to size + var size: String? + + // Find the size. This is where the hang up will be + switch info.type { + case .Multi: + size = self.getMultiSize(info, cache: cache) + break + + default: + size = self.getSize(info, cache: cache) + break + } + + // Get a mutable copy of the selection and return it + var selection = selection + selection.data[.keyShowSize] = size + return selection + } + } + + /// Asynchronously gets the remaining data. + private func findSelectionAsynchronously(info: SelectionInfo, jobID: String, name: String, _ workItem: @escaping () -> SelectionData?) { + + // Create a new job + addJob(jobID) + + // Record the time + let startTime = Date().timeIntervalSince1970.magnitude + + // Start getting the size + DispatchQueue.global(qos: .userInitiated).async { + + // The actual work item that gets the selection + guard let selection = workItem() else { + return self.cleanUpJob(jobID: jobID) + } + + // Now that the size has been found, check to see if the job is still active + // If so, update the selection. Make sure the size is valid too! + if self.isJobActive(jobID) == true { + + // Get elapsed time in milliseconds + let elapsed = (Date().timeIntervalSince1970.magnitude - startTime) * 1000 + + // This sort of ends execution but it gets ref.'d twice + func updateJob() { + self.fastCacheQueueUpdate(jobID: jobID, selection: selection) + self.updateDisplay(info, jobID, selection) + } + + // If time has elapsed past 50ms, then add in a delay + if elapsed > 50 { + DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 0.2) { + updateJob() + } + } + + // Otherwise, update normally + else { + updateJob() + } + } + + // Otherwise, clean up operation + else { + self.cleanUpJob(jobID: jobID) + } + + // Record the end time + let endTime = Date().timeIntervalSince1970.magnitude + + print("โฑ \(name) took: \(((endTime - startTime) * 1000.0).rounded())ms") + } + } + + // MARK: - Sizing Functions + + /// Simply gets the size of a multi. selection. + private func getMultiSize(_ info: SelectionInfo, cache: Cache) -> String? { + + let urls = info.urls + + // Get total size of all selected files + var totalSize: Int? = 0 + + // Iterate over all urls + for url in urls { + + // Nil check total size and make sure the job is active + if totalSize == nil || isJobActive(info.jobIDSize) == false { + break + } + + // Grab resources + let resources = DataUtility.getURLResources(url, [.isDirectoryKey, .totalFileSizeKey]) + + // Continue to next iteration if the item is not a directory + if resources?.isDirectory == false, let size = resources?.totalFileSize { + totalSize! += size + continue + } + + // Otherwise, find the correct directory size + else if resources?.isDirectory == true, let size = getDirectorySize(url, info: info, cache: cache) { + totalSize! += size + continue + } + + // Otherwise bail out on a bad selection + else { + totalSize = nil + break + } + } + + // Do a final nil check + guard let totalSize = totalSize else { + return nil + } + + // Format and exit + return totalSize.formatBytes() + } + + /// Gets the size of the selection regardless of whether it's a directory or file. + /// Selection must only include one item however. Returns selection already formatted. + private func getSize(_ info: SelectionInfo, cache: Cache) -> String? { + + let type = info.type + + // Get size for a directory type + if type == .Directory || type == .Application { + + // Abort if the settings say so + if info.appDelegate.settings.settingSkipGettingSizeForDirectories == true || + info.appDelegate.settings.settingShowSize == false + { + return nil + } + + // Otherwise get the size + let size = getDirectorySize(info.url, info: info, cache: cache) + return size?.formatBytes() + } + + // Otherwise get the size for a single file + else { + return getBytes(info)?.formatBytes() + } + } + + /// This gets the plain bytes of the file. + private func getBytes(_ info: SelectionInfo) -> Int? { + + // Don't get size due to no size existing + if info.type == .Volume { + return nil + } + + // Get the size for a file type + let resources = DataUtility.getURLResources(info.url, [.totalFileSizeKey]) + return resources?.totalFileSize + } + + /// Asynchronously gets the file size for a directory, whether in the cache or not. + func getDirectorySize(_ url: URL, info: SelectionInfo, cache: Cache) -> Int? { + + // Get size of url from the cache + if let size = cache.get(url: url)?.bytes { + return Int(size) + } + + // Otherwise store to the cache + else { + do { + let size = try FileManager.default.allocatedSizeOfDirectory(at: url, info: info) + cache.store(url: url, bytes: size) + return Int(size) + } + catch { } + } + + // Otherwise error out + return nil + } + + /// Simply cleans up execution of the async finder. + /* + private static func cleanUp(_ info: SelectionInfo, _ selection: SelectionData, _ size: String?) { + + let jobID = info.jobID + var selection = selection + + // Now that the size has been found, check to see if the job is still active + // If so, update the selection. Make sure the size is valid too! + if isJobActive(jobID) == true, size != nil { + selection.data[.keyShowSize] = size + + sizes[info.jobID] = size + + // Update the display + updateSelection(info, selection) + } + + // Clean up + endJob(jobID) + removeJob(jobID) + } + */ +} diff --git a/Informant/Directors/NotificationDirector.swift b/Informant/Directors/NotificationDirector.swift new file mode 100644 index 0000000..4d7a453 --- /dev/null +++ b/Informant/Directors/NotificationDirector.swift @@ -0,0 +1,112 @@ +// +// Notifications.swift +// Informant +// +// Created by Ty Irvine on 2022-04-05. +// + +import Cocoa +import Foundation +import SwiftUI + +class NotificationDirector { + + let appDelegate: AppDelegate + + init(appDelegate: AppDelegate) { + + // Assign delegate + self.appDelegate = appDelegate + + // Responsible for updating the accessibility state. + // https://stackoverflow.com/a/56206516/13142325 + DistributedNotificationCenter.default().addObserver( + forName: NSNotification.Name("com.apple.accessibility.api"), + object: nil, + queue: nil + ) { _ in + self.didAccessibilityChange() + } + + // Responsible for issuing updates about screen changes. + // https://developer.apple.com/documentation/appkit/nsapplication/1428749-didchangescreenparametersnotific + NotificationCenter.default.addObserver( + self, + selector: #selector(didScreenChange), + name: NSApplication.didChangeScreenParametersNotification, + object: nil + ) + + // Checks for space focus changes + NSWorkspace.shared.notificationCenter.addObserver( + self, + selector: #selector(didScreenChange), + name: NSWorkspace.activeSpaceDidChangeNotification, + object: nil + ) + + // Checks for app activations, like opening from command + space + NSWorkspace.shared.notificationCenter.addObserver( + self, + selector: #selector(appActivation), + name: NSWorkspace.didActivateApplicationNotification, + object: nil + ) + } + + // MARK: - Notification Handlers ๐Ÿฅฝ + + // Check to see if accessibility controls are enabled in sys. prefs. + @objc func didAccessibilityChange() { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + self.appDelegate.settings.statePrivacyAccessibilityEnabled = AXIsProcessTrusted() + self.appDelegate.interfaceController.updateAllInterfaces() + } + } + + // Check to see if the screen changed! + @objc func didScreenChange() { + appDelegate.displayController.updateMonitors() + } + + #warning("Make sure this works in release. It's difficult to test in Xcode.") + // Opens up the settings window for the app + @objc func appActivation(notification: NSNotification) { + + let foundBundleID = String(describing: notification.userInfo?["NSWorkspaceApplicationKey"]) + + // Grab Informant's app bundle id - com.tyirvine.Informant + guard let appBundleID = NSRunningApplication.current.bundleIdentifier else { + return + } + + // Open settings + if foundBundleID.contains(appBundleID) { + + // Check if settings is open + if appDelegate.settingsWindowController.window.isVisible == false { + + // Get a collection of windows that does not include the nsstatusbarwindows + let windows = NSApplication.shared.windows.filter { window in + window.description.contains("NSStatusBarWindow") == false + } + + // If a window is visible then break execution of this fn. + for window in windows { + if window.isVisible { + return + } + } + + appDelegate.settingsWindowController.open() + } + } + } + + // Issues an update when any view changes + /* + @objc func didViewChange() { + interfaceController.updateDisplays() + } + */ +} diff --git a/Informant/Displays/Display Views/FloatDisplayCloseView.swift b/Informant/Displays/Display Views/FloatDisplayCloseView.swift new file mode 100644 index 0000000..df14338 --- /dev/null +++ b/Informant/Displays/Display Views/FloatDisplayCloseView.swift @@ -0,0 +1,39 @@ +// +// FloatDisplayCloseView.swift +// Informant +// +// Created by Ty Irvine on 2022-04-12. +// + +// TODO: This can be removed +/* + import SwiftUI + + struct FloatDisplayCloseView: View { + + // Keeps track of the view being hovered + @State var isHovering: Bool = false + + var body: some View { + ZStack(alignment: .trailing) { + + // Overlay + Rectangle() + .fill(.clear) + .frame(width: 1) + + // Close button + Group { + Text("๔€†„") + .font(.system(size: 12, weight: .semibold)) + .opacity(isHovering ? 1 : 0.5) + .animation(.easeInOut(duration: 0.18), value: isHovering) + .whenHovered { hovering in + isHovering = hovering + } + } + .padding([.trailing], 10) + } + } + } + */ diff --git a/Informant/Displays/Display Views/FloatDisplayFieldCopyView.swift b/Informant/Displays/Display Views/FloatDisplayFieldCopyView.swift new file mode 100644 index 0000000..29d397f --- /dev/null +++ b/Informant/Displays/Display Views/FloatDisplayFieldCopyView.swift @@ -0,0 +1,119 @@ +// +// FloatDisplayField.swift +// Informant +// +// Created by Ty Irvine on 2022-04-12. +// + +import SwiftUI + +struct FloatDisplayFieldCopyView: View { + + let field: SelectionField + let text: String + + init(_ field: SelectionField, hover: Bool) { + self.field = field + self.text = field.value ?? "" + + // Only reset the hover state if it's false + if hover == false { + self.isHovering = hover + } + } + + // Controls hover state of the blue highlight + @State var isHovering: Bool = false + + // Controls the state of the copied message + @State var wasCopied: Bool = false + + /// Copies the text to the clipboard. + func copy() { + + // TODO: See if you can come up with a better solution for this + // Reset hovering state + isHovering = false + + // Block additional copy attempts + if wasCopied == false { + + // Record the state and copy the fragment + wasCopied = true + + let pasteboard = NSPasteboard.general + pasteboard.declareTypes([.string], owner: nil) + + // Determine if formatting needs to be done before copying + switch field.type { + + case .URL: + let text = text.formatSpecialCharacters() + pasteboard.setString(text, forType: .string) + break + + default: + pasteboard.setString(text, forType: .string) + break + } + + // Reset the state + DispatchQueue.main.asyncAfter(deadline: .now() + 0.9) { + wasCopied = false + } + } + } + + // MARK: View โคต๏ธŽ + + var body: some View { + ZStack { + + // Primary text and highlight group + Group { + // Primary text + Text(text) + .fontWeight(.medium) + .opacity(isHovering ? 0 : 1) + + // Blue highlight on hover for copy/paste + Text(text) + .fontWeight(.medium) + .foregroundColor(.blue) + .opacity(isHovering ? 1 : 0) + + // Runs the copy to clipboard flow + .inactiveWindowTap(draggable: false) { pressed in + if pressed == false { + copy() + } + } + } + .opacity(wasCopied ? 0 : 1) + + // Copied message + HStack(alignment: .center, spacing: 3) { + + // If the field is too small then we just don't show the full copied message + if text.count > 10 { + Text(ContentManager.Labels.copied) + .floatDisplayActionFont() + } + + Text("๔€…") + .font(.system(size: 13, weight: .heavy)) + .opacity(0.4) + } + .opacity(wasCopied ? 1 : 0) + } + + // Controls hover state + .whenHovered { hovering in + isHovering = hovering + } + + // Animate opacities + .animation(.easeInOut(duration: 0.18), value: isHovering) + .animation(.easeInOut(duration: 0.18), value: wasCopied) + } +} diff --git a/Informant/Displays/Display Views/FloatDisplayFieldDividerView.swift b/Informant/Displays/Display Views/FloatDisplayFieldDividerView.swift new file mode 100644 index 0000000..3076b2d --- /dev/null +++ b/Informant/Displays/Display Views/FloatDisplayFieldDividerView.swift @@ -0,0 +1,16 @@ +// +// FloatDisplayFieldDividerView.swift +// Informant +// +// Created by Ty Irvine on 2022-04-20. +// + +import SwiftUI + +struct FloatDisplayFieldDividerView: View { + var body: some View { + Text(ContentManager.Labels.divider) + .fontWeight(.medium) + .opacity(0.4) + } +} diff --git a/Informant/Displays/Display Views/FloatDisplayFieldLoaderView.swift b/Informant/Displays/Display Views/FloatDisplayFieldLoaderView.swift new file mode 100644 index 0000000..8886bfe --- /dev/null +++ b/Informant/Displays/Display Views/FloatDisplayFieldLoaderView.swift @@ -0,0 +1,19 @@ +// +// FloatDisplayFieldLoader.swift +// Informant +// +// Created by Ty Irvine on 2022-04-21. +// + +import SwiftUI + +struct FloatDisplayFieldLoaderView: View { + var body: some View { + VStack { + ProgressView() + .scaleEffect(0.55) + } + .frame(width: 20, height: 20) + .padding([.leading], 10) + } +} diff --git a/Informant/Displays/Display Views/FloatDisplayView.swift b/Informant/Displays/Display Views/FloatDisplayView.swift new file mode 100644 index 0000000..56497cb --- /dev/null +++ b/Informant/Displays/Display Views/FloatDisplayView.swift @@ -0,0 +1,95 @@ +// +// FloatDisplayView.swift +// Informant +// +// Created by Ty Irvine on 2022-04-11. +// + +import SwiftUI + +struct FloatDisplayView: View { + + let fields: [SelectionField] + + let appDelegate: AppDelegate + + // Keeps track of the view being hovered + @State var isHovering: Bool = false + + // Keeps track of the click count for a double click. + @State var mouseUpCount: Int = 0 + @State var mouseDownCount: Int = 0 + + /// Handle double tap and close the display. + func closeDisplay(_ pressed: Bool) { + + // On mouse down + if pressed { + mouseDownCount += 1 + } + + // On mouse up + else { + mouseUpCount += 1 + + // Check to see if this is a valid double click + // If so, close the displays + if mouseUpCount >= 2, mouseDownCount >= 2 { + appDelegate.interfaceController.hideDisplays() + } + + // Cancel double click + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + mouseDownCount = 0 + mouseUpCount = 0 + } + } + } + + var body: some View { + RootView { + ZStack(alignment: .trailing) { + + // This is the background selector for detecting clicks and hovers + Rectangle() + .fill(.clear) + .inactiveWindowTap { pressed in + closeDisplay(pressed) + } + .whenHovered { hovering in + isHovering = hovering + } + + // Field stack + HStack(spacing: 0) { + Spacer(minLength: 18) + + // The actual fields + ForEach(fields, id: \.id) { field in + + // Show the correct field view depending on the type + switch field.type { + case .Text, .URL: + FloatDisplayFieldCopyView(field, hover: isHovering) + + case .Divider: + FloatDisplayFieldDividerView() + + case .LoadingIndicator: + FloatDisplayFieldLoaderView() + } + } + + Spacer(minLength: 18) + } + } + + // Animates properties + .animation(.easeIn(duration: 0.14), value: isHovering) + } + + // Ensures the window displays properly without safety padding from title bar + .fixedSize(horizontal: true, vertical: false) + .frame(height: 16) + } +} diff --git a/Informant/Displays/Display.swift b/Informant/Displays/Display.swift new file mode 100644 index 0000000..83259c2 --- /dev/null +++ b/Informant/Displays/Display.swift @@ -0,0 +1,69 @@ +// +// Display.swift +// Informant +// +// Created by Ty Irvine on 2022-02-25. +// + +import AppKit +import Foundation + +/// This is the base class that all displays inherit. +class DisplayClass { + + let appDelegate: AppDelegate + + var isOpen: Bool? + + var isClosed: Bool? { + if let isOpen = isOpen { + return !isOpen + } + + return nil + } + + /// This is the selection data visible on the display. + private(set) var selectionData: SelectionData? + + /// This is the selection info visible on the display. + private(set) var selectionInfo: SelectionInfo? + + /// This is the front facing update method. It allows us to hook in extra functionality. + func update(_ data: SelectionData, _ info: SelectionInfo) { + + selectionData = data + selectionInfo = info + + guard let display = self as? Display else { + return + } + + display.display(data, info) + } + + required init(appDelegate: AppDelegate) { + self.appDelegate = appDelegate + } +} + +/// This is the protocol that all displays adhere to. +protocol DisplayProtocol { + + /// This is a ref. to the app delegate. + var appDelegate: AppDelegate { get } + + /// This keeps track of the state of the view. + var isOpen: Bool? { get set } + var isClosed: Bool? { get } + + // Init + init(appDelegate: AppDelegate) + + // Functions + func display(_ data: SelectionData, _ info: SelectionInfo) + func hide() + func error() +} + +typealias Display = DisplayClass & DisplayProtocol diff --git a/Informant/Displays/DisplayDetached.swift b/Informant/Displays/DisplayDetached.swift new file mode 100644 index 0000000..f0e2e8f --- /dev/null +++ b/Informant/Displays/DisplayDetached.swift @@ -0,0 +1,79 @@ +// +// DisplayDetached.swift +// Informant +// +// Created by Ty Irvine on 2022-04-16. +// + +import Foundation +import SwiftUI + +/// This provides extra functionality for displays that are detached. +class DetachedDisplayClass: DisplayClass { + + let window: NSWindow + + required init(appDelegate: AppDelegate) { + self.window = NSWindow() + super.init(appDelegate: appDelegate) + print("โ€ผ๏ธ DetachedDisplay - This initializer isn't meant to be used and is only for TESTING. Please check which initializer you're using to instantiate this object.") + } + + init(appDelegate: AppDelegate, window: NSWindow) { + self.window = window + super.init(appDelegate: appDelegate) + } + + /// This gets the origin point based on a provided snap point. + func getRelativeOrigin(_ snapPoint: SnapPoint) -> NSPoint { + let position = snapPoint.position + let displayPoint = FramePoint.getCorrespondingPointWithFrame(window.frame, position) + return calculateRelativeOrigin(snapPoint, displayPoint) + } + + /// This takes in a display point and snap point and calculates the relative origin. + func calculateRelativeOrigin(_ snapPoint: SnapPoint, _ displayPoint: NSPoint) -> NSPoint { + + // Find the delta x and delta y between the snap zone and the found point + let deltaX = snapPoint.point.x - displayPoint.x + let deltaY = snapPoint.point.y - displayPoint.y + + // Find where the display's origin is relative to the mid. point of the snap zone + return NSPoint( + x: window.frame.origin.x + deltaX, + y: window.frame.origin.y + deltaY + ) + } + + /// This gets the origin point on the active desktop. + func getRelativeOriginOnActiveDesktop(_ snapPoint: SnapPoint) -> NSPoint? { + + // Get active desktop screen + guard let main = NSScreen.main else { + return nil + } + + let position = snapPoint.position + + // Get relative snap point on the active desktop screen using the provided one + let relativePoint = FramePoint.getCorrespondingPointWithFrame(main.visibleFrame, position, SnapPoint.side) + + // Save the snap point for future snaps or sessions + let relativeSnapPoint = SnapPoint(origin: relativePoint, position: position) + relativeSnapPoint.save() + + // Return the relative origin + return getRelativeOrigin(relativeSnapPoint) + } +} + +/// This is the protocol that only detached displays adhere to. +protocol DetachedDisplayProtocol: DisplayProtocol { + + // Functions + func snap() + func refresh() + func window() -> NSWindow +} + +typealias DetachedDisplay = DetachedDisplayClass & DetachedDisplayProtocol diff --git a/Informant/Displays/FloatDisplay.swift b/Informant/Displays/FloatDisplay.swift new file mode 100644 index 0000000..3168a86 --- /dev/null +++ b/Informant/Displays/FloatDisplay.swift @@ -0,0 +1,243 @@ +// +// FloatDisplay.swift +// Informant +// +// Created by Ty Irvine on 2022-02-25. +// + +import Foundation +import SwiftUI + +/// This is a controller for the float display. +class FloatDisplay: DetachedDisplay { + + /// This is the floating menu bar that is used when using the existing menu bar is not possible. + /// For example, on 2022+ MacBook Pros. + let float: NSWindow! + + /// Keeps track of transitioning windows. + var transitioning: Bool = false + + /// Keeps track of animation states. Makes sure, animation states aren't interrupted as they come up. + var animationState: AnimationState? + + enum AnimationState { + case Opening + case Closing + } + + required init(appDelegate: AppDelegate) { + + // Initialize the floating menu bar + float = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 500, height: 500), + styleMask: [.closable, .unifiedTitleAndToolbar, .resizable, .fullSizeContentView, .titled], + backing: .buffered, + defer: false + ) + + // Setup remaining fields + float?.titlebarAppearsTransparent = true + + // Hide all title buttons + float.standardWindowButton(.closeButton)?.isHidden = true + float.standardWindowButton(.miniaturizeButton)?.isHidden = true + float.standardWindowButton(.zoomButton)?.isHidden = true + + // Behaviours + float.isReleasedWhenClosed = false + float.animationBehavior = .default + float.isMovableByWindowBackground = true + float.level = .floating + + // Set the minimum size - for some reason this changes depending on the behaviour of the display + // so it has to be set. + float.minSize = NSSize(width: 0, height: 44) + + super.init(appDelegate: appDelegate, window: float) + } + + /// This updates the display. + func display(_ data: SelectionData, _ info: SelectionInfo) { + + // Format the selection + guard let selectionFormatted = appDelegate.displayController.formatSelectionAsFields(data, info) else { + return hide() + } + + // Create the view with the formatted selection + let rootView = FloatDisplayView(fields: selectionFormatted, appDelegate: appDelegate) + + // Assign the view + float.contentViewController = NSHostingController(rootView: rootView) + + // Bail if the window is transitioning + if transitioning { + return + } + + // Get the saved snap position + let savedSnapPoint = appDelegate.displayController.findSavedSnapPoint() + + // Is the panel on the active desktop + let onActiveDesktop = float.screen == NSScreen.main + + // Get the relative origin by calculating the delta + guard let relativeOrigin = onActiveDesktop ? getRelativeOrigin(savedSnapPoint) : getRelativeOriginOnActiveDesktop(savedSnapPoint) else { + return + } + + // At this point the float display needs to switch screens + if onActiveDesktop == false { + transition(relativeOrigin) + } + + // Otherwise, open the display normally + else { + open(relativeOrigin) + } + } + + /// Simply opens the display. + func open(_ origin: NSPoint) { + + animationState = .Opening + + // Translate the window to that position + float.setFrameOrigin(origin) + + // Display the window + float.orderFront(nil) + + // Only show when transitioning + if float.isOnActiveSpace == false { + float.alphaValue = 0 + } + + // Animate window in + animate(alpha: 1) { + + // Display the window + if self.animationState == .Opening { + self.float.orderFront(nil) + } + + // Set the display to be open + self.isOpen = true + } + } + + /// This hides the display. + func hide() { + + animationState = .Closing + + #warning("Remove from production: Reset to 0") + animate(alpha: 0.4) { + + if self.animationState == .Closing { + self.float.close() + } + + // Set the display to be closed + self.isOpen = false + } + } + + /// This hides the display. + func transition(_ origin: NSPoint) { + + // Record start of transition + transitioning = true + + // Begin transitioning + #warning("Remove from production: Reset to 0") + animate(alpha: 0.4) { + + self.open(origin) + + // End transition + self.transitioning = false + } + } + + /// This shows an error message on the display. + func error() { + hide() + } + + /// This simply animates the window. + func animate(alpha: CGFloat, _ completion: @escaping () -> Void) { + DispatchQueue.main.async { + NSAnimationContext.runAnimationGroup { context -> Void in + context.duration = TimeInterval(0.25) + self.float.animator().alphaValue = alpha + } completionHandler: { + completion() + } + } + } + + // MARK: - Detached Display Functions + + /// Simply refreshes the position of the display. + func refresh() { + guard let data = selectionData else { return } + guard let info = selectionInfo else { return } + + if isOpen == true { + display(data, info) + } + } + + /// This snaps the display to the designated corner. + func snap() { + + // Check to see if the display is closed first + if isOpen == false { + return + } + + // Sees if this display is inside a snap zone + guard let snapPointSearch = appDelegate.displayController.closestSnapPointSearch(float.frame) else { + return // The display was not inside a snap zone, so abort + } + + // At this point, the display is inside a snap zone! + let snapPoint = snapPointSearch.snapPointFound + + // Get the corresponding display point + guard let displayPoint = snapPointSearch.displayPointFound else { + return + } + + // Calculates the relative origin + let relativeDisplayOrigin = calculateRelativeOrigin(snapPoint, displayPoint) + + // Bring the display to the front + float.orderFront(nil) + + // Get a temporary copy of the window + var tempFloatFrame = float.frame + + // Translate the temporary frame's origin + tempFloatFrame.origin = relativeDisplayOrigin + + // Animate the window's position + NSAnimationContext.runAnimationGroup({ context in + context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + context.duration = TimeInterval(0.2) + float.animator().setFrame(tempFloatFrame, display: true, animate: true) + }, completionHandler: { + + }) + + // Exit with this message + print("โœจ DISPLAY SNAPPED - IS VISIBLE: \(float.isVisible)") + } + + /// This returns the window for the display. + func window() -> NSWindow { + return float + } +} diff --git a/Informant/Displays/StatusDisplay.swift b/Informant/Displays/StatusDisplay.swift new file mode 100644 index 0000000..1eaef89 --- /dev/null +++ b/Informant/Displays/StatusDisplay.swift @@ -0,0 +1,140 @@ +// +// StatusDisplay.swift +// Informant +// +// Created by Ty Irvine on 2022-02-25. +// + +import Foundation +import SwiftUI + +/// This is a controller for the status display. +class StatusDisplay: Display { + + /// This is a ref. to the status item in the menu bar + let statusItem: NSStatusItem! + + /// This is a ref. to the sizing status item in the menu bar + var statusItemSizing: NSStatusItem? + + /// This is the font that is used in the status bar. + let font = NSFont.monospacedDigitSystemFont(ofSize: 13, weight: .regular) + + /// Keeps track of the old selection so we can make decisions about updating the interface. + var oldSelection: String? + + required init(appDelegate: AppDelegate) { + + // Assigns the status item ref to this field + statusItem = appDelegate.statusItem + + super.init(appDelegate: appDelegate) + } + + // MARK: - Display Functions + + /// This updates the display. + func display(_ data: SelectionData, _ info: SelectionInfo) { + + // Get the formatted string + guard let selectionFormatted = appDelegate.displayController.formatSelectionAsString(data, info, spaceAtEnd: true) else { + return hide() + } + + // Updates the display with the provided selection + updateDisplay(selectionFormatted) + + // Set the display to be open + isOpen = true + } + + /// This hides the display. + func hide() { + + // Reset the status item length and title + statusItem.length = NSStatusItem.variableLength + statusItem.button?.attributedTitle = NSAttributedString(string: "") + + // Set the display to be closed + isOpen = false + } + + /// This shows an error message on the display. + func error() { + hide() + } + + // MARK: - Utility Functions + + /// Updates the display. + private func updateDisplay(_ input: String) { + + // Mutable copy of the information + var information = input + + // Creates a left justified paragraph style. Makes sure size (102 KB or whatever) stays to the left of the status item + let paragraphStyle = NSMutableParagraphStyle() + + // Adjust the styles for a blank icon + if appDelegate.settings.settingMenubarIcon == ContentManager.Icons.menubarBlank { + paragraphStyle.alignment = .center + } else { + paragraphStyle.alignment = .left + information.append(" ") + } + + // Put the attributed string all together + let attrString = NSAttributedString( + string: information, + attributes: [.font: font, .baselineOffset: -0.5, .paragraphStyle: paragraphStyle] + ) + + // Find the status item length + guard let statusItemLength = getStatusItemLength(attrString: attrString) else { + return hide() + } + + // Change the status item length before the title is set to avoid layout conflicts + statusItem.length = statusItemLength + + // Feed the data into the display + statusItem.button?.attributedTitle = attrString + } + + /// Gets the status item size so it can be set before the title is changed. + private func getStatusItemLength(attrString: NSAttributedString) -> CGFloat? { + + // Creates a temporary status item to be used to find the appropriate size + // - if none is already there + if statusItemSizing == nil { + statusItemSizing = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + } + + // Creates a temporary ref. + guard let temp = statusItemSizing else { + return nil + } + + // Find image position + guard let imagePosition = statusItem.button?.imagePosition else { + return nil + } + + // Find image hugs title + guard let imageHugs = statusItem.button?.imageHugsTitle else { + return nil + } + + temp.isVisible = false + temp.button?.image = statusItem.button?.image + temp.button?.imagePosition = imagePosition + temp.button?.imageHugsTitle = imageHugs + temp.button?.attributedTitle = attrString + temp.button?.updateConstraints() + + guard let height = temp.button?.frame.size.height else { return nil } + guard let width = temp.button?.frame.size.width else { return nil } + + return width - height + } +} diff --git a/Informant/Extensions.swift b/Informant/Extensions.swift new file mode 100644 index 0000000..069073c --- /dev/null +++ b/Informant/Extensions.swift @@ -0,0 +1,198 @@ +// +// Extensions.swift +// Informant +// +// Created by Ty Irvine on 2022-02-21. +// + +import AVFoundation +import Foundation +import SwiftUI + +// This is so weird. This gets the codecs but it's really convoluted. +// https://developer.apple.com/documentation/avfoundation/avassettrack/1386694-formatdescriptions +extension AVAssetTrack { + var mediaFormat: String { + var format = "" + let descriptions = self.formatDescriptions as! [CMFormatDescription] + for (index, formatDesc) in descriptions.enumerated() { + // Get a string representation of the media subtype. + let subType = + CMFormatDescriptionGetMediaSubType(formatDesc).toString() + // Format the string as type/subType, such as vide/avc1 or soun/aac. + format += "\(subType.uppercased().replacingOccurrences(of: ".", with: ""))" + // Comma-separate if there's more than one format description. + if index < descriptions.count - 1 { + format += "," + } + } + return format + } +} + +extension FourCharCode { + // Create a string representation of a FourCC. + func toString() -> String { + let bytes: [CChar] = [ + CChar((self >> 24) & 0xff), + CChar((self >> 16) & 0xff), + CChar((self >> 8) & 0xff), + CChar(self & 0xff), + 0 + ] + let result = String(cString: bytes) + let characterSet = CharacterSet.whitespaces + return result.trimmingCharacters(in: characterSet) + } +} + +extension Array where Element == String { + + /// Checks to see if all elements in the new array are equal to the elements in the old array. + func areStringsEqual(_ other: [String]) -> Bool { + + for (index, string) in self.enumerated() { + + if other.indices.contains(index), other[index] == string { + continue + } else { + return false + } + } + + // All elements are equal + return true + } +} + +extension AppDelegate { + + /// Returns the version of the app. + static func version() -> String? { + + guard let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String else { + return nil + } + + return version + } +} + +extension Text { + + /// Adds some extra padding at the end + func togglePadding() -> some View { + self.padding([.leading], 4) + } +} + +extension Date { + + /// This takes in a date object and returns a formatted date as a string. + func formatDate() -> String { + + // Format dates as strings + let dateFormatter = DateFormatter() + dateFormatter.dateStyle = .medium + dateFormatter.timeStyle = .short + dateFormatter.doesRelativeDateFormatting = true + + return dateFormatter.string(from: self) + } +} + +extension Int { + + /// Formats raw byte size into a familliar 10MB, 3.2GB, etc. + func formatBytes() -> String { + return ByteCountFormatter().string(fromByteCount: Int64(self)) + } +} + +extension Int64 { + + /// Formats raw byte size into a familliar 10MB, 3.2GB, etc. + func formatBytes() -> String { + return ByteCountFormatter().string(fromByteCount: self) + } +} + +extension String { + + /// Wraps all special characters. Used primarily for making paths terminal friendly. + func formatSpecialCharacters() -> String { + + var string = self + var escapes = 0 + + // Cycle through characters and place escapes + for (index, char) in string.enumerated() { + if char.isSymbol || char.isWhitespace || char == "&" { + string.insert("\\", at: string.index(string.startIndex, offsetBy: index + escapes)) + escapes += 1 + } + } + + // Remove tilde escape from start + if string.first == "\\" { + string.removeFirst() + } + + return string + } + + /// Translates HFS path to POSIX path. + /// [Check this out for more info](https://en.wikibooks.org/wiki/AppleScript_Programming/Aliases_and_paths). + func posixPathFromHFSPath() -> String? { + + #warning("This needs to be re-written to not rely on the volumes keyword") + guard let fileCFURL = CFURLCreateWithFileSystemPath(kCFAllocatorDefault, ("Volumes:" + self) as CFString?, CFURLPathStyle(rawValue: 1)!, hasSuffix(":")) else { + return nil + } + + let fileURL = fileCFURL as URL + + return fileURL.path + } +} + +extension URL { + + /// Converts an array of string paths to URLs + static func convertPathsToURLs(_ urls: [String]) -> [URL] { + var convertedURLs: [URL] = [] + for url in urls { + convertedURLs.append(URL(fileURLWithPath: url)) + } + return convertedURLs + } +} + +extension NSMenuItem { + + /// Makes the button more juicy to click + func juicyWithoutImage() { + self.image = NSImage() + self.image?.size = NSSize(width: 0.01, height: Style.Menu.juicyImageHeight) + } + + /// Makes the button more juicy to click when an image is present + func juicyWithImage() { + self.image?.isTemplate = true + self.image?.size = NSSize(width: Style.Menu.juicyImageWidth, height: Style.Menu.juicyImageHeight) + } + + /// Sets up the image for the nsmenuitem + func setupImage(_ resourceName: String) { + self.image = NSImage(imageLiteralResourceName: resourceName) + self.juicyWithImage() + } +} + +extension AppDelegate { + + /// Provides the current instance of the app delegate along with all fields present in the class. + static func current() -> AppDelegate { + return NSApp.delegate as! AppDelegate + } +} diff --git a/Informant/Helpers/CacheHelper.swift b/Informant/Helpers/CacheHelper.swift new file mode 100644 index 0000000..33782ec --- /dev/null +++ b/Informant/Helpers/CacheHelper.swift @@ -0,0 +1,101 @@ +// +// DispatchQueueHelper.swift +// Informant +// +// Created by Ty Irvine on 2021-06-25. +// + +import Foundation +import UniformTypeIdentifiers + +/// Stores each url's byte size at a specific time stamp +class Cache { + + // Cache object + fileprivate var cache: [URL: CachedItem] = [:] + + /// Stores the provided bytes into the cache at the position of the url using it as the key. + func store(url: URL, bytes: Int64) { + + let types: [UTType] = [ + .application, + .directory + ] + + guard let type = DataUtility.getSelectionTypeSingle(path: url.path, types: types) else { + return + } + + cache[url] = CachedItem(bytes: bytes, type: type) + } + + /// Retrieves the cached item at the provided url. + func get(url: URL) -> CachedItem? { + + // Gets the object out of the cache + guard let item = cache[url] else { + return nil + } + + // Checks to make sure it's not expired before returning + if item.isExpired() { + return nil + } + + print("๐Ÿ’พ Cache Hit: \(item.bytes.formatBytes())") + + return item + } + + /// Erases the cache item at the provided url. + func erase(url: URL) { + cache.removeValue(forKey: url) + } + + /// This object will tell you when a directory's cache size is expired as well as the size of the directory in bytes. + struct CachedItem { + + var expiry: TimeInterval + var bytes: Int64 + + internal init(bytes: Int64, type: SelectionType) { + self.bytes = bytes + + // Get timestamp created + let created = Date().timeIntervalSince1970 + var interval: TimeInterval = 0 + + // Find expiry by adding specified time to created time + switch type { + + // Expiry is 10 minutes for applications + case .Application: interval = (60 * 10) + break + + // Expiry is 10 seconds for directories + case .Directory: interval = 10 + break + + default: + break + } + + // Set final expiry + expiry = created + interval + } + + /// Checks if the object is valid + func isExpired() -> Bool { + + // Get the current timestamp + let now = Date().timeIntervalSince1970 + + // Compare it to the old timestamp + if now >= expiry { + return true + } + + return false + } + } +} diff --git a/Informant/Helpers/DebugHelper.swift b/Informant/Helpers/DebugHelper.swift new file mode 100644 index 0000000..be9a4a9 --- /dev/null +++ b/Informant/Helpers/DebugHelper.swift @@ -0,0 +1,22 @@ +// +// DebugHelper.swift +// Informant +// +// Created by Ty Irvine on 2022-02-25. +// + +import Foundation + +public func print(_ object: Any...) { + #if DEBUG + for item in object { + Swift.print(item) + } + #endif +} + +public func print(_ object: Any) { + #if DEBUG + Swift.print(object) + #endif +} diff --git a/Informant/Helpers/DiskAllocationHelper.swift b/Informant/Helpers/DiskAllocationHelper.swift new file mode 100644 index 0000000..39fd602 --- /dev/null +++ b/Informant/Helpers/DiskAllocationHelper.swift @@ -0,0 +1,148 @@ + +// +// Copyright (c) 2016, 2018 Nikolai Ruhe. All rights reserved. +// + +import Foundation + +public extension FileManager { + + /// Calculate the allocated size of a directory and all its contents on the volume. + /// + /// As there's no simple way to get this information from the file system the method + /// has to crawl the entire hierarchy, accumulating the overall sum on the way. + /// The resulting value is roughly equivalent with the amount of bytes + /// that would become available on the volume if the directory would be deleted. + /// + /// - note: There are a couple of oddities that are not taken into account (like symbolic links, meta data of + /// directories, hard links, ...). + func allocatedSizeOfDirectory(at directoryURL: URL, info: SelectionInfo) throws -> Int64 { + + enum StopError: Error { + case runtimeError(String) + } + + // The error handler simply stores the error and stops traversal + var enumeratorError: Error? + func errorHandler(_: URL, error: Error) -> Bool { + enumeratorError = error + return false + } + + // We have to enumerate all directory contents, including subdirectories. + let enumerator = self.enumerator( + at: directoryURL, + includingPropertiesForKeys: Array(allocatedSizeResourceKeys), + options: [], + errorHandler: errorHandler + )! + + // We'll sum up content size here: + var accumulatedSize: Int64 = 0 + + // Perform the traversal. + for item in enumerator { + + // Check status of job. Bail on a cancelled job + if info.appDelegate.dataDirector.isJobActive(info.jobIDSize) == false { + throw StopError.runtimeError("Job was cancelled") + } + + // Bail out on errors from the errorHandler. + if enumeratorError != nil { break } + + // Add up individual file sizes. + let contentItemURL = item as! URL + accumulatedSize += try contentItemURL.regularFileAllocatedSize() + } + + // Rethrow errors from errorHandler. + if let error = enumeratorError { throw error } + + return accumulatedSize + } + + /// Finds the shallow number of items in the directory, not-including items contained within sub-directories. + func shallowCountOfItemsInDirectory(at directoryURL: URL) -> Int? { + + let options: FileManager.DirectoryEnumerationOptions = [ + .skipsSubdirectoryDescendants, + .skipsHiddenFiles, + .skipsPackageDescendants, + ] + + return itemsInDirectory(at: directoryURL, options: options) + } + + /// Finds the total number of items in the directory, including sub-directories + func totalCountOfItemsInDirectory(at directoryURL: URL) -> Int? { + + let options: FileManager.DirectoryEnumerationOptions = [] + + return itemsInDirectory(at: directoryURL, options: options) + } + + /// Finds the directory count of items in the directory, including sub-directories but not packages or hidden files + func directoryCountOfItemsInDirectory(at directoryURL: URL) -> Int? { + + let options: FileManager.DirectoryEnumerationOptions = [ + .skipsHiddenFiles, + .skipsPackageDescendants, + ] + + return itemsInDirectory(at: directoryURL, options: options) + } + + /// Finds the number of items in the directory based on enumerator options provided + private func itemsInDirectory(at directoryURL: URL, options: FileManager.DirectoryEnumerationOptions) -> Int? { + + // The error handler simply stores the error and stops traversal + var enumeratorError: Error? + func errorHandler(_: URL, error: Error) -> Bool { + enumeratorError = error + return false + } + + // Enumerate through all of the directory's contents + guard let enumerator = self.enumerator( + at: directoryURL, + includingPropertiesForKeys: [], + options: options, + errorHandler: errorHandler + ) else { return nil } + + /// Total item count in directory + var itemCount = 0 + + // Perform the calculation + for _ in enumerator { + + // Bail out on errors from the error handler + if enumeratorError != nil { break } + + itemCount += 1 + } + + return itemCount + } +} + +private extension URL { + + func regularFileAllocatedSize() throws -> Int64 { + let resourceValues = try self.resourceValues(forKeys: allocatedSizeResourceKeys) + + // To get the file's size we first try the most comprehensive value in terms of what + // the file may use on disk. This includes metadata, compression (on file system + // level) and block size. + + // In case totalFileAllocatedSize is unavailable we use the fallback value (excluding + // meta data and compression) This value should always be available. + + return Int64(resourceValues.totalFileSize ?? 0) + } +} + +private let allocatedSizeResourceKeys: Set = [ + .totalFileSizeKey, +] diff --git a/Informant/Helpers/EventMonitorHelper.swift b/Informant/Helpers/EventMonitorHelper.swift new file mode 100644 index 0000000..d4b231e --- /dev/null +++ b/Informant/Helpers/EventMonitorHelper.swift @@ -0,0 +1,43 @@ +// +// EventLogic.swift +// Informant +// +// Created by Ty Irvine on 2021-04-13. +// + +import Cocoa +import Foundation + +// This class listens for any requested actions and can be used to execute logic. +// So say I wanted to know when the user left clicks, this is the class to use. +// Ensure this gets start/stopped very little. Each monitor uses a good amount of resources +class EventMonitorHelper { + + private var monitor: Any? + private let mask: NSEvent.EventTypeMask + private let handler: (NSEvent) -> Void + + public init(mask: NSEvent.EventTypeMask, handler: @escaping (NSEvent?) -> Void) { + self.mask = mask + self.handler = handler + } + + deinit { + stop() + } + + // Starts monitoring + public func start() { + if monitor == nil { + monitor = NSEvent.addGlobalMonitorForEvents(matching: mask, handler: handler) as! NSObject + } + } + + // Stops monitoring + public func stop() { + if monitor != nil { + NSEvent.removeMonitor(monitor!) + monitor = nil + } + } +} diff --git a/Informant/Helpers/InactiveWindowTapHelper.swift b/Informant/Helpers/InactiveWindowTapHelper.swift new file mode 100644 index 0000000..71b548b --- /dev/null +++ b/Informant/Helpers/InactiveWindowTapHelper.swift @@ -0,0 +1,104 @@ +// +// NonKeyTapHelper.swift +// Informant +// +// Created by Ty Irvine on 2021-06-14. +// + +import Foundation +import SwiftUI + +extension View { + func inactiveWindowTap(draggable: Bool = false, _ pressed: @escaping (Bool) -> Void) -> some View { + modifier(InactiveWindowTapModifier(pressed, draggable: draggable)) + } +} + +struct InactiveWindowTapModifier: ViewModifier { + let pressed: (Bool) -> Void + let draggable: Bool + + init(_ pressed: @escaping (Bool) -> Void, draggable: Bool = false) { + self.pressed = pressed + self.draggable = draggable + } + + func body(content: Content) -> some View { + content.overlay( + GeometryReader { proxy in + ClickableViewRepresentable( + pressed: pressed, + draggable: draggable, + frame: proxy.frame(in: .global) + ) + } + ) + } +} + +private struct ClickableViewRepresentable: NSViewRepresentable { + let pressed: (Bool) -> Void + let draggable: Bool + let frame: NSRect + + func updateNSView(_ nsView: ClickableView, context: Context) { + nsView.pressed = pressed + } + + func makeNSView(context: Context) -> ClickableView { + draggable ? ClickableDraggableView(frame: frame, pressed: pressed) : ClickableView(frame: frame, pressed: pressed) + } +} + +class ClickableView: NSView { + + public var pressed: ((Bool) -> Void)? + + init(frame: NSRect, pressed: ((Bool) -> Void)?) { + super.init(frame: frame) + self.pressed = pressed + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { + return true + } + + override func mouseDown(with event: NSEvent) { + pressed?(true) + } + + override func mouseUp(with event: NSEvent) { + pressed?(false) + } +} + +class ClickableDraggableView: ClickableView { + + private var dragged: Bool? + + override func mouseDown(with event: NSEvent) { + if dragged != true { + pressed?(true) + } else { + dragged = false + } + } + + override func mouseUp(with event: NSEvent) { + if dragged != true { + pressed?(false) + } else { + dragged = false + } + } + + // Makes sure to cancel out taps when dragging + override func mouseDragged(with event: NSEvent) { + dragged = true + } +} diff --git a/Informant/Helpers/LinkHelper.swift b/Informant/Helpers/LinkHelper.swift new file mode 100644 index 0000000..49e3ccb --- /dev/null +++ b/Informant/Helpers/LinkHelper.swift @@ -0,0 +1,46 @@ +// +// LinkHelper.swift +// Informant +// +// Created by Ty Irvine on 2021-08-08. +// + +import Foundation +import SwiftUI + +class LinkHelper { + + /// Opens a link in the default browser. + static func openLink(link: String) { + if let url = URL(string: link) { + NSWorkspace.shared.open(url) + } + } + + /// Opens a file in preview. + static func openPDF(link: String) { + if let url = Bundle.main.url(forResource: link, withExtension: "pdf") { + NSWorkspace.shared.open(url) + } + } +} + +#warning("Add in remaining links") +extension String { + + static let linkTwitter = "https://twitter.com/_tyirvine" + + static let linkDonate = "https://github.com/sponsors/tyirvine" + + static let linkPrivacyPolicy = "https://github.com/tyirvine/Informant#privacy-policy" + + static let linkFeedback = "https://github.com/tyirvine/Informant/issues/new/choose" + + static let linkReleases = "https://github.com/tyirvine/Informant/releases" + + static let linkHelp = "https://github.com/tyirvine/Informant#frequently-asked-questions" + + static let linkAcknowledgements = "https://github.com/tyirvine/Informant#acknowledgements" + + static let linkEULA = "https://informant.so/eula" +} diff --git a/Informant/Helpers/MathHelper.swift b/Informant/Helpers/MathHelper.swift new file mode 100644 index 0000000..22607ab --- /dev/null +++ b/Informant/Helpers/MathHelper.swift @@ -0,0 +1,34 @@ +// +// MathHelper.swift +// Informant +// +// Created by Ty Irvine on 2022-02-24. +// + +import Foundation + +/// Converts approximated numbers into rational fractions. +/// Used to find fractions for shutter speed. +/// [Pimped from SO](https://stackoverflow.com/a/35895607/13142325) +struct Rational { + let numerator: Int + let denominator: Int + + init(numerator: Int, denominator: Int) { + self.numerator = numerator + self.denominator = denominator + } + + init(approximating x0: Double, withPrecision eps: Double = 1.0E-6) { + var x = x0 + var a = x.rounded(.down) + var (h1, k1, h, k) = (1, 0, Int(a), 1) + + while x - a > eps * Double(k) * Double(k) { + x = 1.0 / (x - a) + a = x.rounded(.down) + (h1, k1, h, k) = (h, k, h1 + Int(a) * h, k1 + Int(a) * k) + } + self.init(numerator: h, denominator: k) + } +} diff --git a/Informant/Helpers/MiscellaneousHelper.swift b/Informant/Helpers/MiscellaneousHelper.swift new file mode 100644 index 0000000..60a4404 --- /dev/null +++ b/Informant/Helpers/MiscellaneousHelper.swift @@ -0,0 +1,23 @@ +// +// MiscellaneousHelper.swift +// Informant +// +// Created by Ty Irvine on 2022-02-24. +// + +import Foundation + +/// Used to construct a bitrate. +public enum DataSizeUnit { + case None + case Kb + case Mb +} + +/// Compares two values. Can fail under rare conditions. +/// https://stackoverflow.com/a/61050386/13142325 +func isEqual(_ x: Any, _ y: Any) -> Bool { + guard x is AnyHashable else { return false } + guard y is AnyHashable else { return false } + return (x as! AnyHashable) == (y as! AnyHashable) +} diff --git a/Informant/Helpers/TrackingAreaHelper.swift b/Informant/Helpers/TrackingAreaHelper.swift new file mode 100644 index 0000000..3d47173 --- /dev/null +++ b/Informant/Helpers/TrackingAreaHelper.swift @@ -0,0 +1,85 @@ +// +// TrackingAreaHelper.swift +// Informant +// +// Created by importRyan on 2021-06-08. +// +// https://gist.github.com/importRyan/c668904b0c5442b80b6f38a980595031 + +import Foundation +import SwiftUI + +extension View { + func whenHovered(_ mouseIsInside: @escaping (Bool) -> Void) -> some View { + modifier(MouseInsideModifier(mouseIsInside)) + } +} + +struct MouseInsideModifier: ViewModifier { + let mouseIsInside: (Bool) -> Void + + init(_ mouseIsInside: @escaping (Bool) -> Void) { + self.mouseIsInside = mouseIsInside + } + + func body(content: Content) -> some View { + content.background( + GeometryReader { proxy in + Representable(mouseIsInside: mouseIsInside, + frame: proxy.frame(in: .global)) + } + ) + } + + private struct Representable: NSViewRepresentable { + let mouseIsInside: (Bool) -> Void + let frame: NSRect + + func makeCoordinator() -> Coordinator { + let coordinator = Coordinator() + coordinator.mouseIsInside = mouseIsInside + return coordinator + } + + class Coordinator: NSResponder { + var mouseIsInside: ((Bool) -> Void)? + + override func mouseEntered(with event: NSEvent) { + mouseIsInside?(true) + } + + override func mouseExited(with event: NSEvent) { + mouseIsInside?(false) + } + } + + func makeNSView(context: Context) -> NSView { + let view = NSView(frame: frame) + + let options: NSTrackingArea.Options = [ + .mouseEnteredAndExited, + .inVisibleRect, + .activeAlways, + .enabledDuringMouseDrag, + .mouseMoved + ] + + let trackingArea = NSTrackingArea( + rect: frame, + options: options, + owner: context.coordinator, + userInfo: nil + ) + + view.addTrackingArea(trackingArea) + + return view + } + + func updateNSView(_ nsView: NSView, context: Context) {} + + static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) { + nsView.trackingAreas.forEach { nsView.removeTrackingArea($0) } + } + } +} diff --git a/Informant/Helpers/WindowDragHelper.swift b/Informant/Helpers/WindowDragHelper.swift new file mode 100644 index 0000000..cb8596d --- /dev/null +++ b/Informant/Helpers/WindowDragHelper.swift @@ -0,0 +1,33 @@ +// +// WindowDragHelper.swift +// Informant +// +// Created by Ty Irvine on 2022-04-13. +// +// This solution was found on StackOverflow. +// https://stackoverflow.com/a/70279092/13142325 + +import Foundation +import SwiftUI + +// This modifier basically passes the the mouseDown event and then a drag +// is initiated using that event with a newly created NSView. +extension View { + func dragWindowWithClick() -> some View { + self.overlay(DragWindowView()) + } +} + +struct DragWindowView: NSViewRepresentable { + func makeNSView(context: Context) -> NSView { + return DragWindowNSView() + } + + func updateNSView(_ nsView: NSView, context: Context) { } +} + +class DragWindowNSView: NSView { + override public func mouseDown(with event: NSEvent) { + NSApp?.mainWindow?.performDrag(with: event) + } +} diff --git a/Informant/Info.plist b/Informant/Info.plist new file mode 100644 index 0000000..f67e065 --- /dev/null +++ b/Informant/Info.plist @@ -0,0 +1,12 @@ + + + + + SUPublicEDKey + pufRMV/3EBTexUJ/z7hBV2qaP0HAZGZr5nYQJXPB/LM= + SUFeedURL + https://raw.githubusercontent.com/tyirvine/Informant/main/appcast.xml + LSUIElement + + + diff --git a/Informant/Informant.entitlements b/Informant/Informant.entitlements new file mode 100644 index 0000000..9787375 --- /dev/null +++ b/Informant/Informant.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.automation.apple-events + + com.apple.security.temporary-exception.apple-events + + com.apple.finder + + + diff --git a/Informant/Models/ConnectedMonitor.swift b/Informant/Models/ConnectedMonitor.swift new file mode 100644 index 0000000..4c9b65a --- /dev/null +++ b/Informant/Models/ConnectedMonitor.swift @@ -0,0 +1,70 @@ +// +// ConnectedMonitor.swift +// Informant +// +// Created by Ty Irvine on 2022-04-14. +// + +import AppKit +import Foundation + +/// A simple object to represent a connected monitor. +struct ConnectedMonitor { + + /// The name of the connected monitor. + let name: String + + /// The width of the monitor. + let width: CGFloat + + /// The height of the monitor. + let height: CGFloat + + /// Width x height. + var dimensions: NSSize { + NSSize(width: self.width, height: self.height) + } + + /// The monitor object. + let screen: NSScreen + + /// This is a single side length of a snap zone. + let side: CGFloat + + // Get the screen origin without the dock and menu bar + // Note: The dock has to have hiding turned in order for it to be part of the sizing + /// Simply the frame we wanna use. + var visibleFrame: NSRect { + self.screen.visibleFrame + } + + init(_ screen: NSScreen) { + self.name = screen.localizedName + self.width = screen.visibleFrame.width + self.height = screen.visibleFrame.height + self.screen = screen + + // TODO: This needs to be integrated into settings + self.side = SnapPoint.side + } + + /// Returns all snap zones for the monitor. + func getSnapPoints() -> [SnapPoint] { + + // Logs the zero position for the screen + print("๐Ÿ“Œ SCREEN ORIGIN: \(self.visibleFrame.origin)") + + // Calculate width and height accounting for snap zone side length. + let framePoints = FramePoint.getPoints(frame: self.visibleFrame, offset: self.side) + + // Returns all snap points using the given frame points + return SnapPoint.getSnapPoints(framePoints) + } + + /// Gets the default top right point for the monitor. + func getDefaultPoint() -> SnapPoint { + let position = FramePosition.TopRight + let defaultPoint = FramePoint.getCorrespondingPointWithFrame(self.visibleFrame, position, self.side) + return SnapPoint(origin: defaultPoint, position: position) + } +} diff --git a/Informant/Models/FramePoint.swift b/Informant/Models/FramePoint.swift new file mode 100644 index 0000000..6d607d6 --- /dev/null +++ b/Informant/Models/FramePoint.swift @@ -0,0 +1,126 @@ +// +// FramePoints.swift +// Informant +// +// Created by Ty Irvine on 2022-04-15. +// + +import Foundation + +struct FramePoint { + + /// Provides the position this point is located at. + let position: FramePosition + + /// This is the actual display point. + let point: NSPoint + + internal init(_ point: NSPoint, _ position: FramePosition) { + self.position = position + self.point = point + } + + /// This gets a single point on the provided frame for the provided position. + static func getCorrespondingPointWithFrame(_ frame: NSRect, _ position: FramePosition, _ offset: CGFloat = 0) -> NSPoint { + + // Calculate width and height accounting for snap zone side length. + let maxX = frame.maxX - offset + let maxY = frame.maxY - offset + let midX = frame.midX - offset + let midY = frame.midY - offset + let minX = frame.minX + let minY = frame.minY + + switch position { + case .TopLeft: + return NSPoint(x: minX, y: maxY) + + case .TopMiddle: + return NSPoint(x: midX, y: maxY) + + case .TopRight: + return NSPoint(x: maxX, y: maxY) + + case .MiddleLeft: + return NSPoint(x: minX, y: midY) + + case .MiddleMiddle: + return NSPoint(x: midX, y: midY) + + case .MiddleRight: + return NSPoint(x: maxX, y: midY) + + case .BottomLeft: + return NSPoint(x: minX, y: minY) + + case .BottomMiddle: + return NSPoint(x: midX, y: minY) + + case .BottomRight: + return NSPoint(x: maxX, y: minY) + } + } + + /// This finds the corresponding point out of a collection of points for the provided position. + static func getCorrespondingPoint(_ points: [FramePoint], _ correspondingPosition: FramePosition) -> FramePoint? { + return points.first { point in + point.position == correspondingPosition + } + } + + /// This gets all nine frame points for any given frame. + static func getPoints(frame: NSRect, offset: CGFloat = 0) -> [FramePoint] { + + // Calculate width and height accounting for snap zone side length. + let maxX = frame.maxX - offset + let maxY = frame.maxY - offset + let midX = frame.midX - offset + let midY = frame.midY - offset + let minX = frame.minX + let minY = frame.minY + + // Tops + let originTopLeft = NSPoint(x: minX, y: maxY) + let originTopMiddle = NSPoint(x: midX, y: maxY) + let originTopRight = NSPoint(x: maxX, y: maxY) + + // Middles + let originMiddleLeft = NSPoint(x: minX, y: midY) + let originMiddleMiddle = NSPoint(x: midX, y: midY) + let originMiddleRight = NSPoint(x: maxX, y: midY) + + // Bottoms + let originBottomLeft = NSPoint(x: minX, y: minY) + let originBottomMiddle = NSPoint(x: midX, y: minY) + let originBottomRight = NSPoint(x: maxX, y: minY) + + return [ + FramePoint(originTopLeft, .TopLeft), + FramePoint(originTopMiddle, .TopMiddle), + FramePoint(originTopRight, .TopRight), + + FramePoint(originMiddleLeft, .MiddleLeft), + FramePoint(originMiddleMiddle, .MiddleMiddle), + FramePoint(originMiddleRight, .MiddleRight), + + FramePoint(originBottomLeft, .BottomLeft), + FramePoint(originBottomMiddle, .BottomMiddle), + FramePoint(originBottomRight, .BottomRight) + ] + } +} + +/// This is way to connect a snap point to the correct display point. +public enum FramePosition: String { + case TopLeft + case TopMiddle + case TopRight + + case MiddleLeft + case MiddleMiddle + case MiddleRight + + case BottomLeft + case BottomMiddle + case BottomRight +} diff --git a/Informant/Models/Paths.swift b/Informant/Models/Paths.swift new file mode 100644 index 0000000..b3a665e --- /dev/null +++ b/Informant/Models/Paths.swift @@ -0,0 +1,27 @@ +// +// SelectionModel.swift +// Informant +// +// Created by Ty Irvine on 2022-02-23. +// + +import Foundation + +public enum PathState { + case PathDuplicate + case PathAvailable + case PathUnavailable + case PathError +} + +/// This is just an object that allows us to send error information along with the urls +struct Paths { + + let paths: [String]? + let state: PathState? + + internal init(paths: [String]?, state: PathState? = nil) { + self.paths = paths + self.state = state + } +} diff --git a/Informant/Models/Selection.swift b/Informant/Models/Selection.swift new file mode 100644 index 0000000..3dd40df --- /dev/null +++ b/Informant/Models/Selection.swift @@ -0,0 +1,185 @@ +// +// Selection.swift +// Informant +// +// Created by Ty Irvine on 2022-02-23. +// + +import Foundation + +/// Used to indicate generalized selection type. +public enum SelectionType { + case None + case Error + case Duplicate + case Single + case Multi + case Directory + case Application + case Volume + case Image + case Movie + case Audio +} + +/// Used to transfer selections. +struct Selection { + let type: SelectionType + let info: SelectionInfo? + let data: SelectionData? +} + +/// Useful information that can influence how data is displayed. +public struct SelectionInfo { + + /// Just a ref. to the app's delegate. + let appDelegate: AppDelegate + + /// This is the url that points to the selection. + var url: URL { + return urls[0] + } + + /// Used to store a collection of urls. + var urls: [URL] + + /// Used to hold a type. + var type: SelectionType + + /// Selection id. + var id: String = UUID().uuidString + + /// Used to identify the selection during async operations for size. + var jobIDSize: String = UUID().uuidString + + /// Used to identify the selection during async operations. + var jobIDMain: String = UUID().uuidString + + /// Determines if the file has the .icloud extension. + var isiCloudSyncFile: Bool? + + /// Determines if the file is marked hidden. Hidden files have a period in front of them. + var isHidden: Bool? +} + +/// Used to model data for any type of selection. +struct SelectionData { + + /// This contains all selection data. + var data: [String: String?] + + /// This tells us whether or not the data is loading, completed, error'd out, etc. + private var state: [SelectionDataState] + + init(_ data: [String: String?] = [:]) { + + // Initialize data + self.data = data + self.state = [] + } + + /// Returns all display data as a list filtered for nil values and anything that's not just text. + func toListOfStrings() -> [String] { + + var list: [String] = [] + + // Cycle all fields and only add approved types + for field in toList() { + + // Make sure the value is valid + guard let value = field.value else { + continue + } + + // Only add types that are text based + switch field.type { + + case .URL: + list.append(value) + break + + case .Text: + list.append(value) + break + + default: + break + } + } + + // Removes all nils + return list.compactMap { $0 } + } + + /// Returns all fields with their corresponding field type. + func toListOfFields() -> [SelectionField] { + return toList() + } + + /// Merges another selection display data struct with this one. + func merge(with: Self) -> Self { + + var mergedData = data + mergedData.merge(with.data, uniquingKeysWith: { _, b in b }) + + var newSelection = SelectionData() + newSelection.data = mergedData + + return newSelection + } + + /// Provides the selection as a list of typed selection fields. + private func toList() -> [SelectionField] { + + var list: [SelectionField] = [] + + // Cycle all fields + for (key, _) in Settings.defaultSettingsOrdered { + if let value = data[key] as? String { + + // Hold field type + let fieldType: SelectionFieldType + + // Determine field type + switch key { + case .keyShowPath: + fieldType = .URL + break + + default: + fieldType = .Text + break + } + + // Build the field + let field = SelectionField(value: value, type: fieldType) + + // Add it to the list + list.append(field) + } + } + + // Removes all nils and exit + return list.compactMap { $0 } + } +} + +/// Used to indicate state. +enum SelectionDataState { + case Loading +} + +/// Used to carry additional functionality to the field view. +struct SelectionField { + let value: String? + let type: SelectionFieldType + let id: String = UUID().uuidString +} + +/// Used to define a selection field. +enum SelectionFieldType { + case Divider + case LoadingIndicator + case Text + case URL +} diff --git a/Informant/Models/SnapPoint.swift b/Informant/Models/SnapPoint.swift new file mode 100644 index 0000000..7d00736 --- /dev/null +++ b/Informant/Models/SnapPoint.swift @@ -0,0 +1,147 @@ +// +// SnapPoints.swift +// Informant +// +// Created by Ty Irvine on 2022-04-14. +// + +import Foundation + +/// A simple object that represents the snap zones for a connected monitor. +struct SnapPoint { + + // Collects information needed to create the snap zone + let origin: NSPoint + let size: NSSize + + /// This is what's used to find where to snap to. + let point: NSPoint + + /// This is way to connect a snap point to the correct display point. + let position: FramePosition + + /// This zone is constructed from the size and origin. + /// It uses it's mid point to find the snap point. + private let zone: NSRect + + // TODO: Make this adjustable in the future. + /// This is the length of a single side for a snap zone. + static let side: CGFloat = 20 + + // MARK: - Initialize + + init(origin: NSPoint, position: FramePosition) { + + self.origin = origin + self.position = position + + // Builds zone + size = NSSize(width: Self.side, height: Self.side) + zone = NSRect(origin: self.origin, size: size) + + // Build point + point = NSPoint( + x: zone.midX, + y: zone.midY + ) + } + + /// Returns the snap point as simple save object. It encodes the save object into JSON + /// and then saves that to user defaults. + func save() { + + // Get the save object and encoder + let save = SnapPointSave(origin: origin, position: position.rawValue) + let encoder = JSONEncoder() + + // Encode the save object and save it + if let encoded = try? encoder.encode(save) { + UserDefaults.standard.set(encoded, forKey: .keySavedSnapPoint) + } + } + + /// Reads the encoded JSON data stored in user defaults and returns it as a normal snap point. + static func read() -> SnapPoint? { + + // Read the save data + if let save = UserDefaults.standard.object(forKey: .keySavedSnapPoint) as? Data { + + // Decode the save data and then generate the snap point from the save object + let decoder = JSONDecoder() + if let read = try? decoder.decode(SnapPointSave.self, from: save) { + return read.getSnapPoint() + } + } + + return nil + } + + /// Returns the absolute delta value of the distance from the provided point to this snap point. + func distance(_ providedPoint: NSPoint) -> CGFloat { + + // Find the delta between the two points + let deltaX = (point.x - providedPoint.x).magnitude + let deltaY = (point.y - providedPoint.y).magnitude + + // Find the distance using the Pythagorean theorem + let a = pow(deltaX, 2) + let b = pow(deltaY, 2) + + // Return hypotenuse distance + return sqrt(a + b) + } + + /// Returns an array of snap points using an array of frame points. + static func getSnapPoints(_ framePoints: [FramePoint]) -> [SnapPoint] { + + var snapPoints: [SnapPoint] = [] + + for framePoint in framePoints { + snapPoints.append(SnapPoint(origin: framePoint.point, position: framePoint.position)) + } + + return snapPoints + } +} + +/// This is a simple object meant to be stored in user defaults. +struct SnapPointSave: Codable { + var origin: NSPoint + var position: String + + /// Generates a snap point from the save data. + func getSnapPoint() -> SnapPoint { + return SnapPoint(origin: origin, position: FramePosition(rawValue: position) ?? .TopRight) + } +} + +/// Joins snap point data with other data such as an additional point for a detached display. +struct SnapPointSearch { + + /// This is the snap zone found. + let snapPointFound: SnapPoint + + /// This is the point found within the snap zone. + var displayPointFound: NSPoint? { + + // Get the corresponding point for snap point + guard let correspondingPoint = FramePoint.getCorrespondingPoint(displayPoints, snapPointFound.position) else { + return nil + } + + // Assign the corresponding point + return correspondingPoint.point + } + + /// These are all the display points found in the search. + let displayPoints: [FramePoint] + + /// This is the point to point distance between the two points. + let distance: CGFloat? + + internal init(_ snapPointFound: SnapPoint, _ displayPoints: [FramePoint], _ distance: CGFloat) { + self.snapPointFound = snapPointFound + self.distance = distance + self.displayPoints = displayPoints + } +} diff --git a/Informant/Settings/Settings.swift b/Informant/Settings/Settings.swift new file mode 100644 index 0000000..2aa1588 --- /dev/null +++ b/Informant/Settings/Settings.swift @@ -0,0 +1,457 @@ +// +// SettingsController.swift +// Informant +// +// Created by Ty Irvine on 2022-02-21. +// + +import Foundation +import SwiftUI + +// A place to store all keys used for settings. +extension String { + + // MARK: - Stateful Settings + + static let keyPauseApp = "pauseApp" + + static let keyShowWelcomeWindow = "welcomeWindowShow" + + static let keySavedSnapPoint = "savedSnapPoint" + + // MARK: - Menu Bar Settings + + // General + static let keyShowName = "menubarShowName" + + static let keyShowSize = "menubarShowSize" + + static let keyShowPath = "menubarShowPath" + + static let keyShowiCloudContainerName = "menubarShowiCloudContainerName" + + static let keyShowKind = "menubarShowKind" + + static let keyShowCreated = "menubarShowCreated" + + static let keyShowModified = "menubarShowModified" + + // Media + static let keyShowDuration = "menubarShowDuration" + + static let keyShowDimensions = "menubarShowDimensions" + + static let keyShowCodecs = "menubarShowCodecs" + + static let keyShowColorProfile = "menubarShowColorProfile" + + static let keyShowColorGamut = "menubarShowColorGamut" + + static let keyShowTotalBitrate = "menubarShowTotalBitrate" + + static let keyShowVideoBitrate = "menubarShowVideoBitrate" + + // Directory + static let keyShowItems = "menubarShowItems" + + // Application + static let keyShowVersion = "menubarShowVersion" + + // Audio + static let keyShowSampleRate = "menubarShowSampleRate" + + static let keyShowAudioBitrate = "menubarShowAudioBitrate" + + // Volume + static let keyShowVolumeTotal = "menubarShowVolumeTotal" + + static let keyShowVolumeAvailable = "menubarShowAvailable" + + static let keyShowVolumePurgeable = "menubarShowVolumePurgeable" + + // Images + static let keyShowAperture = "menubarShowAperture" + + static let keyShowISO = "menubarShowISO" + + static let keyShowFocalLength = "menubarShowFocalLength" + + static let keyShowCamera = "menubarShowCamera" + + static let keyShowShutterSpeed = "menubarShowShutterSpeed" + + // Multi selection + static let keyShowTotalCount = "menubarShowTotalCount" + + static let keyShowTotalSize = "menubarShowTotalSize" + + // MARK: - System Settings + + static let keyMenubarIcon = "menubarIcon" + + static let keySkipGettingSizeForDirectories = "skipGettingSizeForDirectories" + + static let keyChosenDisplay = "chosenDisplay" + + static let keyHideWhenViewingOtherApps = "hideWhenViewingOtherApps" + + static let keyUpdateFrequency = "updateFrequency" + + // MARK: - Extra keys + + static let keyMainData = "menubarMainData" +} + +/// Controls all settings throughout the app and makes sure to send an update when a setting is changed. +/// On init, all user defaults are registered. +/// +/// IMPORTANT: +/// Making a change to settings requires changes in three spots. +/// 1. You must make a key that represents the setting. +/// 2. Create a published property representing that key. (optional, if it won't have direct controls) +/// 3. Set the default for the key in user defaults. +class Settings: Controller, ControllerProtocol, ObservableObject { + + // MARK: State Fields + /// Lets us know the state of accessibility permission. + @Published var statePrivacyAccessibilityEnabled: Bool + + // MARK: Settings Fields + // ------------ โšก๏ธ Stateful settings ------------ + @Published var settingShowWelcomeWindow: Bool = Settings.fieldInit(type: .Bool, key: .keyShowWelcomeWindow) { + willSet(value) { fieldWillSet(value: value, key: .keyShowWelcomeWindow) } + didSet(value) { fieldDidSet(key: .keyShowWelcomeWindow) } + } + + @Published var settingPauseApp: Bool = Settings.fieldInit(type: .Bool, key: .keyPauseApp) { + willSet(value) { fieldWillSet(value: value, key: .keyPauseApp) } + didSet(value) { fieldDidSet(key: .keyPauseApp) } + } + + // ------------ โš™๏ธ System settings ------------ + @Published var settingMenubarIcon: String = Settings.fieldInit(type: .String, key: .keyMenubarIcon) { + willSet(value) { fieldWillSet(value: value, key: .keyMenubarIcon) } + didSet(value) { fieldDidSet(key: .keyMenubarIcon) } + } + + @Published var settingSkipGettingSizeForDirectories: Bool = Settings.fieldInit(type: .Bool, key: .keySkipGettingSizeForDirectories) { + willSet(value) { fieldWillSet(value: value, key: .keySkipGettingSizeForDirectories) } + didSet(value) { fieldDidSet(key: .keySkipGettingSizeForDirectories) } + } + + @Published var settingChosenDisplay: DisplayController.Displays = Settings.fieldInit(type: .Display, key: .keyChosenDisplay) { + willSet(value) { fieldWillSet(value: value.rawValue, key: .keyChosenDisplay) } + didSet(value) { fieldDidSet(key: .keyChosenDisplay) } + } + + @Published var settingHideWhenViewingOtherApps: Bool = Settings.fieldInit(type: .Bool, key: .keyHideWhenViewingOtherApps) { + willSet(value) { fieldWillSet(value: value, key: .keyHideWhenViewingOtherApps) } + didSet(value) { fieldDidSet(key: .keyHideWhenViewingOtherApps) } + } + + @Published var settingUpdateFrequency: UpdateFrequency = Settings.fieldInit(type: .UpdateFrequency, key: .keyUpdateFrequency) { + willSet(value) { fieldWillSet(value: value.rawValue, key: .keyUpdateFrequency) } + didSet(value) { fieldDidSet(key: .keyUpdateFrequency) } + } + + // ------------ Display settings ------------ + + // Single Selection โ˜๏ธ + @Published var settingShowSize: Bool = Settings.fieldInit(type: .Bool, key: .keyShowSize) { + willSet(value) { fieldWillSet(value: value, key: .keyShowSize) } + didSet(value) { fieldDidSet(key: .keyShowSize) } + } + + @Published var settingShowName: Bool = Settings.fieldInit(type: .Bool, key: .keyShowName) { + willSet(value) { fieldWillSet(value: value, key: .keyShowName) } + didSet(value) { fieldDidSet(key: .keyShowName) } + } + + @Published var settingShowPath: Bool = Settings.fieldInit(type: .Bool, key: .keyShowPath) { + willSet(value) { fieldWillSet(value: value, key: .keyShowPath) } + didSet(value) { fieldDidSet(key: .keyShowPath) } + } + + @Published var settingShowiCloudContainerName: Bool = Settings.fieldInit(type: .Bool, key: .keyShowiCloudContainerName) { + willSet(value) { fieldWillSet(value: value, key: .keyShowiCloudContainerName) } + didSet(value) { fieldDidSet(key: .keyShowiCloudContainerName) } + } + + @Published var settingShowKind: Bool = Settings.fieldInit(type: .Bool, key: .keyShowKind) { + willSet(value) { fieldWillSet(value: value, key: .keyShowKind) } + didSet(value) { fieldDidSet(key: .keyShowKind) } + } + + @Published var settingShowCreated: Bool = Settings.fieldInit(type: .Bool, key: .keyShowCreated) { + willSet(value) { fieldWillSet(value: value, key: .keyShowCreated) } + didSet(value) { fieldDidSet(key: .keyShowCreated) } + } + + @Published var settingShowModified: Bool = Settings.fieldInit(type: .Bool, key: .keyShowModified) { + willSet(value) { fieldWillSet(value: value, key: .keyShowModified) } + didSet(value) { fieldDidSet(key: .keyShowModified) } + } + + // Image ๐Ÿ“ท + @Published var settingShowCamera: Bool = Settings.fieldInit(type: .Bool, key: .keyShowCamera) { + willSet(value) { fieldWillSet(value: value, key: .keyShowCamera) } + didSet(value) { fieldDidSet(key: .keyShowCamera) } + } + + @Published var settingShowFocalLength: Bool = Settings.fieldInit(type: .Bool, key: .keyShowFocalLength) { + willSet(value) { fieldWillSet(value: value, key: .keyShowFocalLength) } + didSet(value) { fieldDidSet(key: .keyShowFocalLength) } + } + + @Published var settingShowAperture: Bool = Settings.fieldInit(type: .Bool, key: .keyShowAperture) { + willSet(value) { fieldWillSet(value: value, key: .keyShowAperture) } + didSet(value) { fieldDidSet(key: .keyShowAperture) } + } + + @Published var settingShowShutterSpeed: Bool = Settings.fieldInit(type: .Bool, key: .keyShowShutterSpeed) { + willSet(value) { fieldWillSet(value: value, key: .keyShowShutterSpeed) } + didSet(value) { fieldDidSet(key: .keyShowShutterSpeed) } + } + + @Published var settingShowISO: Bool = Settings.fieldInit(type: .Bool, key: .keyShowISO) { + willSet(value) { fieldWillSet(value: value, key: .keyShowISO) } + didSet(value) { fieldDidSet(key: .keyShowISO) } + } + + // Movie ๐Ÿ“ฝ + @Published var settingShowCodecs: Bool = Settings.fieldInit(type: .Bool, key: .keyShowCodecs) { + willSet(value) { fieldWillSet(value: value, key: .keyShowCodecs) } + didSet(value) { fieldDidSet(key: .keyShowCodecs) } + } + + // Audio ๐ŸŽ™ + @Published var settingShowSampleRate: Bool = Settings.fieldInit(type: .Bool, key: .keyShowSampleRate) { + willSet(value) { fieldWillSet(value: value, key: .keyShowSampleRate) } + didSet(value) { fieldDidSet(key: .keyShowSampleRate) } + } + + // Media ๐Ÿ“น + @Published var settingShowDimensions: Bool = Settings.fieldInit(type: .Bool, key: .keyShowDimensions) { + willSet(value) { fieldWillSet(value: value, key: .keyShowDimensions) } + didSet(value) { fieldDidSet(key: .keyShowDimensions) } + } + + @Published var settingShowColorProfile: Bool = Settings.fieldInit(type: .Bool, key: .keyShowColorProfile) { + willSet(value) { fieldWillSet(value: value, key: .keyShowColorProfile) } + didSet(value) { fieldDidSet(key: .keyShowColorProfile) } + } + + @Published var settingShowColorGamut: Bool = Settings.fieldInit(type: .Bool, key: .keyShowColorGamut) { + willSet(value) { fieldWillSet(value: value, key: .keyShowColorGamut) } + didSet(value) { fieldDidSet(key: .keyShowColorGamut) } + } + + @Published var settingShowDuration: Bool = Settings.fieldInit(type: .Bool, key: .keyShowDuration) { + willSet(value) { fieldWillSet(value: value, key: .keyShowDuration) } + didSet(value) { fieldDidSet(key: .keyShowDuration) } + } + + // Bitrates 010001010101010100100001010 + @Published var settingShowTotalBitrate: Bool = Settings.fieldInit(type: .Bool, key: .keyShowTotalBitrate) { + willSet(value) { fieldWillSet(value: value, key: .keyShowTotalBitrate) } + didSet(value) { fieldDidSet(key: .keyShowTotalBitrate) } + } + + @Published var settingShowVideoBitrate: Bool = Settings.fieldInit(type: .Bool, key: .keyShowVideoBitrate) { + willSet(value) { fieldWillSet(value: value, key: .keyShowVideoBitrate) } + didSet(value) { fieldDidSet(key: .keyShowVideoBitrate) } + } + + @Published var settingShowAudioBitrate: Bool = Settings.fieldInit(type: .Bool, key: .keyShowAudioBitrate) { + willSet(value) { fieldWillSet(value: value, key: .keyShowAudioBitrate) } + didSet(value) { fieldDidSet(key: .keyShowAudioBitrate) } + } + + // Directory ๐Ÿ“ + @Published var settingShowItems: Bool = Settings.fieldInit(type: .Bool, key: .keyShowItems) { + willSet(value) { fieldWillSet(value: value, key: .keyShowItems) } + didSet(value) { fieldDidSet(key: .keyShowItems) } + } + + // Application ๐Ÿช€ + @Published var settingShowVersion: Bool = Settings.fieldInit(type: .Bool, key: .keyShowVersion) { + willSet(value) { fieldWillSet(value: value, key: .keyShowVersion) } + didSet(value) { fieldDidSet(key: .keyShowVersion) } + } + + // Volume ๐Ÿ’พ + @Published var settingShowVolumeTotal: Bool = Settings.fieldInit(type: .Bool, key: .keyShowVolumeTotal) { + willSet(value) { fieldWillSet(value: value, key: .keyShowVolumeTotal) } + didSet(value) { fieldDidSet(key: .keyShowVolumeTotal) } + } + + @Published var settingShowVolumeAvailable: Bool = Settings.fieldInit(type: .Bool, key: .keyShowVolumeAvailable) { + willSet(value) { fieldWillSet(value: value, key: .keyShowVolumeAvailable) } + didSet(value) { fieldDidSet(key: .keyShowVolumeAvailable) } + } + + @Published var settingShowVolumePurgeable: Bool = Settings.fieldInit(type: .Bool, key: .keyShowVolumePurgeable) { + willSet(value) { fieldWillSet(value: value, key: .keyShowVolumePurgeable) } + didSet(value) { fieldDidSet(key: .keyShowVolumePurgeable) } + } + + /// Used to initialize properties. + private enum PropertyType { + case String + case Bool + case Display + case UpdateFrequency + case FramePosition + } + + /// All state defaults. + private static let defaultStatesOrdered: KeyValuePairs = [ + .keyShowWelcomeWindow: true, + .keyPauseApp: false, + .keySavedSnapPoint: FramePosition.TopRight.rawValue, + ] + + /// All user defaults. + static let defaultSettingsOrdered: KeyValuePairs = [ + + // System + .keyMenubarIcon: ContentManager.Icons.menubarDefault, + .keySkipGettingSizeForDirectories: false, + .keyHideWhenViewingOtherApps: false, + .keyUpdateFrequency: UpdateFrequency.AutoUpdate.rawValue, + + // Menubar + .keyShowiCloudContainerName: false, + .keyShowName: false, + .keyShowKind: false, + .keyShowCreated: false, + .keyShowModified: false, + + .keyShowCodecs: false, + .keyShowColorProfile: false, + .keyShowColorGamut: false, + .keyShowTotalBitrate: false, + .keyShowVideoBitrate: false, + + .keyShowVersion: false, + + .keyShowSampleRate: false, + .keyShowAudioBitrate: false, + + .keyShowVolumeTotal: false, + .keyShowVolumeAvailable: false, + .keyShowVolumePurgeable: false, + + .keyShowAperture: false, + .keyShowISO: false, + .keyShowFocalLength: false, + .keyShowCamera: false, + .keyShowShutterSpeed: false, + + .keyShowPath: false, + + // Important ordered + .keyShowItems: false, + .keyShowDuration: false, + .keyShowDimensions: false, + .keyShowSize: true, + ] + + /// Converts the ordered key value pair lists into a standard dictionary. + static let defaultSettings = [String: Any](Array(defaultSettingsOrdered), uniquingKeysWith: { first, _ in first }) + private static let defaultStates = [String: Any](Array(defaultStatesOrdered), uniquingKeysWith: { first, _ in first }) + + // ------------------- Initialize ---------------------- + + required init(appDelegate: AppDelegate) { + + // Initialize state fields + self.statePrivacyAccessibilityEnabled = AXIsProcessTrusted() + + super.init(appDelegate: appDelegate) + } + + // MARK: - Settings General Functions + + /// Takes in the new value and sets it in user defaults. + private func fieldWillSet(value: Any, key: String) { + UserDefaults.standard.setValue(value, forKey: key) + } + + /// Interacts with the app and makes changes neccessary based on the settings changed. + /// This gets called by a notifier so you can be sure this fn. will run whenever an update + /// to a user default gets changed. + private func fieldDidSet(key: String) { + + // These are settings that require explicit changes to the app + switch key { + + case .keyMenubarIcon: + appDelegate.interfaceController.updateIcon() + return + + case .keyPauseApp: + appDelegate.interfaceController.updatePause() + return + + default: + break + } + + // Otherwise update the interfaces + appDelegate.interfaceController.updateAllInterfaces(reset: true) + } + + /// Used to initialize each property + private static func fieldInit(type: PropertyType, key: String) -> T { + switch type { + case .String: + return UserDefaults.standard.string(forKey: key) as! T + + case .Bool: + return UserDefaults.standard.bool(forKey: key) as! T + + case .Display: + return DisplayController.Displays(rawValue: UserDefaults.standard.string(forKey: key) ?? DisplayController.Displays.StatusDisplay.rawValue) as! T + + case .UpdateFrequency: + return UpdateFrequency(rawValue: UserDefaults.standard.string(forKey: key) ?? UpdateFrequency.None.rawValue) as! T + + case .FramePosition: + return FramePosition(rawValue: UserDefaults.standard.string(forKey: key) ?? FramePosition.TopRight.rawValue) as! T + } + } + + // MARK: - Settings Utility Functions + + /// Registers the defaults. This should only be called once. + static func registerDefaults() { + + // Register state defaults + UserDefaults.standard.register(defaults: Self.defaultStates) + + // Register settings defaults + UserDefaults.standard.register(defaults: Self.defaultSettings) + } + + /// Applies all settings to the selection object. + func applySettings(_ selection: SelectionData) -> SelectionData? { + + // Grabs mutable copy of the selection + var selected = selection + + // Pull all settings in from user defaults + let allSettings = UserDefaults.standard.dictionaryRepresentation() + + // Iterate through and apply settings to the selection object + for (key, value) in allSettings { + + // Nils out properties that are turned off in settings + if let value = value as? Bool, value == false { + selected.data[key] = nil + } + } + + return selected + } +} diff --git a/Informant/Settings/SettingsController.swift b/Informant/Settings/SettingsController.swift new file mode 100644 index 0000000..1827898 --- /dev/null +++ b/Informant/Settings/SettingsController.swift @@ -0,0 +1,77 @@ +// +// SettingsController.swift +// Informant +// +// Created by Ty Irvine on 2022-03-04. +// + +import AppKit +import Foundation + +/// Responsible for reassigning the settings instance. +class SettingsController: Controller, ControllerProtocol, ObservableObject { + + @Published var settings: Settings + + required init(appDelegate: AppDelegate) { + + self.settings = Settings(appDelegate: appDelegate) + + super.init(appDelegate: appDelegate) + } + + // MARK: - Settings Management Functions + + /// Creates a new instance of the settings class and throws out the old one. + /// https://stackoverflow.com/a/55496821/13142325 + func resetDefaults() { + + let alert = AlertController.alertWarning( + title: ContentManager.Labels.alertWarningTitle, + message: ContentManager.Labels.alertResetSettingsMessage + ) + + switch alert { + + // Confirm + case .alertFirstButtonReturn: + + // Erases settings + UserDefaults.standard.setValuesForKeys(Settings.defaultSettings) + settings = Settings(appDelegate: appDelegate) + + // Confirmation + AlertController.alertOK( + title: ContentManager.Labels.alertResetSettingsSuccessTitle, + message: ContentManager.Labels.alertResetSettingsMessageFinished + ) + break + + // Cancel + case .alertSecondButtonReturn: + break + + default: + break + } + } + + // MARK: - Settings Utility Functions + + /// Opens up the system preferences to the panel we're looking for. + /// https://stackoverflow.com/a/59120311/13142325 + static func openSystemPrefsAccessibility() { + let pref = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")! + NSWorkspace.shared.open(pref) + } + + static func openSystemPrefsAutomation() { + let pref = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Automation")! + NSWorkspace.shared.open(pref) + } + + static func openSystemPrefsFullDiskAccess() { + let pref = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles")! + NSWorkspace.shared.open(pref) + } +} diff --git a/Informant/Style.swift b/Informant/Style.swift new file mode 100644 index 0000000..0b00e97 --- /dev/null +++ b/Informant/Style.swift @@ -0,0 +1,89 @@ +// +// Styling.swift +// Informant +// +// Created by Ty Irvine on 2021-04-22. +// + +import SwiftUI + +/// Contains all styling constants to be used across app +class Style { + public enum Text { + static let opacity = 0.5 + static let darkOpacity = 0.7 + static let fontSFCompact = "SFCompactDisplay-Regular" + static let fontSFMono = "SFMono-Regular" + } + + public enum Button { + static let labelButtonOpacity = 0.8 + } + + public enum Color { + static let backing = "Backing" + } + + public enum Icons { + static let appIconSize: CGFloat = 100 + } + + public enum Menu { + static let juicyImageHeight: CGFloat = 26 + static let juicyImageWidth: CGFloat = 26 + } + + public enum Animation { + static let standard = 0.15 + } +} + +extension Text { + + func H1(weight: Font.Weight = .regular, linelimit: Int? = nil) -> some View { + self.font(.system(size: 36)) + .fontWeight(weight) + .lineLimit(linelimit) + } + + func H2(weight: Font.Weight = .regular, linelimit: Int? = nil) -> some View { + self.font(.system(size: 28)) + .fontWeight(weight) + .lineLimit(linelimit) + } + + func H3X(weight: Font.Weight = .regular, linelimit: Int? = nil) -> some View { + self.font(.system(size: 21)) + .fontWeight(weight) + .lineLimit(linelimit) + } + + func H3(weight: Font.Weight = .regular, linelimit: Int? = nil) -> some View { + self.font(.system(size: 20)) + .fontWeight(weight) + .lineLimit(linelimit) + } + + func H4(weight: Font.Weight = .regular, linelimit: Int? = nil) -> some View { + self.font(.system(size: 17)) + .fontWeight(weight) + .lineLimit(linelimit) + } + + func Body(weight: Font.Weight = .regular, linelimit: Int? = nil, alignment: TextAlignment = .leading) -> some View { + self.font(.system(size: 14)) + .fontWeight(weight) + .lineLimit(linelimit) + .lineSpacing(4.0) + .multilineTextAlignment(alignment) + .fixedSize(horizontal: false, vertical: true) + } + + // MARK: Specialty Styles โคต๏ธŽ + + func floatDisplayActionFont() -> some View { + self.font(.system(size: 12)) + .fontWeight(.medium) + .opacity(0.5) + } +} diff --git a/Informant/Testing.swift b/Informant/Testing.swift new file mode 100644 index 0000000..eec0dea --- /dev/null +++ b/Informant/Testing.swift @@ -0,0 +1,36 @@ +// +// Testing.swift +// Informant +// +// Created by Ty Irvine on 2022-04-16. +// + +import Foundation + +class Testing { + + let appDelegate: AppDelegate + + internal init(appDelegate: AppDelegate) { + self.appDelegate = appDelegate + +// appDelegate.settingsWindowController.open() +// appDelegate.welcomeWindowController.open() +// appDelegate.aboutWindowController.open() +// appDelegate.accessibilityWindowController.open() +// appDelegate.displayController.updateDisplays() +// testSnapZones() + } + + // Test Snap Zone Positions + func testSnapZones() { + // Add new ones + for snapPoint in appDelegate.displayController.allSnapPoints { + let window = WindowController(appDelegate: appDelegate) + window.open() + let mid = snapPoint.point + let frame = window.window.frame + window.window.setFrameOrigin(NSPoint(x: mid.x - (frame.width / 2), y: mid.y - (frame.height / 2))) + } + } +} diff --git a/Informant/ViewController.swift b/Informant/ViewController.swift new file mode 100644 index 0000000..b191ef1 --- /dev/null +++ b/Informant/ViewController.swift @@ -0,0 +1,26 @@ +// +// ViewController.swift +// Informant +// +// Created by Ty Irvine on 2022-02-21. +// + +import Cocoa + +class ViewController: NSViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + // Do any additional setup after loading the view. + } + + override var representedObject: Any? { + didSet { + // Update the view, if already loaded. + } + } + + +} + diff --git a/Informant/Views/AboutView.swift b/Informant/Views/AboutView.swift new file mode 100644 index 0000000..384d459 --- /dev/null +++ b/Informant/Views/AboutView.swift @@ -0,0 +1,95 @@ +// +// AboutView.swift +// Informant +// +// Created by Ty Irvine on 2022-02-21. +// + +import SwiftUI + +struct AboutView: View { + + var version: String + + init() { + if let version = AppDelegate.version() { + self.version = version + } else { + self.version = "--" + } + } + + var body: some View { + RootView { + + // Used to apply a global padding + VStack(spacing: 2) { + + // Top header + VStack(spacing: 0) { + + // Image + Image(ContentManager.Images.appIconNoShadow) + .resizable() + .frame(width: 130, height: 130) + .offset(x: 0, y: 5) + + VStack(spacing: 4) { + + // Title + Text("Informant") + .H2(weight: .medium) + + // Version & Copyright + HStack(spacing: 11) { + Text("\(ContentManager.Labels.menubarShowVersion) \(version)") + Text("ยฉ Ty Irvine") + } + .opacity(0.5) + } + } + + Spacer() + .frame(height: 12) + + // Middle stack + VStack(alignment: .leading, spacing: 2) { + + // Acknowledgements + ComponentsPanelLabelButton { + LinkHelper.openLink(link: .linkAcknowledgements) + } content: { + ComponentsWindowLargeLink(label: ContentManager.Labels.acknowledgements, icon: "shippingbox", iconSize: 20) + } + + // Privacy + ComponentsPanelLabelButton { + LinkHelper.openLink(link: .linkPrivacyPolicy) + } content: { + ComponentsWindowLargeLink(label: ContentManager.Labels.privacyPolicy, icon: "rectangle.3.group.bubble.left", iconSize: 19) + } + + // Follow me on Twitter + ComponentsPanelLabelButton { + LinkHelper.openLink(link: .linkTwitter) + } content: { + ComponentsWindowLargeLink(label: ContentManager.Labels.twitter, icon: "twitter", iconSize: 20, usesSFSymbols: false) + } + + // Divider + Divider() + .padding([.vertical], 10) + + // Releases + ComponentsPanelLabelButton { + LinkHelper.openLink(link: .linkReleases) + } content: { + ComponentsWindowLargeLink(label: ContentManager.Labels.releases, icon: "laptopcomputer.and.arrow.down") + } + } + } + .padding([.horizontal], 20) + } + .frame(width: 280, height: 390, alignment: .center) + } +} diff --git a/Informant/Views/AccessibilityView.swift b/Informant/Views/AccessibilityView.swift new file mode 100644 index 0000000..368e96a --- /dev/null +++ b/Informant/Views/AccessibilityView.swift @@ -0,0 +1,194 @@ +// +// AccessibilityView.swift +// Informant +// +// Created by Ty Irvine on 2022-02-21. +// + +import SwiftUI + +struct AccessibilityView: View { + + let clickIcon = "๔€‡ฐ " + + var body: some View { + RootView { + + // Main stack + VStack(alignment: .center, spacing: 20) { + + // Header stack + VStack(spacing: 4) { + + // Image + Image(ContentManager.Images.appIconNoShadow) + .resizable() + .frame(width: 100, height: 100) + + VStack(spacing: 8) { + // Welcome: Authorize Informant + Text(ContentManager.Labels.authorizeInformant) + .H2(weight: .medium) + + // How to authorize + Text(ContentManager.Labels.authorizeNeedPermission) + .Body(alignment: .center) + } + } + + // Mid stack + VStack { + + // Top box + SecurityGuidanceBox(label: "๔€Ÿ " + ContentManager.Labels.authorizedInstructionSystemPreferencesLong, color: .gray) + + // Privacy boxes + SecurityGuidanceBox(icon: "๔€ฃ", iconSize: 17, label: ContentManager.Labels.authorizedInstructionCheckInformantLong, color: .blue) { + SettingsController.openSystemPrefsAccessibility() + } + + SecurityGuidanceBox(icon: "๔€ฃ‹", label: ContentManager.Labels.authorizedInstructionAutomationCheckFinder, color: .blue) { + SettingsController.openSystemPrefsAutomation() + } + + SecurityGuidanceBox(icon: "๔€ˆ•", iconSize: 15, label: ContentManager.Labels.authorizedInstructionCheckFullDiskAccess, color: .blue) { + SettingsController.openSystemPrefsFullDiskAccess() + } + + // Quit box + SecurityGuidanceBox(label: clickIcon + ContentManager.Labels.authorizedInstructionRestartInformant, color: .purple, arrow: false) { + NSApp.terminate(nil) + } + } + + // Bottom stack + VStack(spacing: 12) { + + Text(ContentManager.Labels.authorizedInstructionClickLock) + .Body(alignment: .center) + + HStack { + Image(systemName: ContentManager.Icons.authLockIcon) + .font(.system(size: 13, weight: .semibold)) + Image(systemName: ContentManager.Icons.rightArrowIcon) + .font(.system(size: 11, weight: .bold)) + .opacity(0.8) + Image(systemName: ContentManager.Icons.authUnlockIcon) + .font(.system(size: 13, weight: .semibold)) + } + .opacity(0.8) + } + .opacity(0.45) + } + + // Main padding + .padding([.horizontal], 20) + } + .frame(width: 335, height: 740, alignment: .center) + } +} + +/// This is the little blue box that shows where the user should navigate to +struct SecurityGuidanceBox: View { + + let icon: String + let iconSize: CGFloat + let label: String + let color: Color + let arrow: Bool + let action: (() -> Void)? + + private let radius: CGFloat = 10.0 + + internal init(icon: String = "", iconSize: CGFloat = 16, label: String, color: Color, arrow: Bool = true, action: (() -> Void)? = nil) { + self.icon = icon + self.iconSize = iconSize + self.label = label + self.color = color + self.arrow = arrow + self.action = action + } + + var body: some View { + VStack { + + // Label + if action == nil { + AuthInstructionBlurb(icon, iconSize, label, color, radius) + } else if let action = action { + AuthInstructionBlurb(icon, iconSize, label, color, radius, hover: true) + .onTapGesture { + action() + } + } + + // Arrow + if arrow { + Image(systemName: ContentManager.Icons.downArrowIcon) + .font(.system(size: 12, weight: .bold, design: .rounded)) + .opacity(0.15) + .padding([.vertical], 2) + } + } + } +} + +/// This is the actual text blurb used in the AuthAccessibilityView +struct AuthInstructionBlurb: View { + + let icon: String + let iconSize: CGFloat + let label: String + let color: Color + let hover: Bool + let radius: CGFloat + + internal init(_ icon: String, _ iconSize: CGFloat, _ label: String, _ color: Color, _ radius: CGFloat, hover: Bool = false) { + self.icon = icon + self.iconSize = iconSize + self.label = label + self.color = color + self.radius = radius + self.hover = hover + } + + @State private var isHovering = false + + var body: some View { + VStack(spacing: 0) { + + if icon != "" { + Text(icon) + .font(.system(size: iconSize)) + .padding([.bottom], 3) + } + + Text(label) + .font(.system(size: 14)) + .fontWeight(.semibold) + .lineSpacing(2) + } + .multilineTextAlignment(.center) + .foregroundColor(color) + .padding([.horizontal], 10) + .padding([.vertical], 6) + .background( + RoundedRectangle(cornerRadius: radius) + .fill(color) + .opacity(isHovering ? 0.25 : 0.1) + ) + .overlay( + RoundedRectangle(cornerRadius: radius) + .stroke(color, lineWidth: 1) + .opacity(0.2) + ) + .fixedSize(horizontal: false, vertical: true) + .onHover(perform: { hovering in + if hover { + isHovering = hovering + } + }) + + .animation(.easeInOut, value: isHovering) + } +} diff --git a/Informant/Views/Components.swift b/Informant/Views/Components.swift new file mode 100644 index 0000000..7cacc82 --- /dev/null +++ b/Informant/Views/Components.swift @@ -0,0 +1,180 @@ +// +// Components.swift +// Informant +// +// Created by Ty Irvine on 2022-03-02. +// + +import Foundation +import SwiftUI + +/// This is just a toggle with some padding between the toggle and title. +struct TogglePadded: View { + + let title: String + var binding: Binding + + internal init(_ title: String, isOn: Binding) { + self.title = title + self.binding = isOn + } + + var body: some View { + Toggle(isOn: binding) { + Text(title).togglePadding() + } + } +} + +/// This is a root view that provides the view with a frame size and material. +struct RootView: View { + + let content: Content + let material: NSVisualEffectView.Material + + internal init(material: NSVisualEffectView.Material = .sidebar, @ViewBuilder content: @escaping () -> Content) { + self.content = content() + self.material = material + } + + var body: some View { + HStack { + Spacer(minLength: 0) + VStack { + Spacer(minLength: 0) + content + Spacer(minLength: 0) + } + Spacer(minLength: 0) + } + .background(VisualEffectView(material: material)) + .edgesIgnoringSafeArea(.top) + } +} + +/// Arranges columns horizontally. +struct ComponentsSettingsToggleSection: View { + + let firstColumn: ContentFirst + let secondColumn: ContentSecond + let thirdColumn: ContentThird + let fourthColumn: ContentFourth + let label: String + + internal init( + _ label: String, + @ViewBuilder firstColumn: @escaping () -> ContentFirst, + @ViewBuilder secondColumn: @escaping () -> ContentSecond, + @ViewBuilder thirdColumn: @escaping () -> ContentThird, + @ViewBuilder fourthColumn: @escaping () -> ContentFourth + ) { + self.firstColumn = firstColumn() + self.secondColumn = secondColumn() + self.thirdColumn = thirdColumn() + self.fourthColumn = fourthColumn() + self.label = label + } + + var body: some View { + + VStack(alignment: .leading, spacing: 8) { + + Text(label) + .H4(weight: .medium) + + HStack(alignment: .top, spacing: 17) { + + VStack(alignment: .leading) { + firstColumn + } + + VStack(alignment: .leading) { + secondColumn + } + + VStack(alignment: .leading) { + thirdColumn + } + + VStack(alignment: .leading) { + fourthColumn + } + } + } + } +} + +/// Settings window button +struct ComponentsWindowLargeLink: View { + + let label: String + let icon: String + let iconSize: CGFloat + let color: Color + let usesSFSymbols: Bool + + internal init(label: String, icon: String, iconSize: CGFloat = 18, color: Color = .blue, usesSFSymbols: Bool = true) { + self.label = label + self.icon = icon + self.iconSize = iconSize + self.color = color + self.usesSFSymbols = usesSFSymbols + } + + var body: some View { + HStack(alignment: .center) { + + if usesSFSymbols { + Image(systemName: icon) + .font(.system(size: iconSize, weight: .semibold)) + .frame(width: 24, height: 20) + } else { + Image(icon) + .resizable() + .frame(width: 24, height: 24) + } + + Text(label).H3X(weight: .medium) + } + .foregroundColor(color) + } +} + +/// Shows a label on hover behind the content. When clicked an action is performed. Typically this is used with text objects. +struct ComponentsPanelLabelButton: View { + + let content: Content + var action: () -> Void + + internal init(action: @escaping () -> Void, @ViewBuilder content: @escaping () -> Content) { + self.content = content() + self.action = action + } + + @State private var isHovering = false + + var body: some View { + Button { + action() + } label: { + ZStack(alignment: .leading) { + + // Backing + Color.blue + .cornerRadius(8.0) + .opacity(isHovering ? 0.1 : 0) + .animation(.easeInOut(duration: Style.Animation.standard)) + + // Label + content + .padding([.horizontal], 8) + .padding([.vertical], 6) + } + } + .fixedSize() + .buttonStyle(PlainButtonStyle()) + .onHover { hovering in + isHovering = hovering + } + } +} diff --git a/Informant/Views/Material.swift b/Informant/Views/Material.swift new file mode 100644 index 0000000..5696069 --- /dev/null +++ b/Informant/Views/Material.swift @@ -0,0 +1,30 @@ +// +// ComponentsExtra.swift +// Informant +// +// Created by Ty Irvine on 2021-06-15. +// + +import SwiftUI + +/// Creates a blurred background effect for the main interface. Allows it to be called in SWiftUI. +struct VisualEffectView: NSViewRepresentable { + + let material: NSVisualEffectView.Material + + func makeNSView(context: Context) -> NSVisualEffectView { + + let visualEffectView = NSVisualEffectView() + visualEffectView.translatesAutoresizingMaskIntoConstraints = false + visualEffectView.material = material + visualEffectView.state = NSVisualEffectView.State.active + visualEffectView.blendingMode = .behindWindow + visualEffectView.isEmphasized = true + + return visualEffectView + } + + func updateNSView(_ visualEffectView: NSVisualEffectView, context: Context) { + visualEffectView.material = material + } +} diff --git a/Informant/Views/PaymentView.swift b/Informant/Views/PaymentView.swift new file mode 100644 index 0000000..c7e00bb --- /dev/null +++ b/Informant/Views/PaymentView.swift @@ -0,0 +1,15 @@ +// +// PaymentView.swift +// Informant +// +// Created by Ty Irvine on 2022-02-21. +// + +import SwiftUI + +struct PaymentView: View { + var body: some View { + Text("Payment") + .frame(width: 400, height: 400, alignment: .center) + } +} diff --git a/Informant/Views/SettingsView.swift b/Informant/Views/SettingsView.swift new file mode 100644 index 0000000..a3ae3c2 --- /dev/null +++ b/Informant/Views/SettingsView.swift @@ -0,0 +1,377 @@ +// +// SettingsView.swift +// Informant +// +// Created by Ty Irvine on 2022-02-21. +// + +import LaunchAtLogin +import SwiftUI + +struct SettingsView: View { + + @ObservedObject var settingsController: SettingsController + + init() { + self.settingsController = AppDelegate.current().settingsController + } + + var body: some View { + + // Main view group + RootView { + HStack { + + Spacer() + + // Left side + SettingsLeftView() + + Spacer() + + // Right side + SettingsRightView(settingsController: settingsController) + } + .padding([.trailing], 20) + .padding([.vertical], 20) + } + .frame(width: 715, height: 425) + } +} + +struct SettingsLeftView: View { + + var version: String? + + init() { + if let version = AppDelegate.version() { + self.version = version + } + } + + var body: some View { + Group { + + // Main stack + VStack(spacing: 15) { + + // Top stack + VStack(spacing: 0) { + Image(ContentManager.Images.appIconNoShadow) + .resizable() + .frame(width: 135, height: 135) + + Text("Informant") + .H2(weight: .medium) + + Spacer() + .frame(height: 1) + + if let version = version { + Text("\(ContentManager.Labels.panelVersion) \(version)") + .opacity(0.5) + } + } + + // Bottom stack + VStack(alignment: .leading) { + + // Feedback + ComponentsPanelLabelButton { + LinkHelper.openLink(link: .linkFeedback) + } content: { + ComponentsWindowLargeLink(label: ContentManager.Labels.feedback, icon: "megaphone") + } + + // License + ComponentsPanelLabelButton { + LinkHelper.openLink(link: .linkDonate) + } content: { + ComponentsWindowLargeLink(label: ContentManager.Labels.donate, icon: "arrow.up.heart", iconSize: 20) + } + + // About + ComponentsPanelLabelButton { + AppDelegate.current().interfaceController.openAboutWindow() + } content: { + ComponentsWindowLargeLink(label: ContentManager.Labels.about, icon: "menucard", iconSize: 19) + } + + // Help + ComponentsPanelLabelButton { + LinkHelper.openLink(link: .linkHelp) + } content: { + ComponentsWindowLargeLink(label: ContentManager.Labels.help, icon: "bubble.left.and.exclamationmark.bubble.right") + } + } + } + } + } +} + +struct SettingsRightView: View { + + @ObservedObject var settingsController: SettingsController + + let cornerRadius: CGFloat = 10 + + var body: some View { + + ScrollView(.vertical, showsIndicators: true) { + SettingsItems(settingsController: settingsController) + .padding([.horizontal], 36) + .padding([.vertical], 22) + } + + // Clips the content to fit in the scroll view + .clipped() + + // Makes sure to clip the scroll indicator + .cornerRadius(cornerRadius) + + // The padding below offsets the stroke so aligned outside the view, not in the centre + .background( + RoundedRectangle(cornerRadius: cornerRadius) + .fill(Color(Style.Color.backing)) + .opacity(0.3) + ) + + .padding([.all], 0.5) + + .overlay( + RoundedRectangle(cornerRadius: cornerRadius) + .stroke(Color.gray) + .opacity(0.4) + ) + } +} + +struct SettingsItems: View { + + @ObservedObject var settingsController: SettingsController + + var body: some View { + + // Panel and system preferences + VStack(alignment: .leading, spacing: 0) { + + // MARK: Display + // For determining fixed size + Group { + + // Menu label + Text(ContentManager.Labels.details) + .H3(weight: .semibold) + .padding([.bottom], 8) + + // Display details in + Picker(ContentManager.Labels.displayDetailsIn, selection: $settingsController.settings.settingChosenDisplay) { + + Text(ContentManager.Labels.displayMenubar) + .tag(DisplayController.Displays.StatusDisplay) + + Text(ContentManager.Labels.displayFloat) + .tag(DisplayController.Displays.FloatDisplay) + } + .pickerStyle(SegmentedPickerStyle()) + .frame(width: 250) + + // Picker spacer + Spacer() + .frame(height: 13) + + // Menu bar icon + Picker(ContentManager.Labels.menubarIconDesc, selection: $settingsController.settings.settingMenubarIcon) { + + Text(ContentManager.Icons.noIcon) + .tag(ContentManager.Icons.menubarBlank) + + Image(ContentManager.Icons.menubarDefault + "-picker") + .tag(ContentManager.Icons.menubarDefault) + + Image(ContentManager.Icons.menubarDark + "-picker") + .tag(ContentManager.Icons.menubarDark) + + Image(ContentManager.Icons.menubarSquare + "-picker") + .tag(ContentManager.Icons.menubarSquare) + } + .pickerStyle(MenuPickerStyle()) + .frame(width: 130) + + // Picker spacer + Spacer() + .frame(height: 16) + + VStack(alignment: .leading, spacing: 21) { + + // MARK: - General + + ComponentsSettingsToggleSection(ContentManager.Labels.menubarSectionsGeneral) { + + TogglePadded(ContentManager.Labels.menubarShowSize, isOn: $settingsController.settings.settingShowSize) + + TogglePadded(ContentManager.Labels.menubarShowKind, isOn: $settingsController.settings.settingShowKind) + + } secondColumn: { + + TogglePadded(ContentManager.Labels.menubarShowName, isOn: $settingsController.settings.settingShowName) + + TogglePadded(ContentManager.Labels.menubarShowPath, isOn: $settingsController.settings.settingShowPath) + + } thirdColumn: { + + TogglePadded(ContentManager.Labels.menubarShowCreated, isOn: $settingsController.settings.settingShowCreated) + + TogglePadded(ContentManager.Labels.menubarShowEdited, isOn: $settingsController.settings.settingShowModified) + + } fourthColumn: { + + TogglePadded(ContentManager.Labels.menubarShowVersion, isOn: $settingsController.settings.settingShowVersion) + + TogglePadded(ContentManager.Labels.menubarShowItems, isOn: $settingsController.settings.settingShowItems) + } + + // MARK: - Images + + ComponentsSettingsToggleSection(ContentManager.Labels.menubarSectionsImages) { + + TogglePadded(ContentManager.Labels.menubarShowAperture, isOn: $settingsController.settings.settingShowAperture) + + TogglePadded(ContentManager.Labels.menubarShowCamera, isOn: $settingsController.settings.settingShowCamera) + + } secondColumn: { + + TogglePadded(ContentManager.Labels.menubarShowShutterspeed, isOn: $settingsController.settings.settingShowShutterSpeed) + + TogglePadded(ContentManager.Labels.menubarShowFocalLength, isOn: $settingsController.settings.settingShowFocalLength) + + } thirdColumn: { + + TogglePadded(ContentManager.Labels.menubarShowColorGamut, isOn: $settingsController.settings.settingShowColorGamut) + + TogglePadded(ContentManager.Labels.menubarShowISO, isOn: $settingsController.settings.settingShowISO) + + } fourthColumn: { + } + + // MARK: - Media + + ComponentsSettingsToggleSection(ContentManager.Labels.menubarSectionsMedia) { + + TogglePadded(ContentManager.Labels.menubarShowDimensions, isOn: $settingsController.settings.settingShowDimensions) + + TogglePadded(ContentManager.Labels.menubarShowDuration, isOn: $settingsController.settings.settingShowDuration) + + } secondColumn: { + + TogglePadded(ContentManager.Labels.menubarShowSampleRate, isOn: $settingsController.settings.settingShowSampleRate) + + TogglePadded(ContentManager.Labels.menubarShowColorProfile, isOn: $settingsController.settings.settingShowColorProfile) + + } thirdColumn: { + + TogglePadded(ContentManager.Labels.menubarShowCodecs, isOn: $settingsController.settings.settingShowCodecs) + + TogglePadded(ContentManager.Labels.menubarShowBitrate, isOn: $settingsController.settings.settingShowTotalBitrate) + + } fourthColumn: { + } + + // MARK: - Volume + + ComponentsSettingsToggleSection(ContentManager.Labels.menubarSectionsVolume) { + + TogglePadded(ContentManager.Labels.menubarShowVolumeTotal, isOn: $settingsController.settings.settingShowVolumeTotal) + + } secondColumn: { + + TogglePadded(ContentManager.Labels.menubarShowVolumeAvailable, isOn: $settingsController.settings.settingShowVolumeAvailable) + + } thirdColumn: { + + TogglePadded(ContentManager.Labels.menubarShowVolumePurgeable, isOn: $settingsController.settings.settingShowVolumePurgeable) + + } fourthColumn: { + } + + // MARK: - Extra + + ComponentsSettingsToggleSection(ContentManager.Labels.menubarShowExtra) { + + TogglePadded(ContentManager.Labels.menubarShowiCloudContainer, isOn: $settingsController.settings.settingShowiCloudContainerName) + + } secondColumn: { + + } thirdColumn: { + + } fourthColumn: { + } + } + } + .fixedSize() + + // Space sections + Spacer() + .frame(height: 20) + + // MARK: System + // For determining fixed size + Group { + + // System label + Text(ContentManager.Labels.system) + .H3(weight: .semibold) + .padding([.bottom], 8) + + VStack(alignment: .leading, spacing: 12) { + + // Hide when using apps besides Finder + TogglePadded(ContentManager.Labels.hideWhenUsingOtherApps, isOn: $settingsController.settings.settingHideWhenViewingOtherApps) + + // Skips the sizing of directories all together + TogglePadded(ContentManager.Labels.skipDirectories, isOn: $settingsController.settings.settingSkipGettingSizeForDirectories) + + // Launch informant on system startup + LaunchAtLogin.Toggle { + Text(ContentManager.Labels.launchOnStartup).togglePadding() + } + + // Reset all settings button + Button { + AppDelegate.current().settingsController.resetDefaults() + } label: { + Text(ContentManager.Labels.settingsResetButton) + } + .padding([.top], 2) + + // Separates system from update settings + Divider() + .padding([.vertical], 8) + + // Check for updates button + Button { + AppDelegate.current().updateController.checkForUpdates() + } label: { + Text(ContentManager.Labels.checkForUpdates) + } + .padding([.bottom], 4) + + // Update frequency + Picker(ContentManager.Labels.updateFrequency, selection: $settingsController.settings.settingUpdateFrequency) { + + Text(ContentManager.Labels.updateAutoUpdate) + .padding([.bottom], 4) + .tag(UpdateFrequency.AutoUpdate) + + Text(ContentManager.Labels.updateDontDoAnything) + .padding([.bottom], 4) + .tag(UpdateFrequency.None) + } + .pickerStyle(RadioGroupPickerStyle()) + .frame(width: 350, alignment: .leading) + } + } + .fixedSize() + } + } +} diff --git a/Informant/Views/TestView.swift b/Informant/Views/TestView.swift new file mode 100644 index 0000000..9ae7575 --- /dev/null +++ b/Informant/Views/TestView.swift @@ -0,0 +1,16 @@ +// +// TestView.swift +// Informant +// +// Created by Ty Irvine on 2022-04-14. +// + +import SwiftUI + +struct TestView: View { + var body: some View { + Text("โŒพ") + .font(.system(size: 16)) + .frame(width: 30, height: 30) + } +} diff --git a/Informant/Views/WelcomeView.swift b/Informant/Views/WelcomeView.swift new file mode 100644 index 0000000..e51dc8d --- /dev/null +++ b/Informant/Views/WelcomeView.swift @@ -0,0 +1,17 @@ +// +// WelcomeView.swift +// Informant +// +// Created by Ty Irvine on 2022-02-21. +// + +import SwiftUI + +struct WelcomeView: View { + var body: some View { + RootView { + Text("Welcome") + } + .frame(width: 400, height: 400, alignment: .center) + } +}