├── .gitignore ├── HomebrUI.xcodeproj └── project.pbxproj ├── HomebrUI ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ └── Contents.json │ └── Contents.json ├── Commands │ ├── AppCommands.swift │ ├── PackageCommands.swift │ └── ViewCommands.swift ├── Extensions │ ├── Binding.swift │ └── Combine.swift ├── HomebrUI.entitlements ├── HomebrUIApp.swift ├── Homebrew │ ├── AsyncOperation.swift │ ├── Homebrew.swift │ ├── HomebrewCommand.swift │ ├── HomebrewConfiguration.swift │ ├── HomebrewModels.swift │ ├── HomebrewOperationQueue.swift │ ├── Process.swift │ └── ProcessOperation.swift ├── Info.plist ├── Models │ ├── OperationRepository.swift │ ├── Package.swift │ └── PackageRepository.swift ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json └── Views │ ├── ContentView.swift │ ├── Input.swift │ ├── InstalledPackagesView.swift │ ├── OperationInfoView.swift │ ├── PackageDetailView.swift │ ├── SeachPackagesView.swift │ ├── SidebarView.swift │ └── ToolbarView.swift ├── LICENSE └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | ### macOS ### 2 | .DS_Store 3 | .AppleDouble 4 | .LSOverride 5 | Icon 6 | ._* 7 | .DocumentRevisions-V100 8 | .fseventsd 9 | .Spotlight-V100 10 | .TemporaryItems 11 | .Trashes 12 | .VolumeIcon.icns 13 | .com.apple.timemachine.donotpresent 14 | .AppleDB 15 | .AppleDesktop 16 | Network Trash Folder 17 | Temporary Items 18 | .apdisk 19 | 20 | ### Swift ### 21 | xcuserdata/ 22 | *.hmap 23 | *.ipa 24 | *.dSYM.zip 25 | *.dSYM 26 | timeline.xctimeline 27 | playground.xcworkspace 28 | .build/ 29 | /*.gcno 30 | *.xcodeproj/* 31 | !*.xcodeproj/project.pbxproj 32 | !*.xcodeproj/xcshareddata/ 33 | !*.xcworkspace/contents.xcworkspacedata 34 | **/xcshareddata/WorkspaceSettings.xcsettings 35 | -------------------------------------------------------------------------------- /HomebrUI.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 50; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 9409BC36258C10A100A95B9A /* Binding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9409BC35258C10A100A95B9A /* Binding.swift */; }; 11 | 9409BC3B258C11E200A95B9A /* PackageCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9409BC3A258C11E200A95B9A /* PackageCommands.swift */; }; 12 | 9409BC3F258C120700A95B9A /* ViewCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9409BC3E258C120700A95B9A /* ViewCommands.swift */; }; 13 | 9417F099258C123C008679E8 /* AppCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9421EF2B258B0A8C00C0EC8B /* AppCommands.swift */; }; 14 | 9417F09F258C15A3008679E8 /* ToolbarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9417F09E258C15A3008679E8 /* ToolbarView.swift */; }; 15 | 9421EF16258AC99000C0EC8B /* HomebrewOperationQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9421EF15258AC99000C0EC8B /* HomebrewOperationQueue.swift */; }; 16 | 9421EF19258AE77E00C0EC8B /* HomebrewCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9421EF18258AE77E00C0EC8B /* HomebrewCommand.swift */; }; 17 | 9421EF1E258AF51100C0EC8B /* ProcessOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9421EF1D258AF51100C0EC8B /* ProcessOperation.swift */; }; 18 | 9421EF23258AF77600C0EC8B /* HomebrewConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9421EF22258AF77600C0EC8B /* HomebrewConfiguration.swift */; }; 19 | 9465923E261988990068A50D /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9465923D261988990068A50D /* ContentView.swift */; }; 20 | 94B042962589666B00222EEF /* HomebrUIApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94B042952589666B00222EEF /* HomebrUIApp.swift */; }; 21 | 94B0429A2589666C00222EEF /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 94B042992589666C00222EEF /* Assets.xcassets */; }; 22 | 94B0429D2589666C00222EEF /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 94B0429C2589666C00222EEF /* Preview Assets.xcassets */; }; 23 | 94B042A82589672700222EEF /* Homebrew.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94B042A72589672700222EEF /* Homebrew.swift */; }; 24 | 94B042AB2589708500222EEF /* Process.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94B042AA2589708500222EEF /* Process.swift */; }; 25 | 94C12123258B22700014E558 /* OperationInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94C12122258B22700014E558 /* OperationInfoView.swift */; }; 26 | 94DB828E258C330F0071F8A8 /* OperationRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94DB828D258C330F0071F8A8 /* OperationRepository.swift */; }; 27 | 94DB8291258C3C590071F8A8 /* SeachPackagesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94DB8290258C3C590071F8A8 /* SeachPackagesView.swift */; }; 28 | 94DB8295258C53F30071F8A8 /* Combine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94DB8294258C53F30071F8A8 /* Combine.swift */; }; 29 | 94E38D4E258B428B00747B04 /* SidebarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94E38D4D258B428B00747B04 /* SidebarView.swift */; }; 30 | 94E4A247258980C400CC1C7D /* HomebrewModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94E4A246258980C400CC1C7D /* HomebrewModels.swift */; }; 31 | 94E4A24A2589A3D400CC1C7D /* PackageRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94E4A2492589A3D400CC1C7D /* PackageRepository.swift */; }; 32 | 94E4A24E2589BD1F00CC1C7D /* InstalledPackagesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94E4A24D2589BD1F00CC1C7D /* InstalledPackagesView.swift */; }; 33 | 94E4A2522589BD3A00CC1C7D /* Input.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94E4A2512589BD3A00CC1C7D /* Input.swift */; }; 34 | 94E4A2562589BD9600CC1C7D /* PackageDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94E4A2552589BD9600CC1C7D /* PackageDetailView.swift */; }; 35 | 94E4A25C2589C9A500CC1C7D /* Package.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94E4A25B2589C9A500CC1C7D /* Package.swift */; }; 36 | 94E615AA258ABFAA000D432C /* AsyncOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94E615A9258ABFAA000D432C /* AsyncOperation.swift */; }; 37 | /* End PBXBuildFile section */ 38 | 39 | /* Begin PBXFileReference section */ 40 | 9409BC35258C10A100A95B9A /* Binding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Binding.swift; sourceTree = ""; }; 41 | 9409BC3A258C11E200A95B9A /* PackageCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PackageCommands.swift; sourceTree = ""; }; 42 | 9409BC3E258C120700A95B9A /* ViewCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewCommands.swift; sourceTree = ""; }; 43 | 9417F09E258C15A3008679E8 /* ToolbarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolbarView.swift; sourceTree = ""; }; 44 | 9421EF15258AC99000C0EC8B /* HomebrewOperationQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomebrewOperationQueue.swift; sourceTree = ""; }; 45 | 9421EF18258AE77E00C0EC8B /* HomebrewCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomebrewCommand.swift; sourceTree = ""; }; 46 | 9421EF1D258AF51100C0EC8B /* ProcessOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessOperation.swift; sourceTree = ""; }; 47 | 9421EF22258AF77600C0EC8B /* HomebrewConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomebrewConfiguration.swift; sourceTree = ""; }; 48 | 9421EF2B258B0A8C00C0EC8B /* AppCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCommands.swift; sourceTree = ""; }; 49 | 9465923D261988990068A50D /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 50 | 947218162593384700F4B584 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; 51 | 94B042922589666B00222EEF /* HomebrUI.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = HomebrUI.app; sourceTree = BUILT_PRODUCTS_DIR; }; 52 | 94B042952589666B00222EEF /* HomebrUIApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomebrUIApp.swift; sourceTree = ""; }; 53 | 94B042992589666C00222EEF /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 54 | 94B0429C2589666C00222EEF /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 55 | 94B0429E2589666C00222EEF /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 56 | 94B0429F2589666C00222EEF /* HomebrUI.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = HomebrUI.entitlements; sourceTree = ""; }; 57 | 94B042A72589672700222EEF /* Homebrew.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Homebrew.swift; sourceTree = ""; }; 58 | 94B042AA2589708500222EEF /* Process.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Process.swift; sourceTree = ""; }; 59 | 94C12122258B22700014E558 /* OperationInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperationInfoView.swift; sourceTree = ""; }; 60 | 94DB828D258C330F0071F8A8 /* OperationRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperationRepository.swift; sourceTree = ""; }; 61 | 94DB8290258C3C590071F8A8 /* SeachPackagesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeachPackagesView.swift; sourceTree = ""; }; 62 | 94DB8294258C53F30071F8A8 /* Combine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Combine.swift; sourceTree = ""; }; 63 | 94E38D4D258B428B00747B04 /* SidebarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarView.swift; sourceTree = ""; }; 64 | 94E4A246258980C400CC1C7D /* HomebrewModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomebrewModels.swift; sourceTree = ""; }; 65 | 94E4A2492589A3D400CC1C7D /* PackageRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PackageRepository.swift; sourceTree = ""; }; 66 | 94E4A24D2589BD1F00CC1C7D /* InstalledPackagesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstalledPackagesView.swift; sourceTree = ""; }; 67 | 94E4A2512589BD3A00CC1C7D /* Input.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Input.swift; sourceTree = ""; }; 68 | 94E4A2552589BD9600CC1C7D /* PackageDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PackageDetailView.swift; sourceTree = ""; }; 69 | 94E4A25B2589C9A500CC1C7D /* Package.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Package.swift; sourceTree = ""; }; 70 | 94E615A9258ABFAA000D432C /* AsyncOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncOperation.swift; sourceTree = ""; }; 71 | /* End PBXFileReference section */ 72 | 73 | /* Begin PBXFrameworksBuildPhase section */ 74 | 94B0428F2589666B00222EEF /* Frameworks */ = { 75 | isa = PBXFrameworksBuildPhase; 76 | buildActionMask = 2147483647; 77 | files = ( 78 | ); 79 | runOnlyForDeploymentPostprocessing = 0; 80 | }; 81 | /* End PBXFrameworksBuildPhase section */ 82 | 83 | /* Begin PBXGroup section */ 84 | 9409BC34258C109600A95B9A /* Extensions */ = { 85 | isa = PBXGroup; 86 | children = ( 87 | 9409BC35258C10A100A95B9A /* Binding.swift */, 88 | 94DB8294258C53F30071F8A8 /* Combine.swift */, 89 | ); 90 | path = Extensions; 91 | sourceTree = ""; 92 | }; 93 | 9409BC39258C11D800A95B9A /* Commands */ = { 94 | isa = PBXGroup; 95 | children = ( 96 | 9421EF2B258B0A8C00C0EC8B /* AppCommands.swift */, 97 | 9409BC3A258C11E200A95B9A /* PackageCommands.swift */, 98 | 9409BC3E258C120700A95B9A /* ViewCommands.swift */, 99 | ); 100 | path = Commands; 101 | sourceTree = ""; 102 | }; 103 | 94B042892589666B00222EEF = { 104 | isa = PBXGroup; 105 | children = ( 106 | 947218162593384700F4B584 /* README.md */, 107 | 94B042942589666B00222EEF /* HomebrUI */, 108 | 94B042932589666B00222EEF /* Products */, 109 | ); 110 | sourceTree = ""; 111 | }; 112 | 94B042932589666B00222EEF /* Products */ = { 113 | isa = PBXGroup; 114 | children = ( 115 | 94B042922589666B00222EEF /* HomebrUI.app */, 116 | ); 117 | name = Products; 118 | sourceTree = ""; 119 | }; 120 | 94B042942589666B00222EEF /* HomebrUI */ = { 121 | isa = PBXGroup; 122 | children = ( 123 | 94E4A25A2589C99100CC1C7D /* Models */, 124 | 94E4A24C2589BD1400CC1C7D /* Views */, 125 | 9409BC39258C11D800A95B9A /* Commands */, 126 | 94B042A62589671600222EEF /* Homebrew */, 127 | 9409BC34258C109600A95B9A /* Extensions */, 128 | 94B042952589666B00222EEF /* HomebrUIApp.swift */, 129 | 94B042992589666C00222EEF /* Assets.xcassets */, 130 | 94B0429E2589666C00222EEF /* Info.plist */, 131 | 94B0429F2589666C00222EEF /* HomebrUI.entitlements */, 132 | 94B0429B2589666C00222EEF /* Preview Content */, 133 | ); 134 | path = HomebrUI; 135 | sourceTree = ""; 136 | }; 137 | 94B0429B2589666C00222EEF /* Preview Content */ = { 138 | isa = PBXGroup; 139 | children = ( 140 | 94B0429C2589666C00222EEF /* Preview Assets.xcassets */, 141 | ); 142 | path = "Preview Content"; 143 | sourceTree = ""; 144 | }; 145 | 94B042A62589671600222EEF /* Homebrew */ = { 146 | isa = PBXGroup; 147 | children = ( 148 | 94E615A9258ABFAA000D432C /* AsyncOperation.swift */, 149 | 94B042A72589672700222EEF /* Homebrew.swift */, 150 | 9421EF18258AE77E00C0EC8B /* HomebrewCommand.swift */, 151 | 9421EF22258AF77600C0EC8B /* HomebrewConfiguration.swift */, 152 | 94E4A246258980C400CC1C7D /* HomebrewModels.swift */, 153 | 9421EF15258AC99000C0EC8B /* HomebrewOperationQueue.swift */, 154 | 94B042AA2589708500222EEF /* Process.swift */, 155 | 9421EF1D258AF51100C0EC8B /* ProcessOperation.swift */, 156 | ); 157 | path = Homebrew; 158 | sourceTree = ""; 159 | }; 160 | 94E4A24C2589BD1400CC1C7D /* Views */ = { 161 | isa = PBXGroup; 162 | children = ( 163 | 9465923D261988990068A50D /* ContentView.swift */, 164 | 94E4A2512589BD3A00CC1C7D /* Input.swift */, 165 | 94E4A24D2589BD1F00CC1C7D /* InstalledPackagesView.swift */, 166 | 94C12122258B22700014E558 /* OperationInfoView.swift */, 167 | 94E4A2552589BD9600CC1C7D /* PackageDetailView.swift */, 168 | 94DB8290258C3C590071F8A8 /* SeachPackagesView.swift */, 169 | 94E38D4D258B428B00747B04 /* SidebarView.swift */, 170 | 9417F09E258C15A3008679E8 /* ToolbarView.swift */, 171 | ); 172 | path = Views; 173 | sourceTree = ""; 174 | }; 175 | 94E4A25A2589C99100CC1C7D /* Models */ = { 176 | isa = PBXGroup; 177 | children = ( 178 | 94E4A25B2589C9A500CC1C7D /* Package.swift */, 179 | 94E4A2492589A3D400CC1C7D /* PackageRepository.swift */, 180 | 94DB828D258C330F0071F8A8 /* OperationRepository.swift */, 181 | ); 182 | path = Models; 183 | sourceTree = ""; 184 | }; 185 | /* End PBXGroup section */ 186 | 187 | /* Begin PBXNativeTarget section */ 188 | 94B042912589666B00222EEF /* HomebrUI */ = { 189 | isa = PBXNativeTarget; 190 | buildConfigurationList = 94B042A22589666C00222EEF /* Build configuration list for PBXNativeTarget "HomebrUI" */; 191 | buildPhases = ( 192 | 94B0428E2589666B00222EEF /* Sources */, 193 | 94B0428F2589666B00222EEF /* Frameworks */, 194 | 94B042902589666B00222EEF /* Resources */, 195 | ); 196 | buildRules = ( 197 | ); 198 | dependencies = ( 199 | ); 200 | name = HomebrUI; 201 | productName = HomebrUI; 202 | productReference = 94B042922589666B00222EEF /* HomebrUI.app */; 203 | productType = "com.apple.product-type.application"; 204 | }; 205 | /* End PBXNativeTarget section */ 206 | 207 | /* Begin PBXProject section */ 208 | 94B0428A2589666B00222EEF /* Project object */ = { 209 | isa = PBXProject; 210 | attributes = { 211 | LastSwiftUpdateCheck = 1230; 212 | LastUpgradeCheck = 1240; 213 | TargetAttributes = { 214 | 94B042912589666B00222EEF = { 215 | CreatedOnToolsVersion = 12.3; 216 | }; 217 | }; 218 | }; 219 | buildConfigurationList = 94B0428D2589666B00222EEF /* Build configuration list for PBXProject "HomebrUI" */; 220 | compatibilityVersion = "Xcode 9.3"; 221 | developmentRegion = en; 222 | hasScannedForEncodings = 0; 223 | knownRegions = ( 224 | en, 225 | Base, 226 | ); 227 | mainGroup = 94B042892589666B00222EEF; 228 | productRefGroup = 94B042932589666B00222EEF /* Products */; 229 | projectDirPath = ""; 230 | projectRoot = ""; 231 | targets = ( 232 | 94B042912589666B00222EEF /* HomebrUI */, 233 | ); 234 | }; 235 | /* End PBXProject section */ 236 | 237 | /* Begin PBXResourcesBuildPhase section */ 238 | 94B042902589666B00222EEF /* Resources */ = { 239 | isa = PBXResourcesBuildPhase; 240 | buildActionMask = 2147483647; 241 | files = ( 242 | 94B0429D2589666C00222EEF /* Preview Assets.xcassets in Resources */, 243 | 94B0429A2589666C00222EEF /* Assets.xcassets in Resources */, 244 | ); 245 | runOnlyForDeploymentPostprocessing = 0; 246 | }; 247 | /* End PBXResourcesBuildPhase section */ 248 | 249 | /* Begin PBXSourcesBuildPhase section */ 250 | 94B0428E2589666B00222EEF /* Sources */ = { 251 | isa = PBXSourcesBuildPhase; 252 | buildActionMask = 2147483647; 253 | files = ( 254 | 94E615AA258ABFAA000D432C /* AsyncOperation.swift in Sources */, 255 | 9465923E261988990068A50D /* ContentView.swift in Sources */, 256 | 94E4A24A2589A3D400CC1C7D /* PackageRepository.swift in Sources */, 257 | 94E4A24E2589BD1F00CC1C7D /* InstalledPackagesView.swift in Sources */, 258 | 9417F099258C123C008679E8 /* AppCommands.swift in Sources */, 259 | 94E4A2562589BD9600CC1C7D /* PackageDetailView.swift in Sources */, 260 | 94DB828E258C330F0071F8A8 /* OperationRepository.swift in Sources */, 261 | 94E4A25C2589C9A500CC1C7D /* Package.swift in Sources */, 262 | 9421EF23258AF77600C0EC8B /* HomebrewConfiguration.swift in Sources */, 263 | 9409BC3F258C120700A95B9A /* ViewCommands.swift in Sources */, 264 | 9421EF1E258AF51100C0EC8B /* ProcessOperation.swift in Sources */, 265 | 94E4A247258980C400CC1C7D /* HomebrewModels.swift in Sources */, 266 | 94B042A82589672700222EEF /* Homebrew.swift in Sources */, 267 | 9421EF16258AC99000C0EC8B /* HomebrewOperationQueue.swift in Sources */, 268 | 94C12123258B22700014E558 /* OperationInfoView.swift in Sources */, 269 | 94B042962589666B00222EEF /* HomebrUIApp.swift in Sources */, 270 | 94E4A2522589BD3A00CC1C7D /* Input.swift in Sources */, 271 | 9409BC36258C10A100A95B9A /* Binding.swift in Sources */, 272 | 94B042AB2589708500222EEF /* Process.swift in Sources */, 273 | 94E38D4E258B428B00747B04 /* SidebarView.swift in Sources */, 274 | 9409BC3B258C11E200A95B9A /* PackageCommands.swift in Sources */, 275 | 9421EF19258AE77E00C0EC8B /* HomebrewCommand.swift in Sources */, 276 | 94DB8295258C53F30071F8A8 /* Combine.swift in Sources */, 277 | 9417F09F258C15A3008679E8 /* ToolbarView.swift in Sources */, 278 | 94DB8291258C3C590071F8A8 /* SeachPackagesView.swift in Sources */, 279 | ); 280 | runOnlyForDeploymentPostprocessing = 0; 281 | }; 282 | /* End PBXSourcesBuildPhase section */ 283 | 284 | /* Begin XCBuildConfiguration section */ 285 | 94B042A02589666C00222EEF /* Debug */ = { 286 | isa = XCBuildConfiguration; 287 | buildSettings = { 288 | ALWAYS_SEARCH_USER_PATHS = NO; 289 | CLANG_ANALYZER_NONNULL = YES; 290 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 291 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 292 | CLANG_CXX_LIBRARY = "libc++"; 293 | CLANG_ENABLE_MODULES = YES; 294 | CLANG_ENABLE_OBJC_ARC = YES; 295 | CLANG_ENABLE_OBJC_WEAK = YES; 296 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 297 | CLANG_WARN_BOOL_CONVERSION = YES; 298 | CLANG_WARN_COMMA = YES; 299 | CLANG_WARN_CONSTANT_CONVERSION = YES; 300 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 301 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 302 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 303 | CLANG_WARN_EMPTY_BODY = YES; 304 | CLANG_WARN_ENUM_CONVERSION = YES; 305 | CLANG_WARN_INFINITE_RECURSION = YES; 306 | CLANG_WARN_INT_CONVERSION = YES; 307 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 308 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 309 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 310 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 311 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 312 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 313 | CLANG_WARN_STRICT_PROTOTYPES = YES; 314 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 315 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 316 | CLANG_WARN_UNREACHABLE_CODE = YES; 317 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 318 | COPY_PHASE_STRIP = NO; 319 | DEBUG_INFORMATION_FORMAT = dwarf; 320 | ENABLE_STRICT_OBJC_MSGSEND = YES; 321 | ENABLE_TESTABILITY = YES; 322 | GCC_C_LANGUAGE_STANDARD = gnu11; 323 | GCC_DYNAMIC_NO_PIC = NO; 324 | GCC_NO_COMMON_BLOCKS = YES; 325 | GCC_OPTIMIZATION_LEVEL = 0; 326 | GCC_PREPROCESSOR_DEFINITIONS = ( 327 | "DEBUG=1", 328 | "$(inherited)", 329 | ); 330 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 331 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 332 | GCC_WARN_UNDECLARED_SELECTOR = YES; 333 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 334 | GCC_WARN_UNUSED_FUNCTION = YES; 335 | GCC_WARN_UNUSED_VARIABLE = YES; 336 | MACOSX_DEPLOYMENT_TARGET = 12.0; 337 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 338 | MTL_FAST_MATH = YES; 339 | ONLY_ACTIVE_ARCH = YES; 340 | SDKROOT = macosx; 341 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 342 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 343 | }; 344 | name = Debug; 345 | }; 346 | 94B042A12589666C00222EEF /* Release */ = { 347 | isa = XCBuildConfiguration; 348 | buildSettings = { 349 | ALWAYS_SEARCH_USER_PATHS = NO; 350 | CLANG_ANALYZER_NONNULL = YES; 351 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 352 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 353 | CLANG_CXX_LIBRARY = "libc++"; 354 | CLANG_ENABLE_MODULES = YES; 355 | CLANG_ENABLE_OBJC_ARC = YES; 356 | CLANG_ENABLE_OBJC_WEAK = YES; 357 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 358 | CLANG_WARN_BOOL_CONVERSION = YES; 359 | CLANG_WARN_COMMA = YES; 360 | CLANG_WARN_CONSTANT_CONVERSION = YES; 361 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 362 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 363 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 364 | CLANG_WARN_EMPTY_BODY = YES; 365 | CLANG_WARN_ENUM_CONVERSION = YES; 366 | CLANG_WARN_INFINITE_RECURSION = YES; 367 | CLANG_WARN_INT_CONVERSION = YES; 368 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 369 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 370 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 371 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 372 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 373 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 374 | CLANG_WARN_STRICT_PROTOTYPES = YES; 375 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 376 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 377 | CLANG_WARN_UNREACHABLE_CODE = YES; 378 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 379 | COPY_PHASE_STRIP = NO; 380 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 381 | ENABLE_NS_ASSERTIONS = NO; 382 | ENABLE_STRICT_OBJC_MSGSEND = YES; 383 | GCC_C_LANGUAGE_STANDARD = gnu11; 384 | GCC_NO_COMMON_BLOCKS = YES; 385 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 386 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 387 | GCC_WARN_UNDECLARED_SELECTOR = YES; 388 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 389 | GCC_WARN_UNUSED_FUNCTION = YES; 390 | GCC_WARN_UNUSED_VARIABLE = YES; 391 | MACOSX_DEPLOYMENT_TARGET = 12.0; 392 | MTL_ENABLE_DEBUG_INFO = NO; 393 | MTL_FAST_MATH = YES; 394 | SDKROOT = macosx; 395 | SWIFT_COMPILATION_MODE = wholemodule; 396 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 397 | }; 398 | name = Release; 399 | }; 400 | 94B042A32589666C00222EEF /* Debug */ = { 401 | isa = XCBuildConfiguration; 402 | buildSettings = { 403 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 404 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 405 | CODE_SIGN_ENTITLEMENTS = HomebrUI/HomebrUI.entitlements; 406 | CODE_SIGN_IDENTITY = "-"; 407 | CODE_SIGN_STYLE = Automatic; 408 | COMBINE_HIDPI_IMAGES = YES; 409 | DEVELOPMENT_ASSET_PATHS = "\"HomebrUI/Preview Content\""; 410 | DEVELOPMENT_TEAM = P2RDBY63TT; 411 | ENABLE_HARDENED_RUNTIME = YES; 412 | ENABLE_PREVIEWS = YES; 413 | INFOPLIST_FILE = HomebrUI/Info.plist; 414 | LD_RUNPATH_SEARCH_PATHS = ( 415 | "$(inherited)", 416 | "@executable_path/../Frameworks", 417 | ); 418 | MACOSX_DEPLOYMENT_TARGET = 12.0; 419 | PRODUCT_BUNDLE_IDENTIFIER = org.rypac.HomebrUI; 420 | PRODUCT_NAME = "$(TARGET_NAME)"; 421 | SWIFT_VERSION = 5.0; 422 | }; 423 | name = Debug; 424 | }; 425 | 94B042A42589666C00222EEF /* Release */ = { 426 | isa = XCBuildConfiguration; 427 | buildSettings = { 428 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 429 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 430 | CODE_SIGN_ENTITLEMENTS = HomebrUI/HomebrUI.entitlements; 431 | CODE_SIGN_IDENTITY = "-"; 432 | CODE_SIGN_STYLE = Automatic; 433 | COMBINE_HIDPI_IMAGES = YES; 434 | DEVELOPMENT_ASSET_PATHS = "\"HomebrUI/Preview Content\""; 435 | DEVELOPMENT_TEAM = P2RDBY63TT; 436 | ENABLE_HARDENED_RUNTIME = YES; 437 | ENABLE_PREVIEWS = YES; 438 | INFOPLIST_FILE = HomebrUI/Info.plist; 439 | LD_RUNPATH_SEARCH_PATHS = ( 440 | "$(inherited)", 441 | "@executable_path/../Frameworks", 442 | ); 443 | MACOSX_DEPLOYMENT_TARGET = 12.0; 444 | PRODUCT_BUNDLE_IDENTIFIER = org.rypac.HomebrUI; 445 | PRODUCT_NAME = "$(TARGET_NAME)"; 446 | SWIFT_VERSION = 5.0; 447 | }; 448 | name = Release; 449 | }; 450 | /* End XCBuildConfiguration section */ 451 | 452 | /* Begin XCConfigurationList section */ 453 | 94B0428D2589666B00222EEF /* Build configuration list for PBXProject "HomebrUI" */ = { 454 | isa = XCConfigurationList; 455 | buildConfigurations = ( 456 | 94B042A02589666C00222EEF /* Debug */, 457 | 94B042A12589666C00222EEF /* Release */, 458 | ); 459 | defaultConfigurationIsVisible = 0; 460 | defaultConfigurationName = Release; 461 | }; 462 | 94B042A22589666C00222EEF /* Build configuration list for PBXNativeTarget "HomebrUI" */ = { 463 | isa = XCConfigurationList; 464 | buildConfigurations = ( 465 | 94B042A32589666C00222EEF /* Debug */, 466 | 94B042A42589666C00222EEF /* Release */, 467 | ); 468 | defaultConfigurationIsVisible = 0; 469 | defaultConfigurationName = Release; 470 | }; 471 | /* End XCConfigurationList section */ 472 | }; 473 | rootObject = 94B0428A2589666B00222EEF /* Project object */; 474 | } 475 | -------------------------------------------------------------------------------- /HomebrUI/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /HomebrUI/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "mac", 5 | "scale" : "1x", 6 | "size" : "16x16" 7 | }, 8 | { 9 | "idiom" : "mac", 10 | "scale" : "2x", 11 | "size" : "16x16" 12 | }, 13 | { 14 | "idiom" : "mac", 15 | "scale" : "1x", 16 | "size" : "32x32" 17 | }, 18 | { 19 | "idiom" : "mac", 20 | "scale" : "2x", 21 | "size" : "32x32" 22 | }, 23 | { 24 | "idiom" : "mac", 25 | "scale" : "1x", 26 | "size" : "128x128" 27 | }, 28 | { 29 | "idiom" : "mac", 30 | "scale" : "2x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "idiom" : "mac", 35 | "scale" : "1x", 36 | "size" : "256x256" 37 | }, 38 | { 39 | "idiom" : "mac", 40 | "scale" : "2x", 41 | "size" : "256x256" 42 | }, 43 | { 44 | "idiom" : "mac", 45 | "scale" : "1x", 46 | "size" : "512x512" 47 | }, 48 | { 49 | "idiom" : "mac", 50 | "scale" : "2x", 51 | "size" : "512x512" 52 | } 53 | ], 54 | "info" : { 55 | "author" : "xcode", 56 | "version" : 1 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /HomebrUI/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /HomebrUI/Commands/AppCommands.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct AppCommands: Commands { 4 | let repository: PackageRepository 5 | 6 | var body: some Commands { 7 | SidebarCommands() 8 | ViewCommands() 9 | PackageCommands(repository: repository) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /HomebrUI/Commands/PackageCommands.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct PackageCommands: Commands { 4 | let repository: PackageRepository 5 | 6 | var body: some Commands { 7 | CommandMenu("Packages") { 8 | PackageCommandsContent(repository: repository) 9 | } 10 | } 11 | } 12 | 13 | private struct PackageCommandsContent: View { 14 | let repository: PackageRepository 15 | 16 | @FocusedBinding(\.selectedPackage) var selectedPackage 17 | 18 | var body: some View { 19 | Section { 20 | Button("Refresh") { 21 | repository.refresh() 22 | } 23 | .keyboardShortcut("r", modifiers: .command) 24 | Button("Update") { 25 | // TODO: Implement update 26 | } 27 | .keyboardShortcut("u", modifiers: .command) 28 | .disabled(true) 29 | } 30 | Section { 31 | Button("Uninstall") { 32 | if let package = selectedPackage { 33 | repository.uninstall(id: package.id) 34 | } 35 | } 36 | .keyboardShortcut("⌫", modifiers: [.command]) 37 | .disabled(selectedPackage == nil) 38 | } 39 | } 40 | } 41 | 42 | private struct SelectedPackageKey: FocusedValueKey { 43 | typealias Value = Binding 44 | } 45 | 46 | extension FocusedValues { 47 | var selectedPackage: Binding? { 48 | get { self[SelectedPackageKey.self] } 49 | set { self[SelectedPackageKey.self] = newValue } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /HomebrUI/Commands/ViewCommands.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ViewCommands: Commands { 4 | var body: some Commands { 5 | CommandGroup(after: .sidebar) { 6 | ViewCommandsContent() 7 | } 8 | } 9 | } 10 | 11 | private struct ViewCommandsContent: View { 12 | @FocusedBinding(\.selectedSidebarItem) var selectedSidebarItem 13 | 14 | var body: some View { 15 | Section { 16 | Button("Installed") { 17 | selectedSidebarItem = .installed 18 | } 19 | .keyboardShortcut("1", modifiers: .command) 20 | Button("Search") { 21 | selectedSidebarItem = .search 22 | } 23 | .keyboardShortcut("2", modifiers: .command) 24 | } 25 | } 26 | } 27 | 28 | private struct SelectedSidebarItemKey: FocusedValueKey { 29 | typealias Value = Binding 30 | } 31 | 32 | extension FocusedValues { 33 | var selectedSidebarItem: Binding? { 34 | get { self[SelectedSidebarItemKey.self] } 35 | set { self[SelectedSidebarItemKey.self] = newValue } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /HomebrUI/Extensions/Binding.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | extension Binding { 4 | var optional: Binding { 5 | Binding( 6 | get: { wrappedValue }, 7 | set: { newValue in 8 | if let value = newValue { 9 | wrappedValue = value 10 | } 11 | } 12 | ) 13 | } 14 | 15 | func nonOptional(withDefault defaultValue: T) -> Binding where Value == T? { 16 | Binding( 17 | get: { wrappedValue ?? defaultValue }, 18 | set: { wrappedValue = $0 } 19 | ) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /HomebrUI/Extensions/Combine.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | 3 | extension AnyPublisher { 4 | static func just(_ value: Output) -> Self { 5 | Just(value) 6 | .setFailureType(to: Failure.self) 7 | .eraseToAnyPublisher() 8 | } 9 | 10 | static var empty: Self { 11 | Empty(completeImmediately: true) 12 | .eraseToAnyPublisher() 13 | } 14 | 15 | static var never: Self { 16 | Empty(completeImmediately: false) 17 | .eraseToAnyPublisher() 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /HomebrUI/HomebrUI.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-only 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /HomebrUI/HomebrUIApp.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | @main 4 | struct HomebrUIApp: App { 5 | private let packageRepository: PackageRepository 6 | private let operationRepository: OperationRepository 7 | 8 | init() { 9 | let homebrew = Homebrew() 10 | packageRepository = PackageRepository(homebrew: homebrew) 11 | operationRepository = OperationRepository(homebrew: homebrew) 12 | } 13 | 14 | var body: some Scene { 15 | WindowGroup { 16 | ContentView(packageRepository: packageRepository, operationRepository: operationRepository) 17 | .frame(minHeight: 400, idealHeight: 700) 18 | } 19 | .commands { 20 | AppCommands(repository: packageRepository) 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /HomebrUI/Homebrew/AsyncOperation.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// An asynchronous `Operation` with proper multi-threading and KVO support. 4 | /// 5 | /// Implementation from: https://www.avanderlee.com/swift/asynchronous-operations/ 6 | open class AsyncOperation: Operation { 7 | private let lockQueue = DispatchQueue(label: "com.swiftlee.asyncoperation", attributes: .concurrent) 8 | 9 | open override var isAsynchronous: Bool { 10 | true 11 | } 12 | 13 | private var _isExecuting: Bool = false 14 | open override private(set) var isExecuting: Bool { 15 | get { 16 | lockQueue.sync { 17 | _isExecuting 18 | } 19 | } 20 | set { 21 | willChangeValue(forKey: "isExecuting") 22 | lockQueue.sync(flags: [.barrier]) { 23 | _isExecuting = newValue 24 | } 25 | didChangeValue(forKey: "isExecuting") 26 | } 27 | } 28 | 29 | private var _isFinished: Bool = false 30 | open override private(set) var isFinished: Bool { 31 | get { 32 | lockQueue.sync { 33 | _isFinished 34 | } 35 | } 36 | set { 37 | willChangeValue(forKey: "isFinished") 38 | lockQueue.sync(flags: [.barrier]) { 39 | _isFinished = newValue 40 | } 41 | didChangeValue(forKey: "isFinished") 42 | } 43 | } 44 | 45 | open override func start() { 46 | guard !isCancelled else { 47 | finish() 48 | return 49 | } 50 | 51 | isFinished = false 52 | isExecuting = true 53 | main() 54 | } 55 | 56 | open override func main() { 57 | fatalError("Subclasses must implement `main` without overriding super.") 58 | } 59 | 60 | func finish() { 61 | isExecuting = false 62 | isFinished = true 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /HomebrUI/Homebrew/Homebrew.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Combine 3 | 4 | struct Homebrew { 5 | private let queue: HomebrewOperationQueue 6 | 7 | init(configuration: HomebrewConfiguration = .default) { 8 | queue = HomebrewOperationQueue(configuration: configuration) 9 | } 10 | 11 | var operationPublisher: AnyPublisher { 12 | queue.operationPublisher 13 | } 14 | 15 | func installedPackages() -> AnyPublisher { 16 | queue.run(.list) 17 | .tryMap { result in 18 | guard result.status == 0 else { 19 | throw HomebrewError(processResult: result) 20 | } 21 | return try JSONDecoder().decode(HomebrewInfo.self, from: result.standardOutput) 22 | } 23 | .eraseToAnyPublisher() 24 | } 25 | 26 | func search(for query: String) -> AnyPublisher { 27 | queue.run(.search(query)) 28 | .tryMap { result in 29 | guard result.status == 0 else { 30 | let errorMessage = String(decoding: result.standardError, as: UTF8.self) 31 | guard errorMessage.hasPrefix("Error: No formulae or casks found for") else { 32 | throw HomebrewError(processResult: result) 33 | } 34 | return HomebrewSearchInfo(formulae: [], casks: []) 35 | } 36 | 37 | enum SearchResult { case formulae, cask } 38 | var searchResult: SearchResult? 39 | 40 | return String(decoding: result.standardOutput, as: UTF8.self) 41 | .split(separator: "\n") 42 | .reduce(into: HomebrewSearchInfo(formulae: [], casks: [])) { search, line in 43 | switch line { 44 | case "==> Formulae": 45 | searchResult = .formulae 46 | case "==> Casks": 47 | searchResult = .cask 48 | case let line where !line.isEmpty: 49 | if searchResult == .formulae { 50 | search.formulae.append(HomebrewID(rawValue: String(line))) 51 | } else if searchResult == .cask { 52 | search.casks.append(HomebrewID(rawValue: String(line))) 53 | } 54 | default: 55 | break 56 | } 57 | } 58 | } 59 | .eraseToAnyPublisher() 60 | } 61 | 62 | func info(for packages: [HomebrewID]) -> AnyPublisher { 63 | if packages.isEmpty { 64 | return .just(HomebrewInfo(formulae: [], casks: [])) 65 | } 66 | 67 | return queue.run(.info(packages)) 68 | .tryMap { result in 69 | guard result.status == 0 else { 70 | throw HomebrewError(processResult: result) 71 | } 72 | return try JSONDecoder().decode(HomebrewInfo.self, from: result.standardOutput) 73 | } 74 | .eraseToAnyPublisher() 75 | } 76 | 77 | func installFormulae(ids: [HomebrewID]) -> AnyPublisher { 78 | queue.run(.install(ids)) 79 | .tryMap { result in 80 | guard result.status == 0 else { 81 | throw HomebrewError(processResult: result) 82 | } 83 | return String(decoding: result.standardOutput, as: UTF8.self) 84 | } 85 | .eraseToAnyPublisher() 86 | } 87 | 88 | func uninstallFormulae(ids: [HomebrewID]) -> AnyPublisher { 89 | queue.run(.uninstall(ids)) 90 | .tryMap { result in 91 | guard result.status == 0 else { 92 | throw HomebrewError(processResult: result) 93 | } 94 | return String(decoding: result.standardOutput, as: UTF8.self) 95 | } 96 | .eraseToAnyPublisher() 97 | } 98 | } 99 | 100 | private struct HomebrewError: LocalizedError { 101 | let status: Int 102 | let output: Data 103 | 104 | var errorDescription: String { 105 | "Exited with status \(status): \(String(decoding: output, as: UTF8.self))" 106 | } 107 | } 108 | 109 | private extension HomebrewError { 110 | init(processResult result: ProcessResult) { 111 | self.init( 112 | status: result.status, 113 | output: result.standardError.isEmpty ? result.standardOutput : result.standardError 114 | ) 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /HomebrUI/Homebrew/HomebrewCommand.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum HomebrewCommand { 4 | case list 5 | case info([HomebrewID]) 6 | case search(String) 7 | case update 8 | case upgrade(HomebrewUpgradeStrategy) 9 | case install([HomebrewID]) 10 | case uninstall([HomebrewID]) 11 | } 12 | 13 | enum HomebrewUpgradeStrategy { 14 | case only([HomebrewID]) 15 | case all 16 | } 17 | 18 | extension HomebrewCommand { 19 | var isBlocking: Bool { 20 | switch self { 21 | case .list, .info, .search: return false 22 | case .update, .upgrade, .install, .uninstall: return true 23 | } 24 | } 25 | 26 | var arguments: [String] { 27 | switch self { 28 | case .list: 29 | return ["info", "--json=v2", "--installed"] 30 | case .info(let formulae): 31 | return ["info", "--json=v2"] + formulae 32 | case .search(let query): 33 | return ["search", query] 34 | case .update: 35 | return ["update"] 36 | case .upgrade(.all): 37 | return ["upgrade"] 38 | case .upgrade(.only(let formulae)): 39 | return ["upgrade"] + formulae 40 | case .install(let formulae): 41 | return ["install"] + formulae 42 | case .uninstall(let formulae): 43 | return ["uninstall"] + formulae 44 | } 45 | } 46 | } 47 | 48 | private func +(_ arguments: [String], ids: [HomebrewID]) -> [String] { 49 | ids.reduce(into: arguments) { $0.append($1.rawValue) } 50 | } 51 | -------------------------------------------------------------------------------- /HomebrUI/Homebrew/HomebrewConfiguration.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct HomebrewConfiguration { 4 | var executablePath: String 5 | } 6 | 7 | extension HomebrewConfiguration { 8 | static let `default` = Self(executablePath: "/usr/local/bin/brew") 9 | } 10 | -------------------------------------------------------------------------------- /HomebrUI/Homebrew/HomebrewModels.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct HomebrewID: Equatable, Hashable, Codable, RawRepresentable { 4 | let rawValue: String 5 | 6 | init(rawValue: String) { 7 | self.rawValue = rawValue 8 | } 9 | } 10 | 11 | extension HomebrewID: ExpressibleByStringLiteral { 12 | init(stringLiteral value: StringLiteralType) { 13 | rawValue = value 14 | } 15 | } 16 | 17 | extension HomebrewID: CustomStringConvertible { 18 | var description: String { 19 | String(describing: rawValue) 20 | } 21 | } 22 | 23 | struct HomebrewInfo: Equatable, Codable { 24 | var formulae: [Formulae] 25 | var casks: [Cask] 26 | } 27 | 28 | struct HomebrewSearchInfo: Equatable { 29 | var formulae: [HomebrewID] 30 | var casks: [HomebrewID] 31 | } 32 | 33 | struct Formulae: Equatable, Identifiable { 34 | let id: HomebrewID 35 | let name: String 36 | let oldName: String? 37 | let aliases: [String] 38 | let description: String 39 | let license: String? 40 | let homepage: URL 41 | let versions: FormulaeVersion 42 | let urls: [String: FormulaeURL] 43 | let revision: Int 44 | let versionScheme: Int 45 | let kegOnly: Bool 46 | let bottleDisabled: Bool 47 | let buildDependencies: [String] 48 | let dependencies: [String] 49 | let recommendedDependencies: [String] 50 | let optionalDependencies: [String] 51 | let pinned: Bool 52 | let outdated: Bool 53 | let deprecated: Bool 54 | let disabled: Bool 55 | let installed: [InstalledPackage] 56 | } 57 | 58 | struct FormulaeVersion: Equatable, Codable { 59 | let stable: String 60 | let head: String? 61 | let bottle: Bool 62 | } 63 | 64 | struct FormulaeURL: Equatable, Codable { 65 | let url: URL 66 | let tag: String? 67 | let revision: String? 68 | } 69 | 70 | struct InstalledPackage: Equatable { 71 | struct Dependency: Equatable { 72 | let name: String 73 | let version: String 74 | } 75 | 76 | let version: String 77 | let runtimeDependencies: [Dependency] 78 | let installedAsDependency: Bool 79 | let installedOnRequest: Bool 80 | } 81 | 82 | extension Formulae: Codable { 83 | enum CodingKeys: String, CodingKey { 84 | case id = "name" 85 | case name = "full_name" 86 | case oldName = "oldname" 87 | case aliases 88 | case description = "desc" 89 | case license 90 | case homepage 91 | case versions 92 | case urls 93 | case revision 94 | case versionScheme = "version_scheme" 95 | case kegOnly = "keg_only" 96 | case bottleDisabled = "bottle_disabled" 97 | case buildDependencies = "build_dependencies" 98 | case dependencies 99 | case recommendedDependencies = "recommended_dependencies" 100 | case optionalDependencies = "optional_dependencies" 101 | case pinned 102 | case outdated 103 | case deprecated 104 | case disabled 105 | case installed 106 | } 107 | } 108 | 109 | extension InstalledPackage: Codable { 110 | enum CodingKeys: String, CodingKey { 111 | case version 112 | case runtimeDependencies = "runtime_dependencies" 113 | case installedAsDependency = "installed_as_dependency" 114 | case installedOnRequest = "installed_on_request" 115 | } 116 | } 117 | 118 | extension InstalledPackage.Dependency: Codable { 119 | enum CodingKeys: String, CodingKey { 120 | case name = "full_name" 121 | case version 122 | } 123 | } 124 | 125 | struct Cask: Equatable, Identifiable { 126 | let id: HomebrewID 127 | let names: [String] 128 | let description: String? 129 | let homepage: URL 130 | let url: URL 131 | let appCast: URL? 132 | let version: String 133 | let sha256: String 134 | let autoUpdates: Bool? 135 | } 136 | 137 | extension Cask: Codable { 138 | enum CodingKeys: String, CodingKey { 139 | case id = "token" 140 | case names = "name" 141 | case description = "desc" 142 | case homepage 143 | case url 144 | case appCast = "appcast" 145 | case version 146 | case sha256 147 | case autoUpdates = "auto_updates" 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /HomebrUI/Homebrew/HomebrewOperationQueue.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | struct HomebrewOperation: Identifiable { 5 | typealias ID = UUID 6 | 7 | enum Status { 8 | case queued 9 | case running 10 | case completed(ProcessResult) 11 | case cancelled 12 | } 13 | 14 | let id: ID 15 | let command: HomebrewCommand 16 | let started: Date 17 | var status: Status 18 | } 19 | 20 | // Homebrew does not allow for concurrent running of commands so all operations 21 | // must be queue and performed serially. 22 | final class HomebrewOperationQueue { 23 | /// The serial queue for running Homebrew commands. 24 | private static let queue: OperationQueue = { 25 | let queue = OperationQueue() 26 | queue.name = "Homebrew Command Queue" 27 | queue.maxConcurrentOperationCount = 1 28 | return queue 29 | }() 30 | 31 | private let operationSubject = PassthroughSubject() 32 | 33 | private let configuration: HomebrewConfiguration 34 | private let now: () -> Date 35 | 36 | init(configuration: HomebrewConfiguration = .default, now: @escaping () -> Date = Date.init) { 37 | self.configuration = configuration 38 | self.now = now 39 | } 40 | 41 | deinit { 42 | Self.queue.cancelAllOperations() 43 | } 44 | 45 | /// A messsage center style publisher which emits new Homebrew operations and status 46 | /// changes as they occur. 47 | var operationPublisher: AnyPublisher { 48 | operationSubject.eraseToAnyPublisher() 49 | } 50 | 51 | /// Runs a Homebrew command and returns the result of running the command, optionally 52 | /// completing with an error if the operation is cancelled. 53 | func run(_ command: HomebrewCommand) -> AnyPublisher { 54 | let id = HomebrewOperation.ID() 55 | return operationSubject 56 | .tryCompactMap { operation in 57 | guard operation.id == id else { 58 | return nil 59 | } 60 | switch operation.status { 61 | case .completed(let result): return result 62 | case .cancelled: throw HomebrewCancellationError(id: id) 63 | case .queued, .running: return nil 64 | } 65 | } 66 | .first() 67 | .handleEvents( 68 | receiveSubscription: { [weak self] _ in 69 | self?.enqueue(id: id, command: command) 70 | }, 71 | receiveCancel: { [weak self] in 72 | self?.cancel(id: id) 73 | } 74 | ) 75 | .eraseToAnyPublisher() 76 | } 77 | 78 | @discardableResult 79 | func enqueue(_ command: HomebrewCommand) -> HomebrewOperation.ID { 80 | let id = HomebrewOperation.ID() 81 | enqueue(id: id, command: command) 82 | return id 83 | } 84 | 85 | func cancel(id: HomebrewOperation.ID) { 86 | if let process = Self.queue.operations.first(where: { ($0 as? ProcessOperation)?.id == id }) { 87 | process.cancel() 88 | } 89 | } 90 | 91 | private func enqueue(id: HomebrewOperation.ID, command: HomebrewCommand) { 92 | // Prevent queuing of identical pending commands. 93 | if Self.queue.operations.contains(where: { ($0 as? ProcessOperation)?.id == id }) { 94 | return 95 | } 96 | 97 | let operation = HomebrewOperation(id: id, command: command, started: now(), status: .queued) 98 | 99 | operationSubject.send(operation) 100 | 101 | Self.queue.addOperation( 102 | ProcessOperation( 103 | id: operation.id, 104 | url: URL(fileURLWithPath: configuration.executablePath), 105 | arguments: operation.command.arguments, 106 | startHandler: { [operationSubject] in 107 | var operation = operation 108 | operation.status = .running 109 | operationSubject.send(operation) 110 | }, 111 | cancellationHandler: { [operationSubject] in 112 | var operation = operation 113 | operation.status = .cancelled 114 | operationSubject.send(operation) 115 | }, 116 | completionHandler: { [operationSubject] result in 117 | var operation = operation 118 | operation.status = .completed(result) 119 | operationSubject.send(operation) 120 | } 121 | ) 122 | ) 123 | } 124 | } 125 | 126 | private struct HomebrewCancellationError: LocalizedError { 127 | let id: HomebrewOperation.ID 128 | 129 | var errorDescription: String { 130 | "Cancelled running command: \(id)" 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /HomebrUI/Homebrew/Process.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | struct ProcessHandle: Identifiable { 5 | let id: Int 6 | let cancel: () -> Void 7 | } 8 | 9 | struct ProcessResult: Equatable { 10 | let status: Int 11 | let standardOutput: Data 12 | let standardError: Data 13 | } 14 | 15 | extension Process { 16 | static func run( 17 | for url: URL, 18 | arguments: [String] = [], 19 | qualityOfService: QualityOfService = .default, 20 | handler: @escaping (ProcessResult) -> Void 21 | ) throws -> ProcessHandle { 22 | let task = Process() 23 | task.executableURL = url 24 | task.arguments = arguments 25 | task.qualityOfService = qualityOfService 26 | 27 | let outputPipe = Pipe() 28 | let errorPipe = Pipe() 29 | 30 | task.standardOutput = outputPipe 31 | task.standardError = errorPipe 32 | 33 | try task.run() 34 | 35 | var outputData = Data() 36 | var errorData = Data() 37 | 38 | processQueue.addOperation { 39 | outputPipe.read(into: &outputData) 40 | } 41 | processQueue.addOperation { 42 | errorPipe.read(into: &errorData) 43 | } 44 | processQueue.addOperation { 45 | task.waitUntilExit() 46 | } 47 | 48 | processQueue.addBarrierBlock { 49 | handler( 50 | ProcessResult( 51 | status: Int(task.terminationStatus), 52 | standardOutput: outputData, 53 | standardError: errorData 54 | ) 55 | ) 56 | } 57 | 58 | return ProcessHandle( 59 | id: Int(task.processIdentifier), 60 | cancel: task.terminate 61 | ) 62 | } 63 | 64 | private static let processQueue: OperationQueue = { 65 | let queue = OperationQueue() 66 | queue.name = "Process Queue" 67 | queue.maxConcurrentOperationCount = OperationQueue.defaultMaxConcurrentOperationCount 68 | return queue 69 | }() 70 | } 71 | 72 | private extension Pipe { 73 | func read(into buffer: inout Data) { 74 | var availableData = Data() 75 | repeat { 76 | availableData = fileHandleForReading.availableData 77 | buffer.append(availableData) 78 | } while !availableData.isEmpty 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /HomebrUI/Homebrew/ProcessOperation.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | final class ProcessOperation: AsyncOperation, Identifiable { 4 | typealias ID = UUID 5 | 6 | let id: ID 7 | private let url: URL 8 | private let arguments: [String] 9 | private let startHandler: () -> Void 10 | private let cancellationHandler: () -> Void 11 | private let completionHandler: (ProcessResult) -> Void 12 | 13 | private var processHandle: ProcessHandle? 14 | 15 | init( 16 | id: ID, 17 | url: URL, 18 | arguments: [String] = [], 19 | qualityOfService: QualityOfService = .default, 20 | startHandler: @escaping () -> Void, 21 | cancellationHandler: @escaping () -> Void, 22 | completionHandler: @escaping (ProcessResult) -> Void 23 | ) { 24 | self.id = id 25 | self.url = url 26 | self.arguments = arguments 27 | self.startHandler = startHandler 28 | self.cancellationHandler = cancellationHandler 29 | self.completionHandler = completionHandler 30 | super.init() 31 | self.qualityOfService = qualityOfService 32 | } 33 | 34 | override func main() { 35 | startHandler() 36 | 37 | processHandle = try? Process.run( 38 | for: url, 39 | arguments: arguments, 40 | qualityOfService: qualityOfService 41 | ) { [weak self] result in 42 | self?.completionHandler(result) 43 | self?.finish() 44 | } 45 | } 46 | 47 | override func cancel() { 48 | processHandle?.cancel() 49 | cancellationHandler() 50 | super.cancel() 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /HomebrUI/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | LSApplicationCategoryType 22 | public.app-category.developer-tools 23 | LSMinimumSystemVersion 24 | $(MACOSX_DEPLOYMENT_TARGET) 25 | 26 | 27 | -------------------------------------------------------------------------------- /HomebrUI/Models/OperationRepository.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | final class OperationRepository { 5 | private let accumulatedOperations = CurrentValueSubject<[HomebrewOperation], Never>([]) 6 | 7 | private var cancellables = Set() 8 | 9 | init(homebrew: Homebrew) { 10 | homebrew.operationPublisher 11 | .scan([HomebrewOperation.ID: HomebrewOperation]()) { operations, operation in 12 | var operations = operations 13 | operations[operation.id] = operation 14 | return operations 15 | } 16 | .map { operations in 17 | operations.values.sorted(by: { $0.started > $1.started }) 18 | } 19 | .receive(on: DispatchQueue.main) 20 | .sink { [accumulatedOperations] operations in 21 | accumulatedOperations.send(operations) 22 | } 23 | .store(in: &cancellables) 24 | } 25 | 26 | deinit { 27 | cancellables.forEach { cancellable in 28 | cancellable.cancel() 29 | } 30 | cancellables.removeAll() 31 | } 32 | } 33 | 34 | extension OperationRepository { 35 | var operations: AnyPublisher<[HomebrewOperation], Never> { 36 | accumulatedOperations.eraseToAnyPublisher() 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /HomebrUI/Models/Package.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct Package: Identifiable, Equatable { 4 | let id: HomebrewID 5 | var name: String 6 | var description: String? 7 | var homepage: URL 8 | var installedVersion: String? 9 | var latestVersion: String 10 | } 11 | 12 | enum PackageActivity { 13 | case installing 14 | case updating 15 | case uninstalling 16 | } 17 | 18 | @dynamicMemberLookup 19 | struct PackageDetail: Identifiable { 20 | var id: Package.ID { package.id } 21 | var package: Package 22 | var activity: PackageActivity? 23 | 24 | var isInstalled: Bool { package.installedVersion != nil } 25 | 26 | subscript(dynamicMember keyPath: WritableKeyPath) -> Property { 27 | get { package[keyPath: keyPath] } 28 | set { package[keyPath: keyPath] = newValue } 29 | } 30 | } 31 | 32 | extension Package { 33 | init(formulae: Formulae) { 34 | let installedVersion: String? 35 | if let installed = formulae.installed.first, installed.installedOnRequest { 36 | installedVersion = installed.version 37 | } else { 38 | installedVersion = nil 39 | } 40 | 41 | self.init( 42 | id: formulae.id, 43 | name: formulae.name, 44 | description: formulae.description, 45 | homepage: formulae.homepage, 46 | installedVersion: installedVersion, 47 | latestVersion: formulae.versions.stable 48 | ) 49 | } 50 | 51 | init(cask: Cask) { 52 | self.init( 53 | id: cask.id, 54 | name: cask.names.first ?? cask.id.rawValue, 55 | description: cask.description, 56 | homepage: cask.homepage, 57 | installedVersion: cask.version, 58 | latestVersion: cask.version 59 | ) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /HomebrUI/Models/PackageRepository.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | struct Packages { 5 | var formulae: [Package] 6 | var casks: [Package] 7 | } 8 | 9 | extension Packages { 10 | var count: Int { formulae.count + casks.count } 11 | var isEmpty: Bool { formulae.isEmpty && casks.isEmpty } 12 | var hasFormulae: Bool { !formulae.isEmpty } 13 | var hasCasks: Bool { !casks.isEmpty } 14 | } 15 | 16 | final class PackageRepository { 17 | private enum PackageState { 18 | case empty 19 | case loaded(Packages) 20 | } 21 | 22 | private enum RefreshState { 23 | case idle 24 | case refreshing 25 | } 26 | 27 | private struct ActivityState: Equatable { 28 | enum Action { case install, uninstall, update } 29 | enum Status { case started, completed, failed } 30 | 31 | var id: Package.ID 32 | var action: Action 33 | var status: Status 34 | } 35 | 36 | private enum Action: Equatable { 37 | case refresh(RefreshStrategy) 38 | case install(Package.ID) 39 | case uninstall(Package.ID) 40 | } 41 | 42 | private enum RefreshStrategy: Equatable { 43 | case installed 44 | case only(Package.ID) 45 | } 46 | 47 | private let packageState = CurrentValueSubject(.empty) 48 | private let refreshState = CurrentValueSubject(.idle) 49 | private let activityState = PassthroughSubject() 50 | private let actions = PassthroughSubject() 51 | private let homebrew: Homebrew 52 | 53 | private var cancellables = Set() 54 | 55 | init(homebrew: Homebrew) { 56 | self.homebrew = homebrew 57 | 58 | actions 59 | .filter { $0 == .refresh(.installed) } 60 | .map { [refreshState] _ in 61 | homebrew.installedPackages() 62 | .handleEvents( 63 | receiveSubscription: { _ in 64 | refreshState.send(.refreshing) 65 | }, 66 | receiveCompletion: { _ in 67 | refreshState.send(.idle) 68 | } 69 | ) 70 | .map { info in 71 | Packages( 72 | formulae: info.formulae.compactMap { formulae in 73 | guard formulae.installed.first?.installedOnRequest == true else { 74 | return nil 75 | } 76 | return Package(formulae: formulae) 77 | }, 78 | casks: info.casks.map(Package.init(cask:)) 79 | ) 80 | } 81 | .catch { _ in 82 | Just(Packages(formulae: [], casks: [])) 83 | } 84 | } 85 | .switchToLatest() 86 | .receive(on: DispatchQueue.main) 87 | .sink { [packageState] installedPackages in 88 | packageState.send(.loaded(installedPackages)) 89 | } 90 | .store(in: &cancellables) 91 | 92 | let installPackage = actions 93 | .compactMap { action -> AnyPublisher? in 94 | guard case let .install(id) = action else { 95 | return nil 96 | } 97 | return homebrew.installFormulae(ids: [id]) 98 | .map { _ in .completed } 99 | .catch { _ in Just(.failed) } 100 | .prepend(.started) 101 | .map { ActivityState(id: id, action: .install, status: $0) } 102 | .eraseToAnyPublisher() 103 | } 104 | 105 | let uninstallPackage = actions 106 | .compactMap { action -> AnyPublisher? in 107 | guard case let .uninstall(id) = action else { 108 | return nil 109 | } 110 | return homebrew.uninstallFormulae(ids: [id]) 111 | .map { _ in .completed } 112 | .catch { _ in Just(.failed) } 113 | .prepend(.started) 114 | .map { ActivityState(id: id, action: .uninstall, status: $0) } 115 | .eraseToAnyPublisher() 116 | } 117 | 118 | Publishers.Merge(installPackage, uninstallPackage) 119 | .switchToLatest() 120 | .receive(on: DispatchQueue.main) 121 | .sink { [actions, packageState, activityState] state in 122 | switch (state.action, state.status) { 123 | case (.install, .completed): 124 | actions.send(.refresh(.installed)) 125 | actions.send(.refresh(.only(state.id))) 126 | 127 | case (.uninstall, .completed): 128 | // Remove locally installed version before refresh occurs. 129 | if case var .loaded(packages) = packageState.value { 130 | if let index = packages.formulae.firstIndex(where: { $0.id == state.id }) { 131 | packages.formulae[index].installedVersion = nil 132 | } else if let index = packages.casks.firstIndex(where: { $0.id == state.id }) { 133 | packages.casks[index].installedVersion = nil 134 | } 135 | packageState.send(.loaded(packages)) 136 | } 137 | 138 | actions.send(.refresh(.installed)) 139 | actions.send(.refresh(.only(state.id))) 140 | 141 | default: 142 | break 143 | } 144 | 145 | activityState.send(state) 146 | } 147 | .store(in: &cancellables) 148 | } 149 | 150 | deinit { 151 | cancellables.forEach { cancellable in 152 | cancellable.cancel() 153 | } 154 | cancellables.removeAll() 155 | } 156 | 157 | func refresh() { 158 | actions.send(.refresh(.installed)) 159 | } 160 | 161 | func refresh(id: Package.ID) { 162 | actions.send(.refresh(.only(id))) 163 | } 164 | 165 | func install(id: Package.ID) { 166 | actions.send(.install(id)) 167 | } 168 | 169 | func uninstall(id: Package.ID) { 170 | actions.send(.uninstall(id)) 171 | } 172 | } 173 | 174 | extension PackageRepository { 175 | var packages: AnyPublisher { 176 | packageState 177 | .compactMap { state in 178 | guard case let .loaded(packages) = state else { 179 | return nil 180 | } 181 | return packages 182 | } 183 | .eraseToAnyPublisher() 184 | } 185 | 186 | var refreshing: AnyPublisher { 187 | refreshState 188 | .map { $0 == .refreshing } 189 | .eraseToAnyPublisher() 190 | } 191 | 192 | func searchForPackage(withName query: String) -> AnyPublisher { 193 | homebrew.search(for: query) 194 | .map { [homebrew] result in 195 | Publishers.Zip( 196 | homebrew.info(for: result.formulae).map(\.formulae), 197 | homebrew.info(for: result.casks).map(\.casks) 198 | ) 199 | .map(HomebrewInfo.init) 200 | } 201 | .switchToLatest() 202 | .combineLatest(installedVersions.setFailureType(to: Error.self)) 203 | .map { info, versions in 204 | var packages = Packages(formulae: [], casks: []) 205 | for formulae in info.formulae { 206 | var package = Package(formulae: formulae) 207 | package.installedVersion = versions[formulae.id] 208 | packages.formulae.append(package) 209 | } 210 | for cask in info.casks { 211 | var package = Package(cask: cask) 212 | package.installedVersion = versions[cask.id] 213 | packages.casks.append(package) 214 | } 215 | return packages 216 | } 217 | .eraseToAnyPublisher() 218 | } 219 | 220 | private var installedVersions: AnyPublisher<[Package.ID: String], Never> { 221 | packageState 222 | .map { state in 223 | guard case let .loaded(packages) = state else { 224 | return [:] 225 | } 226 | var packageVersions: [Package.ID: String] = [:] 227 | for formulae in packages.formulae { 228 | packageVersions[formulae.id] = formulae.installedVersion 229 | } 230 | for cask in packages.casks { 231 | packageVersions[cask.id] = cask.installedVersion 232 | } 233 | return packageVersions 234 | } 235 | .eraseToAnyPublisher() 236 | } 237 | 238 | func detail(for package: Package) -> AnyPublisher { 239 | refreshedPackage(id: package.id) 240 | .prepend(package) 241 | .removeDuplicates() 242 | .map { [activityState] package in 243 | activityState 244 | .filter { $0.id == package.id } 245 | .scan(PackageDetail(package: package, activity: nil)) { detail, state in 246 | var detail = detail 247 | switch (state.action, state.status) { 248 | case (.uninstall, .started): 249 | detail.activity = .uninstalling 250 | case (.uninstall, .completed): 251 | detail.activity = nil 252 | detail.package.installedVersion = nil 253 | case (.install, .started): 254 | detail.activity = .installing 255 | case (.install, .completed): 256 | detail.activity = nil 257 | detail.package.installedVersion = package.latestVersion 258 | default: 259 | detail.activity = nil 260 | } 261 | return detail 262 | } 263 | .prepend(PackageDetail(package: package, activity: nil)) 264 | } 265 | .switchToLatest() 266 | .eraseToAnyPublisher() 267 | } 268 | 269 | private func refreshedPackage(id: Package.ID) -> AnyPublisher { 270 | let installedPackageVersion = packageState 271 | .map { state -> String? in 272 | guard case let .loaded(packages) = state else { 273 | return nil 274 | } 275 | 276 | return packages[id]?.installedVersion 277 | } 278 | .setFailureType(to: Error.self) 279 | 280 | let refreshedPackage = actions 281 | .compactMap { [homebrew] action -> AnyPublisher? in 282 | guard case .refresh(.only(id)) = action else { 283 | return nil 284 | } 285 | 286 | return homebrew.info(for: [id]) 287 | .tryMap { info in 288 | guard let package = info[id] else { 289 | throw MissingPackageError(id: id) 290 | } 291 | return package 292 | } 293 | .eraseToAnyPublisher() 294 | } 295 | .switchToLatest() 296 | .eraseToAnyPublisher() 297 | 298 | return Publishers.CombineLatest(refreshedPackage, installedPackageVersion) 299 | .map { package, version in 300 | var package = package 301 | package.installedVersion = version 302 | return package 303 | } 304 | .eraseToAnyPublisher() 305 | } 306 | } 307 | 308 | private struct MissingPackageError: LocalizedError { 309 | let id: Package.ID 310 | var errorDescription: String { "Missing package \"\(id)\"" } 311 | } 312 | 313 | private extension Packages { 314 | subscript(id: Package.ID) -> Package? { 315 | formulae.first(where: { $0.id == id }) ?? casks.first(where: { $0.id == id }) 316 | } 317 | } 318 | 319 | private extension HomebrewInfo { 320 | subscript(id: Package.ID) -> Package? { 321 | if let formulae = formulae.first(where: { $0.id == id }) { 322 | return Package(formulae: formulae) 323 | } 324 | if let cask = casks.first(where: { $0.id == id }) { 325 | return Package(cask: cask) 326 | } 327 | return nil 328 | } 329 | } 330 | -------------------------------------------------------------------------------- /HomebrUI/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /HomebrUI/Views/ContentView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ContentView: View { 4 | let packageRepository: PackageRepository 5 | let operationRepository: OperationRepository 6 | 7 | @State private var isInfoPopoverPresented: Bool = false 8 | 9 | @Environment(\.scenePhase) private var scenePhase 10 | 11 | var body: some View { 12 | SidebarView(repository: packageRepository) 13 | .toolbar { 14 | ToolbarView( 15 | operations: operationRepository.operations, 16 | isInfoPopoverPresented: $isInfoPopoverPresented 17 | ) 18 | } 19 | .onChange(of: scenePhase) { newScenePhase in 20 | if newScenePhase == .active { 21 | packageRepository.refresh() 22 | } 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /HomebrUI/Views/Input.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | 3 | /// Similar to the builtin `Published` property wrapper except that it will not automatically 4 | /// trigger `objectWillChange` the publisher that SwiftUI uses to connect `ObservableObject`. 5 | /// 6 | /// Inspired by: https://www.swiftbysundell.com/articles/connecting-and-merging-combine-publishers-in-swift/ 7 | @propertyWrapper 8 | public struct Input { 9 | private let subject: CurrentValueSubject 10 | 11 | public init(wrappedValue: Value) { 12 | subject = CurrentValueSubject(wrappedValue) 13 | } 14 | 15 | public var wrappedValue: Value { 16 | get { subject.value } 17 | set { subject.send(newValue) } 18 | } 19 | 20 | public var projectedValue: AnyPublisher { 21 | subject.eraseToAnyPublisher() 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /HomebrUI/Views/InstalledPackagesView.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import SwiftUI 3 | 4 | final class InstalledPackagesViewModel: ObservableObject { 5 | struct Environment { 6 | var packages: AnyPublisher 7 | var isRefreshing: AnyPublisher 8 | var detail: (Package) -> AnyPublisher 9 | var load: (Package.ID) -> Void 10 | var install: (Package.ID) -> Void 11 | var uninstall: (Package.ID) -> Void 12 | } 13 | 14 | enum State { 15 | case empty 16 | case loading 17 | case loaded(Packages, refreshing: Bool) 18 | } 19 | 20 | @Published private(set) var packageState: State = .empty 21 | 22 | @Input var query: String = "" 23 | 24 | private let environment: Environment 25 | 26 | init(environment: Environment) { 27 | self.environment = environment 28 | 29 | Publishers 30 | .CombineLatest(environment.packages, $query.removeDuplicates()) 31 | .map { packages, query in 32 | if query.isEmpty { 33 | return packages 34 | } 35 | return Packages( 36 | formulae: packages.formulae.filter { package in 37 | package.name.localizedCaseInsensitiveContains(query) 38 | }, 39 | casks: packages.casks.filter { package in 40 | package.name.localizedCaseInsensitiveContains(query) 41 | } 42 | ) 43 | } 44 | .combineLatest(environment.isRefreshing) 45 | .map(State.loaded) 46 | .prepend(.loading) 47 | .receive(on: DispatchQueue.main) 48 | .assign(to: &$packageState) 49 | } 50 | 51 | func uninstall(package: Package) { 52 | environment.uninstall(package.id) 53 | } 54 | 55 | func detailViewModel(for package: Package) -> PackageDetailViewModel { 56 | PackageDetailViewModel( 57 | environment: .init( 58 | package: environment.detail(package), 59 | load: { [load = environment.load] in load(package.id) }, 60 | install: { [install = environment.install] in install(package.id) }, 61 | uninstall: { [uninstall = environment.uninstall] in uninstall(package.id) } 62 | ), 63 | packageDetail: PackageDetail(package: package, activity: nil) 64 | ) 65 | } 66 | } 67 | 68 | extension InstalledPackagesViewModel { 69 | convenience init(repository: PackageRepository) { 70 | self.init( 71 | environment: Environment( 72 | packages: repository.packages, 73 | isRefreshing: repository.refreshing, 74 | detail: repository.detail, 75 | load: repository.refresh, 76 | install: repository.install, 77 | uninstall: repository.uninstall 78 | ) 79 | ) 80 | } 81 | } 82 | 83 | struct InstalledPackagesView: View { 84 | @StateObject var viewModel: InstalledPackagesViewModel 85 | 86 | @Environment(\.openURL) private var openURL 87 | 88 | @State private var selection: Package.ID? 89 | 90 | var body: some View { 91 | VStack(spacing: 0) { 92 | switch viewModel.packageState { 93 | case .empty: 94 | PackageFilterView(query: $viewModel.query) 95 | Spacer() 96 | case .loading: 97 | ProgressView() 98 | case let .loaded(packages, isRefreshing): 99 | PackageFilterView(query: $viewModel.query) 100 | PackageListView( 101 | packages: packages, 102 | detailViewModel: viewModel.detailViewModel, 103 | selection: $selection, 104 | action: { action in 105 | switch action { 106 | case .viewHomepage(let package): 107 | openURL(package.homepage) 108 | case .uninstall(let package): 109 | viewModel.uninstall(package: package) 110 | } 111 | } 112 | ) 113 | Spacer(minLength: 0) 114 | if isRefreshing { 115 | PackageRefreshIndicator() 116 | } 117 | } 118 | } 119 | } 120 | } 121 | 122 | private struct PackageFilterView: View { 123 | @Binding var query: String 124 | 125 | var body: some View { 126 | TextField("Filter", text: $query) 127 | .textFieldStyle(.roundedBorder) 128 | .padding(8) 129 | } 130 | } 131 | 132 | private struct PackageListView: View { 133 | enum Action { 134 | case viewHomepage(Package) 135 | case uninstall(Package) 136 | } 137 | 138 | let packages: Packages 139 | let detailViewModel: (Package) -> PackageDetailViewModel 140 | @Binding var selection: Package.ID? 141 | let action: (Action) -> Void 142 | 143 | var body: some View { 144 | List(selection: $selection) { 145 | if packages.hasFormulae { 146 | Section(header: Text("Formulae")) { 147 | ForEach(packages.formulae, content: packageRow) 148 | } 149 | } 150 | if packages.hasFormulae && packages.hasCasks { 151 | Divider() 152 | } 153 | if packages.hasCasks { 154 | Section(header: Text("Casks")) { 155 | ForEach(packages.casks, content: packageRow) 156 | } 157 | } 158 | } 159 | } 160 | 161 | private func packageRow(_ package: Package) -> some View { 162 | NavigationLink { 163 | PackageDetailView(viewModel: detailViewModel(package)) 164 | } label: { 165 | HStack { 166 | Text(package.name) 167 | .layoutPriority(1) 168 | Spacer() 169 | if let version = package.installedVersion { 170 | Text(version) 171 | .foregroundColor(.secondary) 172 | .lineLimit(1) 173 | } 174 | } 175 | } 176 | .tag(package.id) 177 | .contextMenu { 178 | Button("View Homepage") { 179 | action(.viewHomepage(package)) 180 | } 181 | Divider() 182 | Button("Uninstall") { 183 | action(.uninstall(package)) 184 | } 185 | } 186 | } 187 | } 188 | 189 | private struct PackageRefreshIndicator: View { 190 | var body: some View { 191 | VStack { 192 | Divider() 193 | HStack { 194 | Text("Refreshing") 195 | .font(.callout) 196 | Spacer() 197 | ProgressView() 198 | .scaleEffect(0.5) 199 | } 200 | .padding([.leading, .trailing]) 201 | .padding(.bottom, 8) 202 | } 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /HomebrUI/Views/OperationInfoView.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | import SwiftUI 4 | 5 | final class OperationInfoViewModel: ObservableObject { 6 | struct Environment { 7 | var operations: AnyPublisher<[HomebrewOperation], Never> 8 | } 9 | 10 | struct Operation: Identifiable { 11 | let id: HomebrewOperation.ID 12 | let name: String 13 | let status: String 14 | } 15 | 16 | @Published private(set) var operations: [Operation] = [] 17 | 18 | init(environment: Environment) { 19 | environment.operations 20 | .map { operations in 21 | operations.map(Operation.init) 22 | } 23 | .assign(to: &$operations) 24 | } 25 | } 26 | 27 | struct OperationInfoView: View { 28 | @StateObject var viewModel: OperationInfoViewModel 29 | 30 | var body: some View { 31 | VStack { 32 | Text("Homebrew Operations") 33 | List(viewModel.operations) { operation in 34 | HStack { 35 | Text(operation.name) 36 | Spacer() 37 | Text(operation.status) 38 | } 39 | } 40 | .listStyle(.sidebar) 41 | } 42 | .padding(.top) 43 | .frame(minWidth: 350, minHeight: 400) 44 | } 45 | } 46 | 47 | private extension OperationInfoViewModel.Operation { 48 | init(operation: HomebrewOperation) { 49 | id = operation.id 50 | name = operation.command.name 51 | status = operation.status.name 52 | } 53 | } 54 | 55 | private extension HomebrewCommand { 56 | var name: String { 57 | switch self { 58 | case .list: return "Refreshing packages" 59 | case .info(let package): return "Getting info for \"\(package)\"" 60 | case .search(let query): return "Searching for \"\(query)\"" 61 | case .install(let packages): return "Installing \(packages)" 62 | case .uninstall(let package): return "Uninstalling \"\(package)\"" 63 | case .update: return "Updating packages" 64 | case .upgrade(.all): return "Upgrading all packages" 65 | case .upgrade(.only(let package)): return "Upgrading \"\(package)\"" 66 | } 67 | } 68 | } 69 | 70 | private extension HomebrewOperation.Status { 71 | var name: String { 72 | switch self { 73 | case .queued: return "Queued" 74 | case .running: return "Running" 75 | case .cancelled: return "Cancelled" 76 | case .completed(let result): return "Completed: Status \(result.status)" 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /HomebrUI/Views/PackageDetailView.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import SwiftUI 3 | 4 | final class PackageDetailViewModel: ObservableObject { 5 | struct Environment { 6 | var package: AnyPublisher 7 | var load: () -> Void 8 | var install: () -> Void 9 | var uninstall: () -> Void 10 | } 11 | 12 | enum State { 13 | case empty 14 | case loading 15 | case loaded(PackageDetail) 16 | case error(String) 17 | } 18 | 19 | @Published private(set) var state: State = .empty 20 | 21 | private let environment: Environment 22 | 23 | init(environment: Environment, packageDetail: PackageDetail? = nil) { 24 | self.environment = environment 25 | 26 | if let packageDetail = packageDetail { 27 | state = .loaded(packageDetail) 28 | } 29 | 30 | environment.package 31 | .map(State.loaded) 32 | .catch { _ in Just(.error("Failed to load package")) } 33 | .receive(on: DispatchQueue.main) 34 | .assign(to: &$state) 35 | } 36 | 37 | func load() { 38 | state = .loading 39 | environment.load() 40 | } 41 | 42 | func install() { 43 | environment.install() 44 | } 45 | 46 | func uninstall() { 47 | environment.uninstall() 48 | } 49 | } 50 | 51 | struct PackageDetailView: View { 52 | @StateObject var viewModel: PackageDetailViewModel 53 | 54 | var body: some View { 55 | switch viewModel.state { 56 | case .empty: 57 | PackageDetailPlaceholderView() 58 | case .loading: 59 | LoadingPackageDetailView() 60 | case .loaded(let package): 61 | LoadedPackageDetailView(package: package) { action in 62 | switch action { 63 | case .install: viewModel.install() 64 | case .uninstall: viewModel.uninstall() 65 | } 66 | } 67 | case .error(let message): 68 | FailedToLoadPackageView(message: message, retry: viewModel.load) 69 | } 70 | } 71 | } 72 | 73 | struct PackageDetailPlaceholderView: View { 74 | var body: some View { 75 | Text("Select a Package") 76 | .font(.callout) 77 | .foregroundColor(.secondary) 78 | } 79 | } 80 | 81 | private struct LoadingPackageDetailView: View { 82 | var body: some View { 83 | ProgressView() 84 | } 85 | } 86 | 87 | private struct LoadedPackageDetailView: View { 88 | enum Action { 89 | case install 90 | case uninstall 91 | } 92 | 93 | let package: PackageDetail 94 | let action: (Action) -> Void 95 | 96 | var body: some View { 97 | VStack(alignment: .leading) { 98 | HStack { 99 | Text(package.name) 100 | .font(.title) 101 | Spacer() 102 | if package.activity != nil { 103 | ProgressView() 104 | .scaleEffect(0.5) 105 | } 106 | if package.isInstalled { 107 | ActionButton("Uninstall") { 108 | action(.uninstall) 109 | } 110 | .disabled(package.activity == .uninstalling) 111 | } else { 112 | ActionButton("Install") { 113 | action(.install) 114 | } 115 | .disabled(package.activity == .installing) 116 | } 117 | } 118 | Divider() 119 | if let description = package.description { 120 | Text(description) 121 | } 122 | Link(package.homepage.absoluteString, destination: package.homepage) 123 | if let version = package.installedVersion { 124 | Text("Installed Version: ") + Text(version).foregroundColor(.secondary) 125 | } 126 | Text("Latest Version: ") + Text(package.latestVersion).foregroundColor(.secondary) 127 | Spacer(minLength: 0) 128 | } 129 | .padding() 130 | .frame(minWidth: 300) 131 | } 132 | } 133 | 134 | private struct FailedToLoadPackageView: View { 135 | let message: String 136 | let retry: () -> Void 137 | 138 | var body: some View { 139 | VStack { 140 | Text(message) 141 | Button("Retry", action: retry) 142 | } 143 | } 144 | } 145 | 146 | private struct ActionButton: View { 147 | let title: String 148 | let action: () -> Void 149 | 150 | init(_ title: String, action: @escaping () -> Void) { 151 | self.title = title 152 | self.action = action 153 | } 154 | 155 | var body: some View { 156 | Button(title, action: action) 157 | .buttonStyle(ActionButtonStyle()) 158 | } 159 | } 160 | 161 | struct ActionButtonStyle: ButtonStyle { 162 | func makeBody(configuration: Self.Configuration) -> some View { 163 | configuration.label 164 | .padding(.vertical, 6) 165 | .padding(.horizontal, 12) 166 | .foregroundColor(.white) 167 | .background( 168 | RoundedRectangle(cornerRadius: .infinity, style: .continuous) 169 | .fill(Color.blue) 170 | ) 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /HomebrUI/Views/SeachPackagesView.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import SwiftUI 3 | 4 | final class SearchPackagesViewModel: ObservableObject { 5 | struct Environment { 6 | var search: (String) -> AnyPublisher 7 | var detail: (Package) -> AnyPublisher 8 | var load: (Package.ID) -> Void 9 | var install: (Package.ID) -> Void 10 | var uninstall: (Package.ID) -> Void 11 | } 12 | 13 | enum State { 14 | case empty 15 | case loading 16 | case loaded(Packages) 17 | case noResults 18 | case error(String) 19 | } 20 | 21 | enum Action { 22 | case search 23 | case retry 24 | } 25 | 26 | @Published private(set) var state: State = .empty 27 | 28 | @Input var query: String = "" 29 | 30 | private let actions = PassthroughSubject() 31 | private let environment: Environment 32 | 33 | init(environment: Environment) { 34 | self.environment = environment 35 | 36 | let executeSearch = $query 37 | .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main) 38 | .merge(with: actions.compactMap { [weak self] in $0 == .search ? self?.query : nil }) 39 | .removeDuplicates() 40 | .merge(with: actions.compactMap { [weak self] in $0 == .retry ? self?.query : nil }) 41 | .filter { !$0.isEmpty } 42 | 43 | let clearSearch = $query.filter(\.isEmpty) 44 | 45 | Publishers.Merge(executeSearch, clearSearch) 46 | .map { query -> AnyPublisher in 47 | if query.isEmpty { 48 | return .just(.empty) 49 | } 50 | return environment.search(query) 51 | .map { packages in 52 | guard !packages.isEmpty else { 53 | return .noResults 54 | } 55 | return .loaded(packages) 56 | } 57 | .prepend(.loading) 58 | .catch { _ in Just(.error("Failed to load results")) } 59 | .eraseToAnyPublisher() 60 | } 61 | .switchToLatest() 62 | .receive(on: DispatchQueue.main) 63 | .assign(to: &$state) 64 | } 65 | 66 | func search() { 67 | actions.send(.search) 68 | } 69 | 70 | func retry() { 71 | actions.send(.retry) 72 | } 73 | 74 | func detailViewModel(for package: Package) -> PackageDetailViewModel { 75 | PackageDetailViewModel( 76 | environment: .init( 77 | package: environment.detail(package), 78 | load: { [load = environment.load] in load(package.id) }, 79 | install: { [install = environment.install] in install(package.id) }, 80 | uninstall: { [uninstall = environment.uninstall] in uninstall(package.id) } 81 | ), 82 | packageDetail: PackageDetail(package: package, activity: nil) 83 | ) 84 | } 85 | } 86 | 87 | extension SearchPackagesViewModel { 88 | convenience init(repository: PackageRepository) { 89 | self.init( 90 | environment: Environment( 91 | search: repository.searchForPackage, 92 | detail: repository.detail, 93 | load: repository.refresh, 94 | install: repository.install, 95 | uninstall: repository.uninstall 96 | ) 97 | ) 98 | } 99 | } 100 | 101 | struct SearchPackagesView: View { 102 | @StateObject var viewModel: SearchPackagesViewModel 103 | 104 | var body: some View { 105 | VStack(spacing: 0) { 106 | PackageSearchField(query: $viewModel.query, submit: viewModel.search) 107 | switch viewModel.state { 108 | case .empty: 109 | SearchInfoView() 110 | case .loading: 111 | SearchLoadingView() 112 | case .loaded(let results): 113 | SearchResultsView(results: results, detailViewModel: viewModel.detailViewModel) 114 | case .noResults: 115 | NoSearchResultsView() 116 | case .error(let message): 117 | FailedToLoadSearchResultsView(message: message, retry: viewModel.retry) 118 | } 119 | Spacer(minLength: 0) 120 | } 121 | } 122 | } 123 | 124 | private struct PackageSearchField: View { 125 | @Binding var query: String 126 | 127 | var submit: () -> Void 128 | 129 | var body: some View { 130 | TextField("Search", text: $query, onCommit: submit) 131 | .disableAutocorrection(true) 132 | .textFieldStyle(.roundedBorder) 133 | .padding(8) 134 | } 135 | } 136 | 137 | private struct SearchInfoView: View { 138 | var body: some View { 139 | Spacer() 140 | Text("Search for a Homebrew package") 141 | .font(.callout) 142 | .foregroundColor(.secondary) 143 | } 144 | } 145 | 146 | private struct SearchLoadingView: View { 147 | var body: some View { 148 | Spacer() 149 | ProgressView() 150 | } 151 | } 152 | 153 | private struct SearchResultsView: View { 154 | let results: Packages 155 | let detailViewModel: (Package) -> PackageDetailViewModel 156 | 157 | var body: some View { 158 | List { 159 | if results.hasFormulae { 160 | SearchResultsSectionView( 161 | title: "Formulae", 162 | results: results.formulae, 163 | detailViewModel: detailViewModel 164 | ) 165 | } 166 | if results.hasFormulae && results.hasCasks { 167 | Divider() 168 | } 169 | if results.hasCasks { 170 | SearchResultsSectionView( 171 | title: "Casks", 172 | results: results.casks, 173 | detailViewModel: detailViewModel 174 | ) 175 | } 176 | } 177 | } 178 | } 179 | 180 | private struct SearchResultsSectionView: View { 181 | let title: String 182 | let results: [Package] 183 | let detailViewModel: (Package) -> PackageDetailViewModel 184 | 185 | var body: some View { 186 | Section( 187 | header: HStack { 188 | Text(title) 189 | Spacer(minLength: 8) 190 | if results.count == 1 { 191 | Text("\(results.count) result") 192 | } else { 193 | Text("\(results.count) results") 194 | } 195 | } 196 | ) { 197 | ForEach(results) { result in 198 | NavigationLink(result.name) { 199 | PackageDetailView(viewModel: detailViewModel(result)) 200 | } 201 | } 202 | } 203 | } 204 | } 205 | 206 | private struct NoSearchResultsView: View { 207 | var body: some View { 208 | Spacer() 209 | Text("No packages found") 210 | .font(.callout) 211 | .foregroundColor(.secondary) 212 | } 213 | } 214 | 215 | private struct FailedToLoadSearchResultsView: View { 216 | let message: String 217 | let retry: () -> Void 218 | 219 | var body: some View { 220 | Spacer() 221 | Text(message) 222 | Button("Retry", action: retry) 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /HomebrUI/Views/SidebarView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct SidebarView: View { 4 | private let installedViewModel: InstalledPackagesViewModel 5 | private let searchViewModel: SearchPackagesViewModel 6 | 7 | @State private var selectedSidebarItem: SidebarItem? = .installed 8 | 9 | init(repository: PackageRepository) { 10 | installedViewModel = InstalledPackagesViewModel(repository: repository) 11 | searchViewModel = SearchPackagesViewModel(repository: repository) 12 | } 13 | 14 | var body: some View { 15 | NavigationView { 16 | List(selection: $selectedSidebarItem) { 17 | NavigationLink { 18 | InstalledPackagesView(viewModel: installedViewModel) 19 | } label: { 20 | Label("Installed", systemImage: "shippingbox") 21 | } 22 | .tag(SidebarItem.installed) 23 | NavigationLink { 24 | SearchPackagesView(viewModel: searchViewModel) 25 | } label: { 26 | Label("Search", systemImage: "magnifyingglass") 27 | } 28 | .tag(SidebarItem.search) 29 | } 30 | .listStyle(.sidebar) 31 | .frame(minWidth: 200, maxWidth: 300) 32 | 33 | PackageListPlaceholderView() 34 | .frame(minWidth: 250, maxWidth: 300) 35 | PackageDetailPlaceholderView() 36 | } 37 | .navigationTitle("HomebrUI") 38 | .focusedValue( 39 | \.selectedSidebarItem, 40 | $selectedSidebarItem.nonOptional(withDefault: .installed) 41 | ) 42 | } 43 | } 44 | 45 | typealias PackageListPlaceholderView = EmptyView 46 | 47 | enum SidebarItem { 48 | case installed 49 | case search 50 | } 51 | -------------------------------------------------------------------------------- /HomebrUI/Views/ToolbarView.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import SwiftUI 3 | 4 | struct ToolbarView: ToolbarContent { 5 | private let operationViewModel: OperationInfoViewModel 6 | 7 | @Binding var isInfoPopoverPresented: Bool 8 | 9 | init(operations: AnyPublisher<[HomebrewOperation], Never>, isInfoPopoverPresented: Binding) { 10 | self._isInfoPopoverPresented = isInfoPopoverPresented 11 | self.operationViewModel = OperationInfoViewModel(environment: .init(operations: operations)) 12 | } 13 | 14 | var body: some ToolbarContent { 15 | ToolbarItem(placement: .navigation) { 16 | Button(action: toggleSidebar) { 17 | Label("Toggle Sidebar", systemImage: "sidebar.left") 18 | } 19 | .help("Hide or show the Sidebar") 20 | } 21 | ToolbarItem(placement: .status) { 22 | Button { 23 | isInfoPopoverPresented.toggle() 24 | } label: { 25 | Label("Info", systemImage: "info.circle") 26 | } 27 | .popover(isPresented: $isInfoPopoverPresented) { 28 | OperationInfoView(viewModel: operationViewModel) 29 | } 30 | .keyboardShortcut("i", modifiers: .command) 31 | .help("Show or hide Homebrew operation info") 32 | } 33 | } 34 | } 35 | 36 | private func toggleSidebar() { 37 | NSApp.keyWindow?.firstResponder?.tryToPerform( 38 | #selector(NSSplitViewController.toggleSidebar(_:)), 39 | with: nil 40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Ryan Davis 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HomebrUI 2 | 3 | A GUI frontend for [Homebrew](https://brew.sh). 4 | 5 | There are not many features right now but assuming a fairly standard `/usr/local/bin/brew` installation, you can: 6 | 7 | - View installed formulae and casks 8 | - Install and uninstall formulae 9 | - Search and view info for formulae and casks 10 | --------------------------------------------------------------------------------