diff --git a/Gas Mask.xcodeproj/project.pbxproj b/Gas Mask.xcodeproj/project.pbxproj index 5bfba76..3b1ff49 100644 --- a/Gas Mask.xcodeproj/project.pbxproj +++ b/Gas Mask.xcodeproj/project.pbxproj @@ -19,8 +19,6 @@ 351167C010D6346000A5FAA1 /* HostsGroup.m in Sources */ = {isa = PBXBuildFile; fileRef = 351167BF10D6346000A5FAA1 /* HostsGroup.m */; }; 3511697810D68B1000A5FAA1 /* FilesCountTransformer.m in Sources */ = {isa = PBXBuildFile; fileRef = 3511697710D68B1000A5FAA1 /* FilesCountTransformer.m */; }; 35116A7010D6AAD200A5FAA1 /* Menulet.m in Sources */ = {isa = PBXBuildFile; fileRef = 35116A6F10D6AAD200A5FAA1 /* Menulet.m */; }; - 35116AA010D6ADF000A5FAA1 /* Preferences.xib in Resources */ = {isa = PBXBuildFile; fileRef = 35116A9F10D6ADF000A5FAA1 /* Preferences.xib */; }; - 35116AAD10D6AE5600A5FAA1 /* PreferenceController.m in Sources */ = {isa = PBXBuildFile; fileRef = 35116AAC10D6AE5600A5FAA1 /* PreferenceController.m */; }; 35116AC010D6AF0500A5FAA1 /* LoginItem.m in Sources */ = {isa = PBXBuildFile; fileRef = 35116ABF10D6AF0500A5FAA1 /* LoginItem.m */; }; 35116ADA10D6B00200A5FAA1 /* Hotkey.m in Sources */ = {isa = PBXBuildFile; fileRef = 35116AD910D6B00200A5FAA1 /* Hotkey.m */; }; 3513A600113908A900AD789D /* Read Only.png in Resources */ = {isa = PBXBuildFile; fileRef = 3513A5FF113908A900AD789D /* Read Only.png */; }; @@ -73,7 +71,6 @@ 354E7F5E10AEFA9D00FC4757 /* PrivilegedActions.m in Sources */ = {isa = PBXBuildFile; fileRef = 354E7F5D10AEFA9D00FC4757 /* PrivilegedActions.m */; }; 354E7F8110AEFBC500FC4757 /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 354E7F8010AEFBC500FC4757 /* Security.framework */; }; 354E80B510AF383C00FC4757 /* FileUtil.m in Sources */ = {isa = PBXBuildFile; fileRef = 354E80B410AF383C00FC4757 /* FileUtil.m */; }; - 3556CEF910D6B70100C7301E /* UpdateDateTransformer.m in Sources */ = {isa = PBXBuildFile; fileRef = 3556CEF810D6B70100C7301E /* UpdateDateTransformer.m */; }; 355F5C5B12244B67006C2884 /* LocalHostsController.m in Sources */ = {isa = PBXBuildFile; fileRef = 355F5C5A12244B67006C2884 /* LocalHostsController.m */; }; 355F5CA512244F28006C2884 /* RemoteHostsController.m in Sources */ = {isa = PBXBuildFile; fileRef = 355F5CA412244F28006C2884 /* RemoteHostsController.m */; }; 355F5CDF122452BE006C2884 /* AbstractHostsController.m in Sources */ = {isa = PBXBuildFile; fileRef = 355F5CDE122452BE006C2884 /* AbstractHostsController.m */; }; @@ -103,10 +100,6 @@ 3597135D110DED0F00C7ECAF /* HostsMenu.m in Sources */ = {isa = PBXBuildFile; fileRef = 3597135C110DED0F00C7ECAF /* HostsMenu.m */; }; 359967541656B52500BCF16D /* NotificationHelper.m in Sources */ = {isa = PBXBuildFile; fileRef = 359967531656B52500BCF16D /* NotificationHelper.m */; }; 35A4CD301534927F005176BD /* Combined Hosts Hint.png in Resources */ = {isa = PBXBuildFile; fileRef = 35A4CD2F1534927F005176BD /* Combined Hosts Hint.png */; }; - 35B049921A46234100EB89CA /* Editor.png in Resources */ = {isa = PBXBuildFile; fileRef = 35B0498E1A46234100EB89CA /* Editor.png */; }; - 35B049931A46234100EB89CA /* Hotkeys.png in Resources */ = {isa = PBXBuildFile; fileRef = 35B0498F1A46234100EB89CA /* Hotkeys.png */; }; - 35B049941A46234100EB89CA /* Remote.png in Resources */ = {isa = PBXBuildFile; fileRef = 35B049901A46234100EB89CA /* Remote.png */; }; - 35B049951A46234100EB89CA /* Update.png in Resources */ = {isa = PBXBuildFile; fileRef = 35B049911A46234100EB89CA /* Update.png */; }; 35B0499B1A462AE900EB89CA /* Activated@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 35B0499A1A462AE900EB89CA /* Activated@2x.png */; }; 35B0499D1A462BC500EB89CA /* Blue Dot@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 35B0499C1A462BC500EB89CA /* Blue Dot@2x.png */; }; 35B36A641263B25A005F6A66 /* DebugUtil.m in Sources */ = {isa = PBXBuildFile; fileRef = 35B36A631263B25A005F6A66 /* DebugUtil.m */; }; @@ -145,6 +138,15 @@ AA00000C000000000000AAAA /* URLValidatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA00000B000000000000AAAA /* URLValidatorTests.swift */; }; AA00000E000000000000AAAA /* URLSheetPresenterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA00000D000000000000AAAA /* URLSheetPresenterTests.swift */; }; AA000010000000000000AAAA /* URLSheetViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA00000F000000000000AAAA /* URLSheetViewTests.swift */; }; + BB000002000000000000BBBB /* RemoteIntervalMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB000001000000000000BBBB /* RemoteIntervalMapper.swift */; }; + BB000004000000000000BBBB /* ShortcutRecorderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB000003000000000000BBBB /* ShortcutRecorderView.swift */; }; + BB000006000000000000BBBB /* SparkleObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB000005000000000000BBBB /* SparkleObserver.swift */; }; + BB000008000000000000BBBB /* LoginItemObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB000007000000000000BBBB /* LoginItemObserver.swift */; }; + BB00000A000000000000BBBB /* PreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB000009000000000000BBBB /* PreferencesView.swift */; }; + BB00000C000000000000BBBB /* PreferencesPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB00000B000000000000BBBB /* PreferencesPresenter.swift */; }; + BB00000E000000000000BBBB /* RemoteIntervalMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB00000D000000000000BBBB /* RemoteIntervalMapperTests.swift */; }; + BB000010000000000000BBBB /* SparkleObserverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB00000F000000000000BBBB /* SparkleObserverTests.swift */; }; + BB000012000000000000BBBB /* PreferencesPresenterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB000011000000000000BBBB /* PreferencesPresenterTests.swift */; }; /* Begin PBXContainerItemProxy section */ 353D18A01114C067005C4E54 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; @@ -203,9 +205,6 @@ 3511697710D68B1000A5FAA1 /* FilesCountTransformer.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = FilesCountTransformer.m; path = Source/FilesCountTransformer.m; sourceTree = ""; }; 35116A6E10D6AAD200A5FAA1 /* Menulet.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = Menulet.h; path = Source/Menulet.h; sourceTree = ""; }; 35116A6F10D6AAD200A5FAA1 /* Menulet.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = Menulet.m; path = Source/Menulet.m; sourceTree = ""; }; - 35116A9F10D6ADF000A5FAA1 /* Preferences.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = Preferences.xib; sourceTree = ""; }; - 35116AAB10D6AE5600A5FAA1 /* PreferenceController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = PreferenceController.h; path = Source/PreferenceController.h; sourceTree = ""; }; - 35116AAC10D6AE5600A5FAA1 /* PreferenceController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = PreferenceController.m; path = Source/PreferenceController.m; sourceTree = ""; }; 35116ABE10D6AF0500A5FAA1 /* LoginItem.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = LoginItem.h; path = Source/LoginItem.h; sourceTree = ""; }; 35116ABF10D6AF0500A5FAA1 /* LoginItem.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = LoginItem.m; path = Source/LoginItem.m; sourceTree = ""; }; 35116AD810D6B00200A5FAA1 /* Hotkey.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = Hotkey.h; path = Source/Hotkey.h; sourceTree = ""; }; @@ -283,8 +282,6 @@ 354E7F8010AEFBC500FC4757 /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = /System/Library/Frameworks/Security.framework; sourceTree = ""; }; 354E80B310AF383C00FC4757 /* FileUtil.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = FileUtil.h; path = Source/FileUtil.h; sourceTree = ""; }; 354E80B410AF383C00FC4757 /* FileUtil.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = FileUtil.m; path = Source/FileUtil.m; sourceTree = ""; }; - 3556CEF710D6B70100C7301E /* UpdateDateTransformer.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = UpdateDateTransformer.h; path = Source/UpdateDateTransformer.h; sourceTree = ""; }; - 3556CEF810D6B70100C7301E /* UpdateDateTransformer.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = UpdateDateTransformer.m; path = Source/UpdateDateTransformer.m; sourceTree = ""; }; 355F5C5912244B67006C2884 /* LocalHostsController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = LocalHostsController.h; path = Source/LocalHostsController.h; sourceTree = ""; }; 355F5C5A12244B67006C2884 /* LocalHostsController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = LocalHostsController.m; path = Source/LocalHostsController.m; sourceTree = ""; }; 355F5CA312244F28006C2884 /* RemoteHostsController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = RemoteHostsController.h; path = Source/RemoteHostsController.h; sourceTree = ""; }; @@ -333,11 +330,16 @@ AA00000B000000000000AAAA /* URLValidatorTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = URLValidatorTests.swift; sourceTree = ""; }; AA00000D000000000000AAAA /* URLSheetPresenterTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = URLSheetPresenterTests.swift; sourceTree = ""; }; AA00000F000000000000AAAA /* URLSheetViewTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = URLSheetViewTests.swift; sourceTree = ""; }; + BB000001000000000000BBBB /* RemoteIntervalMapper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = RemoteIntervalMapper.swift; path = "Source/Swift/RemoteIntervalMapper.swift"; sourceTree = ""; }; + BB000003000000000000BBBB /* ShortcutRecorderView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = ShortcutRecorderView.swift; path = "Source/Swift/ShortcutRecorderView.swift"; sourceTree = ""; }; + BB000005000000000000BBBB /* SparkleObserver.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = SparkleObserver.swift; path = "Source/Swift/SparkleObserver.swift"; sourceTree = ""; }; + BB000007000000000000BBBB /* LoginItemObserver.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = LoginItemObserver.swift; path = "Source/Swift/LoginItemObserver.swift"; sourceTree = ""; }; + BB000009000000000000BBBB /* PreferencesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = PreferencesView.swift; path = "Source/Swift/PreferencesView.swift"; sourceTree = ""; }; + BB00000B000000000000BBBB /* PreferencesPresenter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = PreferencesPresenter.swift; path = "Source/Swift/PreferencesPresenter.swift"; sourceTree = ""; }; + BB00000D000000000000BBBB /* RemoteIntervalMapperTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteIntervalMapperTests.swift; sourceTree = ""; }; + BB00000F000000000000BBBB /* SparkleObserverTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SparkleObserverTests.swift; sourceTree = ""; }; + BB000011000000000000BBBB /* PreferencesPresenterTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PreferencesPresenterTests.swift; sourceTree = ""; }; 35A4CD2F1534927F005176BD /* Combined Hosts Hint.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = "Combined Hosts Hint.png"; path = "Resources/Images/Combined Hosts Hint.png"; sourceTree = ""; }; - 35B0498E1A46234100EB89CA /* Editor.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = Editor.png; path = Resources/Images/Preferences/Editor.png; sourceTree = ""; }; - 35B0498F1A46234100EB89CA /* Hotkeys.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = Hotkeys.png; path = Resources/Images/Preferences/Hotkeys.png; sourceTree = ""; }; - 35B049901A46234100EB89CA /* Remote.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = Remote.png; path = Resources/Images/Preferences/Remote.png; sourceTree = ""; }; - 35B049911A46234100EB89CA /* Update.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = Update.png; path = Resources/Images/Preferences/Update.png; sourceTree = ""; }; 35B0499A1A462AE900EB89CA /* Activated@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = "Activated@2x.png"; path = "Resources/Images/Activated@2x.png"; sourceTree = ""; }; 35B0499C1A462BC500EB89CA /* Blue Dot@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = "Blue Dot@2x.png"; path = "Resources/Images/Blue Dot@2x.png"; sourceTree = ""; }; 35B36A621263B25A005F6A66 /* DebugUtil.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = DebugUtil.h; path = Source/DebugUtil.h; sourceTree = ""; }; @@ -562,8 +564,6 @@ 3556CEF410D6B6F300C7301E /* GUI */, 353A80B910B01C050005CAD1 /* Preferences.h */, 353A80BA10B01C050005CAD1 /* Preferences.m */, - 35116AAB10D6AE5600A5FAA1 /* PreferenceController.h */, - 35116AAC10D6AE5600A5FAA1 /* PreferenceController.m */, ); name = Preferences; sourceTree = ""; @@ -668,7 +668,6 @@ 3545DC8E10E38F1800EBA66D /* Images */ = { isa = PBXGroup; children = ( - 35B0498D1A46233600EB89CA /* Preferences */, 35A4CD2F1534927F005176BD /* Combined Hosts Hint.png */, 358C36E31A0D6F2E00161C98 /* Combined_File_yosemite.tiff */, 358C36E71A0D6FCC00161C98 /* Combined_File_yosemite@2x.tiff */, @@ -707,9 +706,6 @@ 3556CEF410D6B6F300C7301E /* GUI */ = { isa = PBXGroup; children = ( - 35116A9F10D6ADF000A5FAA1 /* Preferences.xib */, - 3556CEF710D6B70100C7301E /* UpdateDateTransformer.h */, - 3556CEF810D6B70100C7301E /* UpdateDateTransformer.m */, ); name = GUI; sourceTree = ""; @@ -771,17 +767,20 @@ AA000005000000000000AAAA /* NetworkStatusObserver.swift */, AA000007000000000000AAAA /* URLSheetView.swift */, AA000009000000000000AAAA /* URLSheetPresenter.swift */, + BB000020000000000000BBBB /* Preferences */, ); name = Swift; sourceTree = ""; }; - 35B0498D1A46233600EB89CA /* Preferences */ = { + BB000020000000000000BBBB /* Preferences */ = { isa = PBXGroup; children = ( - 35B0498E1A46234100EB89CA /* Editor.png */, - 35B0498F1A46234100EB89CA /* Hotkeys.png */, - 35B049901A46234100EB89CA /* Remote.png */, - 35B049911A46234100EB89CA /* Update.png */, + BB000009000000000000BBBB /* PreferencesView.swift */, + BB00000B000000000000BBBB /* PreferencesPresenter.swift */, + BB000001000000000000BBBB /* RemoteIntervalMapper.swift */, + BB000003000000000000BBBB /* ShortcutRecorderView.swift */, + BB000005000000000000BBBB /* SparkleObserver.swift */, + BB000007000000000000BBBB /* LoginItemObserver.swift */, ); name = Preferences; sourceTree = ""; @@ -862,6 +861,9 @@ AA00000B000000000000AAAA /* URLValidatorTests.swift */, AA00000D000000000000AAAA /* URLSheetPresenterTests.swift */, AA00000F000000000000AAAA /* URLSheetViewTests.swift */, + BB00000D000000000000BBBB /* RemoteIntervalMapperTests.swift */, + BB00000F000000000000BBBB /* SparkleObserverTests.swift */, + BB000011000000000000BBBB /* PreferencesPresenterTests.swift */, CC2B3C4D5E6F000100000010 /* NodeTests.m */, CC2B3C4D5E6F000100000011 /* HostsTests.m */, CC2B3C4D5E6F000100000012 /* HostsGroupTests.m */, @@ -990,8 +992,6 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 35B049921A46234100EB89CA /* Editor.png in Resources */, - 35B049931A46234100EB89CA /* Hotkeys.png in Resources */, 3579F1181114B95300123416 /* Launcher.app in Resources */, 8D11072B0486CEB800E47090 /* InfoPlist.strings in Resources */, 1DDD58160DA1D0A300B32029 /* MainMenu.xib in Resources */, @@ -999,7 +999,6 @@ 352E04FD106281940071E25B /* Create.png in Resources */, 352E04FF106281940071E25B /* Save.png in Resources */, 358E0A891A3A1E1A004521D1 /* Remote yosemite@2x.tiff in Resources */, - 35B049941A46234100EB89CA /* Remote.png in Resources */, 352E05B610628D790071E25B /* Activated.png in Resources */, 350C7F3B1A3C56BB00B46B09 /* Read Only@2x.png in Resources */, 352E05B710628D790071E25B /* Blue Dot.png in Resources */, @@ -1007,7 +1006,6 @@ 350C7F391A3C55B000B46B09 /* Create@2x.png in Resources */, 353A80FC10B020B10005CAD1 /* UserDefaults.plist in Resources */, 358E0A881A3A1E1A004521D1 /* Remote yosemite.tiff in Resources */, - 35116AA010D6ADF000A5FAA1 /* Preferences.xib in Resources */, 35D83BEF20DD97CF00169358 /* Assets.xcassets in Resources */, 352F48D210DA90FE000003BE /* default.hst in Resources */, 3545DC7310E38DDE00EBA66D /* Offline.png in Resources */, @@ -1037,7 +1035,6 @@ 35A4CD301534927F005176BD /* Combined Hosts Hint.png in Resources */, 359289AB1659222E00492494 /* CHANGELOG.txt in Resources */, 358E0A841A3A1C54004521D1 /* Local File yosemite.tiff in Resources */, - 35B049951A46234100EB89CA /* Update.png in Resources */, 358C36E61A0D6F2E00161C98 /* Combined_File.png in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1082,10 +1079,8 @@ 351167C010D6346000A5FAA1 /* HostsGroup.m in Sources */, 3511697810D68B1000A5FAA1 /* FilesCountTransformer.m in Sources */, 35116A7010D6AAD200A5FAA1 /* Menulet.m in Sources */, - 35116AAD10D6AE5600A5FAA1 /* PreferenceController.m in Sources */, 35116AC010D6AF0500A5FAA1 /* LoginItem.m in Sources */, 35116ADA10D6B00200A5FAA1 /* Hotkey.m in Sources */, - 3556CEF910D6B70100C7301E /* UpdateDateTransformer.m in Sources */, 358448B310DFE283002E6A9B /* Util.m in Sources */, 3545DCA910E396CF00EBA66D /* Network.m in Sources */, 3541CE0510E4AF9B00FA00CB /* SyncingArrowsBadge.m in Sources */, @@ -1106,6 +1101,12 @@ AA000006000000000000AAAA /* NetworkStatusObserver.swift in Sources */, AA000008000000000000AAAA /* URLSheetView.swift in Sources */, AA00000A000000000000AAAA /* URLSheetPresenter.swift in Sources */, + BB000002000000000000BBBB /* RemoteIntervalMapper.swift in Sources */, + BB000004000000000000BBBB /* ShortcutRecorderView.swift in Sources */, + BB000006000000000000BBBB /* SparkleObserver.swift in Sources */, + BB000008000000000000BBBB /* LoginItemObserver.swift in Sources */, + BB00000A000000000000BBBB /* PreferencesView.swift in Sources */, + BB00000C000000000000BBBB /* PreferencesPresenter.swift in Sources */, 356DB76A1824EAFD0020CEA0 /* ExtendedNSSplitView.m in Sources */, 35E9008A1147F42900851A25 /* MAAttachedWindow.m in Sources */, 350E7D3E121093E400D2F5F5 /* AlertBadge.m in Sources */, @@ -1147,6 +1148,9 @@ AA00000C000000000000AAAA /* URLValidatorTests.swift in Sources */, AA00000E000000000000AAAA /* URLSheetPresenterTests.swift in Sources */, AA000010000000000000AAAA /* URLSheetViewTests.swift in Sources */, + BB00000E000000000000BBBB /* RemoteIntervalMapperTests.swift in Sources */, + BB000010000000000000BBBB /* SparkleObserverTests.swift in Sources */, + BB000012000000000000BBBB /* PreferencesPresenterTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Preferences.xib b/Preferences.xib deleted file mode 100644 index 7a8f185..0000000 --- a/Preferences.xib +++ /dev/null @@ -1,237 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - UpdateDateTransformer - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 5 15 30 1h 2h 5h 10h 24h 7d - - - - - - - - - diff --git a/Resources/Images/Preferences/Editor.png b/Resources/Images/Preferences/Editor.png deleted file mode 100644 index 707527e..0000000 Binary files a/Resources/Images/Preferences/Editor.png and /dev/null differ diff --git a/Resources/Images/Preferences/Hotkeys.png b/Resources/Images/Preferences/Hotkeys.png deleted file mode 100644 index db3d6d8..0000000 Binary files a/Resources/Images/Preferences/Hotkeys.png and /dev/null differ diff --git a/Resources/Images/Preferences/Remote.png b/Resources/Images/Preferences/Remote.png deleted file mode 100644 index 9816dd1..0000000 Binary files a/Resources/Images/Preferences/Remote.png and /dev/null differ diff --git a/Resources/Images/Preferences/Update.png b/Resources/Images/Preferences/Update.png deleted file mode 100644 index 4026c7b..0000000 Binary files a/Resources/Images/Preferences/Update.png and /dev/null differ diff --git a/Source/ApplicationController.h b/Source/ApplicationController.h index 0e769ae..8aa3fa8 100644 --- a/Source/ApplicationController.h +++ b/Source/ApplicationController.h @@ -19,7 +19,6 @@ ***************************************************************************/ #import "HostsMainController.h" -#import "PreferenceController.h" @class AboutBoxController; @@ -30,7 +29,6 @@ int busyThreads; BOOL shouldQuit; BOOL editorWindowOpened; - PreferenceController *preferenceController; AboutBoxController *aboutBoxController; } diff --git a/Source/ApplicationController.m b/Source/ApplicationController.m index 41ec0c1..501305e 100644 --- a/Source/ApplicationController.m +++ b/Source/ApplicationController.m @@ -78,12 +78,8 @@ - (id)init -(IBAction)openPreferencesWindow:(id)sender { - if (!preferenceController) { - preferenceController = [[PreferenceController alloc] init]; - } - [self showApplicationInDock]; - [preferenceController showWindow:self]; + [PreferencesPresenter showPreferences]; } - (IBAction)displayAboutBox:(id)sender diff --git a/Source/PreferenceController.h b/Source/PreferenceController.h deleted file mode 100644 index 2cf5ce2..0000000 --- a/Source/PreferenceController.h +++ /dev/null @@ -1,42 +0,0 @@ -/*************************************************************************** - * Copyright (C) 2009-2010 by Clockwise * - * copyright@clockwise.ee * - * * - * This program is free software; you can redistribute it and/or modify * - * it under the terms of the GNU General Public License as published by * - * the Free Software Foundation; either version 2 of the License, or * - * (at your option) any later version. * - * * - * This program is distributed in the hope that it will be useful, * - * but WITHOUT ANY WARRANTY; without even the implied warranty of * - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * - * GNU General Public License for more details. * - * * - * You should have received a copy of the GNU General Public License * - * along with this program; if not, write to the * - * Free Software Foundation, Inc., * - * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * - ***************************************************************************/ - -@class SRRecorderControl; -@protocol SRRecorderControlDelegate; -@class LoginItem; - -@interface PreferenceController : NSWindowController { - @private - IBOutlet NSView *generalView, *editorView, *hotkeysView, *updateView, *remoteView; - LoginItem *loginItem; - - __unsafe_unretained IBOutlet NSButton *showHostFileNameButton; - - // Remote - IBOutlet NSSlider *remoteIntervalSlider; - NSDictionary *remoteIntervals; - - // Hotkeys - IBOutlet SRRecorderControl *activatePreviousHotkey, *activateNextHotkey, *updateHotkey; -} - -- (void) setPreferenceView:(id)sender; - -@end diff --git a/Source/PreferenceController.m b/Source/PreferenceController.m deleted file mode 100644 index d8e2ce3..0000000 --- a/Source/PreferenceController.m +++ /dev/null @@ -1,287 +0,0 @@ -/*************************************************************************** - * Copyright (C) 2009-2012 by Clockwise * - * copyright@clockwise.ee * - * * - * This program is free software; you can redistribute it and/or modify * - * it under the terms of the GNU General Public License as published by * - * the Free Software Foundation; either version 2 of the License, or * - * (at your option) any later version. * - * * - * This program is distributed in the hope that it will be useful, * - * but WITHOUT ANY WARRANTY; without even the implied warranty of * - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * - * GNU General Public License for more details. * - * * - * You should have received a copy of the GNU General Public License * - * along with this program; if not, write to the * - * Free Software Foundation, Inc., * - * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * - ***************************************************************************/ - -#import - -#import "PreferenceController.h" -#import "Preferences.h" -#import "Preferences+Remote.h" -#import "LoginItem.h" -#import "Hotkey.h" -#import "Util.h" - -#define TOOLBAR_GENERAL @"TOOLBAR_GENERAL" -#define TOOLBAR_EDITOR @"TOOLBAR_EDITOR" -#define TOOLBAR_REMOTE @"TOOLBAR_REMOTE" -#define TOOLBAR_HOTKEYS @"TOOLBAR_HOTKEYS" -#define TOOLBAR_UPDATE @"TOOLBAR_UPDATE" - - -@interface PreferenceController (Remote) -- (void)initRemote; -- (int)remoteInterval; -- (void)setRemoteInterval:(int)interval; -@end - -@interface PreferenceController (Hotkeys) -- (void)initHotkeys; -@end - -@interface PreferenceController (General) -- (void)initGeneral; -@end - -@implementation PreferenceController - -- (id)init -{ - self = [super initWithWindowNibName:@"Preferences"]; - if (self == nil) { - return nil; - } - - NSToolbar * toolbar = [[NSToolbar alloc] initWithIdentifier: @"Preferences Toolbar"]; - [toolbar setDelegate: self]; - [toolbar setAllowsUserCustomization: NO]; - [toolbar setDisplayMode: NSToolbarDisplayModeIconAndLabel]; - [toolbar setSizeMode: NSToolbarSizeModeRegular]; - [toolbar setSelectedItemIdentifier: TOOLBAR_GENERAL]; - [[self window] setToolbar: toolbar]; - - return self; -} - -- (void) awakeFromNib -{ - [self setPreferenceView:nil]; - - loginItem = [LoginItem new]; - [loginItem bind:@"enabled" toObject:[Preferences instance] withKeyPath:@"values.openAtLogin" options:nil]; - - [self initGeneral]; - [self initRemote]; - [self initHotkeys]; -} - -- (NSArray *) toolbarSelectableItemIdentifiers: (NSToolbar *) toolbar -{ - return [self toolbarDefaultItemIdentifiers: toolbar]; -} - -- (NSArray *) toolbarDefaultItemIdentifiers: (NSToolbar *) toolbar -{ - return [self toolbarAllowedItemIdentifiers: toolbar]; -} - -- (NSArray *) toolbarAllowedItemIdentifiers: (NSToolbar *) toolbar -{ - return [NSArray arrayWithObjects: TOOLBAR_GENERAL, - TOOLBAR_EDITOR, - TOOLBAR_REMOTE, - TOOLBAR_HOTKEYS, - TOOLBAR_UPDATE, - nil]; -} - -- (NSToolbarItem *) toolbar: (NSToolbar *) toolbar itemForItemIdentifier: (NSString *) ident willBeInsertedIntoToolbar: (BOOL) flag -{ - NSToolbarItem * item = [[NSToolbarItem alloc] initWithItemIdentifier: ident]; - - if ([ident isEqualTo:TOOLBAR_GENERAL]) { - [item setLabel: @"General"]; - [item setImage: [NSImage imageNamed: NSImageNamePreferencesGeneral]]; - } - else if ([ident isEqualTo:TOOLBAR_EDITOR]) { - [item setLabel: @"Editor"]; - [item setImage: [NSImage imageNamed: @"Editor.png"]]; - } - else if ([ident isEqualTo:TOOLBAR_REMOTE]) { - [item setLabel: @"Remote"]; - [item setImage: [NSImage imageNamed: @"Remote.png"]]; - } - else if ([ident isEqualTo:TOOLBAR_HOTKEYS]) { - [item setLabel: @"Hotkeys"]; - [item setImage: [NSImage imageNamed: @"Hotkeys.png"]]; - } - else if ([ident isEqualTo:TOOLBAR_UPDATE]) { - [item setLabel: @"Update"]; - [item setImage: [NSImage imageNamed: @"Update.png"]]; - } - [item setTarget: self]; - [item setAction: @selector(setPreferenceView:)]; - [item setAutovalidates: NO]; - - return item; -} - -- (void) setPreferenceView:(id)sender -{ - NSView *view = generalView; - NSString * identifier = [sender itemIdentifier]; - if ([identifier isEqualToString:TOOLBAR_EDITOR]) { - view = editorView; - } - else if ([identifier isEqualToString:TOOLBAR_REMOTE]) { - view = remoteView; - } - else if ([identifier isEqualToString:TOOLBAR_HOTKEYS]) { - view = hotkeysView; - } - else if ([identifier isEqualToString:TOOLBAR_UPDATE]) { - view = updateView; - } - - NSWindow * window = [self window]; - NSRect windowRect = [window frame]; - float difference = ([view frame].size.height - [[window contentView] frame].size.height); - windowRect.origin.y -= difference; - windowRect.size.height += difference; - - [window setContentView: view]; - [window setFrame: windowRect display: YES animate: YES]; -} - -@end - -@implementation PreferenceController (General) -/** - * OS X 10.10 and later support the NSStatusItemBar button which is what the - * "Show Host File Name in Status Bar" feature is built upon. So if we're - * not 10.10 or above, then we need to disable the preference selection. - */ -- (void) initGeneral -{ - showHostFileNameButton.enabled = ![Util isPre10_10]; -} -@end - -@implementation PreferenceController (Remote) - -- (void)initRemote -{ - NSArray *objects = [NSArray arrayWithObjects: - [NSNumber numberWithInt:5], - [NSNumber numberWithInt:15], - [NSNumber numberWithInt:30], - [NSNumber numberWithInt:60], - [NSNumber numberWithInt:120], - [NSNumber numberWithInt:300], - [NSNumber numberWithInt:600], - [NSNumber numberWithInt:1440], - [NSNumber numberWithInt:10080], - nil]; - NSArray *keys = [NSArray arrayWithObjects: - [NSNumber numberWithInt:1], - [NSNumber numberWithInt:2], - [NSNumber numberWithInt:3], - [NSNumber numberWithInt:4], - [NSNumber numberWithInt:5], - [NSNumber numberWithInt:6], - [NSNumber numberWithInt:7], - [NSNumber numberWithInt:8], - [NSNumber numberWithInt:9], - nil]; - - remoteIntervals = [NSDictionary dictionaryWithObjects:objects forKeys:keys]; - - [remoteIntervalSlider bind:@"value" toObject:self withKeyPath:@"remoteInterval" options:nil]; -} - -- (int)remoteInterval -{ - NSNumber *interval = [NSNumber numberWithInt:[Preferences remoteHostsUpdateInterval]]; - - for (NSNumber *key in remoteIntervals) { - if ([[remoteIntervals objectForKey:key] isEqual:interval]) { - return [key integerValue]; - } - } - - return 0; -} - -- (void)setRemoteInterval:(int)interval -{ - NSNumber *value = [remoteIntervals objectForKey:[NSNumber numberWithInt:interval]]; - [Preferences setRemoteHostsUpdateInterval:[value intValue]]; -} - -@end - - -@implementation PreferenceController (Hotkeys) - -- (void)initHotkeys -{ - id plist = [[[Preferences instance] defaults] valueForKey:ActivatePreviousFilePrefKey]; - Hotkey *hotkey = [[Hotkey alloc] initWithPlistRepresentation:plist]; - if (hotkey.keyCode > 0) { - [activatePreviousHotkey setObjectValue:[SRShortcut shortcutWithCode:(SRKeyCode)hotkey.keyCode - modifierFlags:SRCarbonToCocoaFlags((UInt32)hotkey.modifiers) - characters:nil - charactersIgnoringModifiers:nil]]; - } - - plist = [[[Preferences instance] defaults] valueForKey:ActivateNextFilePrefKey]; - hotkey = [[Hotkey alloc] initWithPlistRepresentation:plist]; - if (hotkey.keyCode > 0) { - [activateNextHotkey setObjectValue:[SRShortcut shortcutWithCode:(SRKeyCode)hotkey.keyCode - modifierFlags:SRCarbonToCocoaFlags((UInt32)hotkey.modifiers) - characters:nil - charactersIgnoringModifiers:nil]]; - } - - plist = [[[Preferences instance] defaults] valueForKey:UpdateAndSynchronizePrefKey]; - hotkey = [[Hotkey alloc] initWithPlistRepresentation:plist]; - if (hotkey.keyCode > 0) { - [updateHotkey setObjectValue:[SRShortcut shortcutWithCode:(SRKeyCode)hotkey.keyCode - modifierFlags:SRCarbonToCocoaFlags((UInt32)hotkey.modifiers) - characters:nil - charactersIgnoringModifiers:nil]]; - } -} - -- (void)recorderControlDidEndRecording:(SRRecorderControl *)aControl -{ - SRShortcut *shortcut = aControl.objectValue; - Hotkey *hotkey; - if (shortcut) { - hotkey = [[Hotkey alloc] initWithKeyCode:(int)shortcut.carbonKeyCode - modifiers:(int)shortcut.carbonModifierFlags]; - } else { - hotkey = [[Hotkey alloc] initWithKeyCode:-1 modifiers:-1]; - } - - NSString *prefKey; - if (aControl == activatePreviousHotkey) { - prefKey = ActivatePreviousFilePrefKey; - } - else if (aControl == activateNextHotkey) { - prefKey = ActivateNextFilePrefKey; - } - else { - prefKey = UpdateAndSynchronizePrefKey; - } - - [[[Preferences instance] defaults] setValue:[hotkey plistRepresentation] forKey:prefKey]; -} - - -@end \ No newline at end of file diff --git a/Source/Swift/GasMask-Bridging-Header.h b/Source/Swift/GasMask-Bridging-Header.h index 4e8bda7..d6b3ce2 100644 --- a/Source/Swift/GasMask-Bridging-Header.h +++ b/Source/Swift/GasMask-Bridging-Header.h @@ -2,3 +2,7 @@ #import "Network.h" #import "HostsMainController.h" #import "RemoteHostsController.h" +#import "Hotkey.h" +#import "Preferences.h" +#import "Preferences+Remote.h" +#import "LoginItem.h" diff --git a/Source/Swift/LoginItemObserver.swift b/Source/Swift/LoginItemObserver.swift new file mode 100644 index 0000000..5b58834 --- /dev/null +++ b/Source/Swift/LoginItemObserver.swift @@ -0,0 +1,19 @@ +import Foundation + +/// Thin ObservableObject wrapper around the existing ObjC `LoginItem` class. +/// +/// Delegates all `SMAppService` logic to `LoginItem`, avoiding a parallel implementation. +final class LoginItemObserver: ObservableObject { + private let loginItem = LoginItem() + + @Published var isEnabled: Bool { + didSet { + guard isEnabled != oldValue else { return } + loginItem.setEnabled(isEnabled) + } + } + + init() { + self.isEnabled = loginItem.enabled() + } +} diff --git a/Source/Swift/PreferencesPresenter.swift b/Source/Swift/PreferencesPresenter.swift new file mode 100644 index 0000000..ad61ed3 --- /dev/null +++ b/Source/Swift/PreferencesPresenter.swift @@ -0,0 +1,46 @@ +import AppKit +import SwiftUI + +@objc final class PreferencesPresenter: NSObject { + private static var window: NSWindow? + + @objc static func showPreferences() { + if let existing = window { + existing.makeKeyAndOrderFront(nil) + return + } + + let tabVC = NSTabViewController() + tabVC.tabStyle = .toolbar + + let contentSize = NSSize(width: 550, height: 150) + + func makeTab(label: String, symbolName: String, rootView: V) -> NSTabViewItem { + let hc = NSHostingController(rootView: rootView) + hc.preferredContentSize = contentSize + let item = NSTabViewItem(viewController: hc) + item.label = label + item.image = NSImage(systemSymbolName: symbolName, accessibilityDescription: label) + return item + } + + tabVC.addTabViewItem(makeTab(label: "General", symbolName: "gearshape", rootView: GeneralTab())) + tabVC.addTabViewItem(makeTab(label: "Editor", symbolName: "square.and.pencil", rootView: EditorTab())) + tabVC.addTabViewItem(makeTab(label: "Remote", symbolName: "globe", rootView: RemoteTab())) + tabVC.addTabViewItem(makeTab(label: "Hotkeys", symbolName: "command.square.fill", rootView: HotkeysTab())) + tabVC.addTabViewItem(makeTab(label: "Update", symbolName: "arrow.triangle.2.circlepath", rootView: UpdateTab())) + + let w = NSWindow(contentViewController: tabVC) + w.styleMask = [.titled, .closable] + w.toolbarStyle = .preference + w.title = tabVC.tabViewItems.first?.label ?? "Preferences" + w.setFrameAutosaveName("PreferencesWindow") + + // Keep the window alive after close so it can be reopened + w.isReleasedWhenClosed = false + + window = w + w.center() + w.makeKeyAndOrderFront(nil) + } +} diff --git a/Source/Swift/PreferencesView.swift b/Source/Swift/PreferencesView.swift new file mode 100644 index 0000000..af98f6a --- /dev/null +++ b/Source/Swift/PreferencesView.swift @@ -0,0 +1,119 @@ +import SwiftUI + +// MARK: - General Tab + +struct GeneralTab: View { + @StateObject private var loginItemObserver = LoginItemObserver() + @AppStorage("overrideExternalModifications") private var overrideExternalModifications = true + @AppStorage("showNameInStatusBar") private var showNameInStatusBar = false + + var body: some View { + Form { + Toggle("Open at Login", isOn: $loginItemObserver.isEnabled) + Toggle("Override external modifications", isOn: $overrideExternalModifications) + Toggle("Show Host File Name in Status Bar", isOn: $showNameInStatusBar) + } + .padding(20) + } +} + +// MARK: - Editor Tab + +struct EditorTab: View { + @AppStorage("syntaxHighlighting") private var syntaxHighlighting = true + + var body: some View { + Form { + Toggle("Syntax Highlighting", isOn: $syntaxHighlighting) + } + .padding(20) + } +} + +// MARK: - Remote Tab + +struct RemoteTab: View { + @State private var sliderPosition: Double + + init() { + let currentMinutes = Int(Preferences.remoteHostsUpdateInterval()) + _sliderPosition = State(initialValue: Double(RemoteIntervalMapper.position(forMinutes: currentMinutes))) + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Update interval:") + + Slider(value: $sliderPosition, in: 1...9, step: 1) + .onChange(of: sliderPosition) { newValue in + let minutes = RemoteIntervalMapper.minutes(forPosition: Int(newValue)) + Preferences.setRemoteHostsUpdateInterval(Int32(minutes)) + } + + HStack(spacing: 0) { + ForEach(Array(RemoteIntervalMapper.labels.enumerated()), id: \.offset) { _, label in + Text(label) + .font(.caption) + .frame(maxWidth: .infinity) + } + } + } + .padding(20) + } +} + +// MARK: - Hotkeys Tab + +struct HotkeysTab: View { + var body: some View { + // Keys match ObjC #define constants in Preferences.h: + // ActivatePreviousFilePrefKey = @"activatePreviousHotkey" + // ActivateNextFilePrefKey = @"activateNextHotkey" + // UpdateAndSynchronizePrefKey = @"updateAndSynchronizeHotkey" + Form { + HStack { + Text("Activate Previous File:") + .frame(width: 160, alignment: .trailing) + ShortcutRecorderView(prefsKey: "activatePreviousHotkey") + .frame(width: 150, height: 22) + } + HStack { + Text("Activate Next File:") + .frame(width: 160, alignment: .trailing) + ShortcutRecorderView(prefsKey: "activateNextHotkey") + .frame(width: 150, height: 22) + } + HStack { + Text("Update Remote Files:") + .frame(width: 160, alignment: .trailing) + ShortcutRecorderView(prefsKey: "updateAndSynchronizeHotkey") + .frame(width: 150, height: 22) + } + } + .padding(20) + } +} + +// MARK: - Update Tab + +struct UpdateTab: View { + @StateObject private var sparkleObserver = SparkleObserver() + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Toggle("Automatically check for updates", isOn: Binding( + get: { sparkleObserver.automaticChecksEnabled }, + set: { sparkleObserver.setAutomaticChecks($0) } + )) + + Text(sparkleObserver.lastCheckDateFormatted) + .font(.caption) + + Button("Check Now") { + sparkleObserver.checkForUpdates() + } + .disabled(!sparkleObserver.automaticChecksEnabled) + } + .padding(20) + } +} diff --git a/Source/Swift/RemoteIntervalMapper.swift b/Source/Swift/RemoteIntervalMapper.swift new file mode 100644 index 0000000..d562e71 --- /dev/null +++ b/Source/Swift/RemoteIntervalMapper.swift @@ -0,0 +1,35 @@ +import Foundation + +/// Maps slider positions (1–9) to remote update intervals in minutes. +/// +/// Extracted from the ObjC `PreferenceController (Remote)` category for testability. +/// The same minute values are stored in UserDefaults and read by `GlobalHotkeys`. +enum RemoteIntervalMapper { + static let intervals: [(position: Int, minutes: Int)] = [ + (1, 5), + (2, 15), + (3, 30), + (4, 60), + (5, 120), + (6, 300), + (7, 600), + (8, 1440), + (9, 10080), + ] + + /// Display labels for each slider tick mark, matching the 9 positions. + static let labels: [String] = ["5m", "15m", "30m", "1h", "2h", "5h", "10h", "24h", "7d"] + + /// Returns the interval in minutes for a given slider position (1–9). + static func minutes(forPosition position: Int) -> Int { + intervals.first { $0.position == position }?.minutes ?? 5 + } + + /// Returns the slider position (1–9) for a given interval in minutes. + /// + /// Returns `1` for unknown values — an intentional improvement over the ObjC code + /// which returned `0` (below the slider's minimum), causing an impossible state. + static func position(forMinutes minutes: Int) -> Int { + intervals.first { $0.minutes == minutes }?.position ?? 1 + } +} diff --git a/Source/Swift/ShortcutRecorderView.swift b/Source/Swift/ShortcutRecorderView.swift new file mode 100644 index 0000000..4e0187b --- /dev/null +++ b/Source/Swift/ShortcutRecorderView.swift @@ -0,0 +1,73 @@ +import AppKit +import ShortcutRecorder +import SwiftUI + +/// Wraps `RecorderControl` (ShortcutRecorder) for use in SwiftUI. +/// +/// Each instance reads/writes a single UserDefaults key (e.g. `"activatePreviousHotkey"`) +/// in the same plist dict format that `GlobalHotkeys` observes via KVO. +struct ShortcutRecorderView: NSViewRepresentable { + /// The UserDefaults key storing the hotkey dict (matches ObjC #define constants). + let prefsKey: String + + func makeCoordinator() -> Coordinator { + Coordinator(prefsKey: prefsKey) + } + + func makeNSView(context: Context) -> RecorderControl { + let control = RecorderControl() + control.delegate = context.coordinator + + // Load existing shortcut from UserDefaults + if let plist = UserDefaults.standard.object(forKey: prefsKey), + let hotkey = Hotkey(plistRepresentation: plist), + hotkey.keyCode > 0 { + if let keyCode = KeyCode(rawValue: UInt16(hotkey.keyCode)) { + control.objectValue = Shortcut( + code: keyCode, + modifierFlags: Self.carbonToCocoaFlags(UInt32(hotkey.modifiers)), + characters: nil, + charactersIgnoringModifiers: nil + ) + } + } + + return control + } + + func updateNSView(_ nsView: RecorderControl, context: Context) { + // No-op: prefsKey is immutable per instance, and state changes are driven + // by user interaction within RecorderControl + its delegate. + } + + /// Convert Carbon modifier flags to Cocoa NSEvent.ModifierFlags. + private static func carbonToCocoaFlags(_ carbonFlags: UInt32) -> NSEvent.ModifierFlags { + var flags = NSEvent.ModifierFlags() + if carbonFlags & UInt32(cmdKey) != 0 { flags.insert(.command) } + if carbonFlags & UInt32(optionKey) != 0 { flags.insert(.option) } + if carbonFlags & UInt32(controlKey) != 0 { flags.insert(.control) } + if carbonFlags & UInt32(shiftKey) != 0 { flags.insert(.shift) } + return flags + } + + final class Coordinator: NSObject, RecorderControlDelegate { + let prefsKey: String + + init(prefsKey: String) { + self.prefsKey = prefsKey + } + + func recorderControlDidEndRecording(_ recorder: RecorderControl) { + let hotkey: Hotkey + if let shortcut = recorder.objectValue { + hotkey = Hotkey( + keyCode: Int32(shortcut.carbonKeyCode), + modifiers: Int32(shortcut.carbonModifierFlags) + ) + } else { + hotkey = Hotkey(keyCode: -1, modifiers: -1) + } + UserDefaults.standard.set(hotkey.plistRepresentation(), forKey: prefsKey) + } + } +} diff --git a/Source/Swift/SparkleObserver.swift b/Source/Swift/SparkleObserver.swift new file mode 100644 index 0000000..66ee4c1 --- /dev/null +++ b/Source/Swift/SparkleObserver.swift @@ -0,0 +1,54 @@ +import Foundation +import Sparkle + +/// Wraps `SUUpdater.shared()` as an `ObservableObject` for SwiftUI. +/// +/// Replaces the XIB-instantiated `SUUpdater` object and `UpdateDateTransformer`. +final class SparkleObserver: ObservableObject { + @Published var lastCheckDate: Date? + @Published var automaticChecksEnabled: Bool + + private var dateObservation: NSKeyValueObservation? + + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .long + formatter.timeStyle = .short + return formatter + }() + + /// Formats `lastCheckDate` as `"Last Checked: Never"` or `"Last Checked: "`. + var lastCheckDateFormatted: String { + guard let date = lastCheckDate else { + return "Last Checked: Never" + } + return "Last Checked: \(Self.dateFormatter.string(from: date))" + } + + init() { + guard let updater = SUUpdater.shared() else { + self.lastCheckDate = nil + self.automaticChecksEnabled = false + return + } + self.lastCheckDate = updater.lastUpdateCheckDate + self.automaticChecksEnabled = updater.automaticallyChecksForUpdates + + // Observe lastUpdateCheckDate via KVO + dateObservation = updater.observe(\.lastUpdateCheckDate, options: [.new]) { [weak self] _, change in + DispatchQueue.main.async { + self?.lastCheckDate = change.newValue ?? nil + } + } + } + + /// Writes only to UserDefaults — SUUpdater observes this key via its own KVO. + func setAutomaticChecks(_ enabled: Bool) { + automaticChecksEnabled = enabled + UserDefaults.standard.set(enabled, forKey: "SUEnableAutomaticChecks") + } + + func checkForUpdates() { + SUUpdater.shared()?.checkForUpdates(nil) + } +} diff --git a/Source/UpdateDateTransformer.h b/Source/UpdateDateTransformer.h deleted file mode 100644 index 8875935..0000000 --- a/Source/UpdateDateTransformer.h +++ /dev/null @@ -1,23 +0,0 @@ -/*************************************************************************** - * Copyright (C) 2009-2010 by Clockwise * - * copyright@clockwise.ee * - * * - * This program is free software; you can redistribute it and/or modify * - * it under the terms of the GNU General Public License as published by * - * the Free Software Foundation; either version 2 of the License, or * - * (at your option) any later version. * - * * - * This program is distributed in the hope that it will be useful, * - * but WITHOUT ANY WARRANTY; without even the implied warranty of * - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * - * GNU General Public License for more details. * - * * - * You should have received a copy of the GNU General Public License * - * along with this program; if not, write to the * - * Free Software Foundation, Inc., * - * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * - ***************************************************************************/ - -@interface UpdateDateTransformer : NSValueTransformer - -@end diff --git a/Source/UpdateDateTransformer.m b/Source/UpdateDateTransformer.m deleted file mode 100644 index 6a9ab43..0000000 --- a/Source/UpdateDateTransformer.m +++ /dev/null @@ -1,55 +0,0 @@ -/*************************************************************************** - * Copyright (C) 2009-2012 by Clockwise * - * copyright@clockwise.ee * - * * - * This program is free software; you can redistribute it and/or modify * - * it under the terms of the GNU General Public License as published by * - * the Free Software Foundation; either version 2 of the License, or * - * (at your option) any later version. * - * * - * This program is distributed in the hope that it will be useful, * - * but WITHOUT ANY WARRANTY; without even the implied warranty of * - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * - * GNU General Public License for more details. * - * * - * You should have received a copy of the GNU General Public License * - * along with this program; if not, write to the * - * Free Software Foundation, Inc., * - * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * - ***************************************************************************/ - -#import "UpdateDateTransformer.h" - - -@implementation UpdateDateTransformer - -+ (Class)transformedValueClass -{ - return [NSString class]; -} - -+ (BOOL)allowsReverseTransformation -{ - return NO; -} - -- (id)transformedValue:(id)value -{ - - NSString *checked; - - if (value == nil) { - checked = @"Never"; - } - else { - NSDate *date = (NSDate*)value; - NSDateFormatter *formatter = [NSDateFormatter new]; - [formatter setDateStyle:NSDateFormatterLongStyle]; - [formatter setTimeStyle:NSDateFormatterShortStyle]; - checked = [formatter stringFromDate:date]; - } - - return [NSString stringWithFormat:@"Last Checked: %@", checked]; -} - -@end diff --git a/Tests/GasMaskTests/PreferencesPresenterTests.swift b/Tests/GasMaskTests/PreferencesPresenterTests.swift new file mode 100644 index 0000000..ef18930 --- /dev/null +++ b/Tests/GasMaskTests/PreferencesPresenterTests.swift @@ -0,0 +1,112 @@ +import XCTest +import AppKit +@testable import Gas_Mask + +final class PreferencesPresenterTests: XCTestCase { + + /// Finds the preferences window by its `NSTabViewController` content. + private func preferencesWindow() -> NSWindow? { + NSApp.windows.first { $0.contentViewController is NSTabViewController } + } + + override func tearDown() { + // Close any preferences window opened during the test. + // Drain CA transactions first to avoid animation crashes. + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.3)) + if let w = preferencesWindow() { w.close() } + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.1)) + super.tearDown() + } + + func testShowPreferences_createsWindow() { + PreferencesPresenter.showPreferences() + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.3)) + + let w = preferencesWindow() + XCTAssertNotNil(w, "A preferences window should exist") + XCTAssertTrue(w?.isVisible ?? false, "Preferences window should be visible") + } + + func testShowPreferences_reusesWindow() { + PreferencesPresenter.showPreferences() + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.3)) + + let first = preferencesWindow() + XCTAssertNotNil(first) + + PreferencesPresenter.showPreferences() + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.1)) + + let second = preferencesWindow() + XCTAssertTrue(first === second, "Should reuse the same window instance") + } + + func testShowPreferences_hasFiveTabs() { + PreferencesPresenter.showPreferences() + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.3)) + + let tabVC = preferencesWindow()?.contentViewController as? NSTabViewController + XCTAssertEqual(tabVC?.tabViewItems.count, 5, "Should have 5 preference tabs") + } + + func testShowPreferences_tabLabelsMatch() { + PreferencesPresenter.showPreferences() + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.3)) + + let tabVC = preferencesWindow()?.contentViewController as? NSTabViewController + let labels = tabVC?.tabViewItems.map(\.label) + XCTAssertEqual(labels, ["General", "Editor", "Remote", "Hotkeys", "Update"]) + } + + func testShowPreferences_tabsHaveIcons() { + PreferencesPresenter.showPreferences() + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.3)) + + let tabVC = preferencesWindow()?.contentViewController as? NSTabViewController + XCTAssertNotNil(tabVC) + for item in tabVC?.tabViewItems ?? [] { + XCTAssertNotNil(item.image, "Tab '\(item.label)' should have an icon") + } + } + + /// Captures a screenshot of a specific tab and saves to /tmp/preferences-.png. + private func captureTab(index: Int, name: String) throws { + try XCTSkipIf(NSScreen.main == nil, "No display available") + PreferencesPresenter.showPreferences() + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.3)) + let w = try XCTUnwrap(preferencesWindow()) + let tabVC = try XCTUnwrap(w.contentViewController as? NSTabViewController) + tabVC.selectedTabViewItemIndex = index + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.3)) + + let frame = w.frame + let screenFrame = w.screen?.frame ?? NSScreen.main?.frame ?? .zero + let cgRect = CGRect( + x: frame.origin.x, + y: screenFrame.height - frame.origin.y - frame.height, + width: frame.width, + height: frame.height + ) + if let displayID = w.screen?.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? CGDirectDisplayID, + let image = CGDisplayCreateImage(displayID, rect: cgRect) { + let bitmap = NSBitmapImageRep(cgImage: image) + if let png = bitmap.representation(using: .png, properties: [:]) { + try png.write(to: URL(fileURLWithPath: "/tmp/preferences-\(name).png")) + } + } + } + + func testScreenshot_allTabs() throws { + for (i, name) in ["general", "editor", "remote", "hotkeys", "update"].enumerated() { + try captureTab(index: i, name: name) + } + } + + func testShowPreferences_toolbarStyleIsPreference() { + PreferencesPresenter.showPreferences() + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.3)) + + let w = preferencesWindow() + XCTAssertEqual(w?.toolbarStyle, .preference) + } +} diff --git a/Tests/GasMaskTests/RemoteIntervalMapperTests.swift b/Tests/GasMaskTests/RemoteIntervalMapperTests.swift new file mode 100644 index 0000000..abf7a0b --- /dev/null +++ b/Tests/GasMaskTests/RemoteIntervalMapperTests.swift @@ -0,0 +1,41 @@ +import XCTest +@testable import Gas_Mask + +final class RemoteIntervalMapperTests: XCTestCase { + + func testAllForwardMappings() { + let expected: [(position: Int, minutes: Int)] = [ + (1, 5), (2, 15), (3, 30), (4, 60), (5, 120), + (6, 300), (7, 600), (8, 1440), (9, 10080), + ] + for (position, minutes) in expected { + XCTAssertEqual( + RemoteIntervalMapper.minutes(forPosition: position), minutes, + "Position \(position) should map to \(minutes) minutes" + ) + } + } + + func testAllReverseMappings() { + let expected: [(minutes: Int, position: Int)] = [ + (5, 1), (15, 2), (30, 3), (60, 4), (120, 5), + (300, 6), (600, 7), (1440, 8), (10080, 9), + ] + for (minutes, position) in expected { + XCTAssertEqual( + RemoteIntervalMapper.position(forMinutes: minutes), position, + "\(minutes) minutes should map to position \(position)" + ) + } + } + + func testUnknownMinutes_fallsBackToPosition1() { + XCTAssertEqual(RemoteIntervalMapper.position(forMinutes: 999), 1) + XCTAssertEqual(RemoteIntervalMapper.position(forMinutes: 0), 1) + XCTAssertEqual(RemoteIntervalMapper.position(forMinutes: -1), 1) + } + + func testLabels_hasNineElements() { + XCTAssertEqual(RemoteIntervalMapper.labels.count, 9) + } +} diff --git a/Tests/GasMaskTests/SparkleObserverTests.swift b/Tests/GasMaskTests/SparkleObserverTests.swift new file mode 100644 index 0000000..fd44774 --- /dev/null +++ b/Tests/GasMaskTests/SparkleObserverTests.swift @@ -0,0 +1,33 @@ +import XCTest +@testable import Gas_Mask + +final class SparkleObserverTests: XCTestCase { + + func testLastCheckDateFormatted_nil_returnsNever() { + let observer = SparkleObserver() + observer.lastCheckDate = nil + XCTAssertEqual(observer.lastCheckDateFormatted, "Last Checked: Never") + } + + func testLastCheckDateFormatted_date_returnsFormattedString() { + let observer = SparkleObserver() + // Use a fixed date: 2026-02-28 15:45:00 UTC + var components = DateComponents() + components.year = 2026 + components.month = 2 + components.day = 28 + components.hour = 15 + components.minute = 45 + let date = Calendar.current.date(from: components)! + observer.lastCheckDate = date + + let result = observer.lastCheckDateFormatted + XCTAssertTrue(result.hasPrefix("Last Checked: "), + "Should start with 'Last Checked: ', got: \(result)") + XCTAssertFalse(result.hasSuffix("Never"), + "Should not say Never when date is set") + // The exact format depends on locale, so just verify it contains the year + XCTAssertTrue(result.contains("2026"), + "Should contain the year, got: \(result)") + } +} diff --git a/docs/preferences-window.png b/docs/preferences-window.png new file mode 100644 index 0000000..2fb7423 Binary files /dev/null and b/docs/preferences-window.png differ