├── .gitignore ├── Casks └── mountmate.rb ├── MountMate.xcodeproj ├── project.pbxproj └── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ └── swiftpm │ └── Package.resolved ├── MountMate ├── App │ └── MountMateApp.swift ├── Info.plist ├── Managers │ ├── DiskMounter.swift │ ├── DriveManager.swift │ ├── LaunchAtLoginManager.swift │ ├── PersistenceManager.swift │ └── UpdaterController.swift ├── Models │ └── DiskModels.swift ├── MountMate.entitlements ├── Resources │ ├── Assets.xcassets │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset │ │ │ ├── Contents.json │ │ │ ├── icon_128x128.png │ │ │ ├── icon_128x128@2x.png │ │ │ ├── icon_16x16.png │ │ │ ├── icon_16x16@2x.png │ │ │ ├── icon_256x256.png │ │ │ ├── icon_256x256@2x.png │ │ │ ├── icon_32x32.png │ │ │ ├── icon_32x32@2x.png │ │ │ ├── icon_512x512.png │ │ │ └── icon_512x512@2x.png │ │ └── Contents.json │ ├── en.lproj │ │ └── Localizable.strings │ ├── vi.lproj │ │ └── Localizable.strings │ └── zh-Hans.lproj │ │ └── Localizable.strings ├── Utilities │ ├── AppAlert.swift │ ├── AppDelegate.swift │ ├── Notifications.swift │ └── Shell.swift └── Views │ ├── Components │ └── CircularProgressRing.swift │ ├── Main │ ├── LoadingView.swift │ └── MainView.swift │ └── Settings │ └── SettingsView.swift ├── README-vi.md ├── README.md ├── docs ├── appcast.xml ├── assets │ ├── icon.icns │ └── icon.png ├── index.html └── screenshots │ ├── dark-full.png │ ├── dark.png │ ├── light-full.png │ └── light.png └── scripts ├── 1-create-app.sh ├── 2-build.sh ├── 3-release.sh └── 4-generate_cask.sh /.gitignore: -------------------------------------------------------------------------------- 1 | xcuserdata/ 2 | *.xcscmblueprint 3 | *.xccheckout 4 | *.xcodeproj/* 5 | !*.xcodeproj/project.pbxproj 6 | !*.xcodeproj/xcshareddata/ 7 | !*.xcodeproj/project.xcworkspace/ 8 | !*.xcworkspace/contents.xcworkspacedata 9 | /*.gcno 10 | **/xcshareddata/WorkspaceSettings.xcsettings 11 | .DS_Store 12 | 13 | .env.local 14 | Dist/ 15 | -------------------------------------------------------------------------------- /Casks/mountmate.rb: -------------------------------------------------------------------------------- 1 | cask "mountmate" do 2 | version "1.6" 3 | sha256 "44e569005874e00291147c528b177e4e2b410e26680fe1328c6f48b5066550a3" 4 | 5 | url "https://github.com/homielab/mountmate/releases/download/v#{version}/MountMate_#{version}.dmg" 6 | name "MountMate" 7 | desc "A menubar app to easily manage external drives" 8 | homepage "https://homielab.com/page/mountmate" 9 | 10 | auto_updates true 11 | app "MountMate.app" 12 | 13 | zap trash: [ 14 | "~/Library/Preferences/com.homielab.mountmate.plist", 15 | ] 16 | end 17 | -------------------------------------------------------------------------------- /MountMate.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 77; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | B4B37DEB2DFE68A600A60D0D /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = B4B37DEA2DFE68A600A60D0D /* Sparkle */; }; 11 | /* End PBXBuildFile section */ 12 | 13 | /* Begin PBXContainerItemProxy section */ 14 | B4AADB202DFE3BBD00EDAAC2 /* PBXContainerItemProxy */ = { 15 | isa = PBXContainerItemProxy; 16 | containerPortal = B4AADB092DFE3BBC00EDAAC2 /* Project object */; 17 | proxyType = 1; 18 | remoteGlobalIDString = B4AADB102DFE3BBC00EDAAC2; 19 | remoteInfo = MountMate; 20 | }; 21 | B4AADB2A2DFE3BBD00EDAAC2 /* PBXContainerItemProxy */ = { 22 | isa = PBXContainerItemProxy; 23 | containerPortal = B4AADB092DFE3BBC00EDAAC2 /* Project object */; 24 | proxyType = 1; 25 | remoteGlobalIDString = B4AADB102DFE3BBC00EDAAC2; 26 | remoteInfo = MountMate; 27 | }; 28 | /* End PBXContainerItemProxy section */ 29 | 30 | /* Begin PBXFileReference section */ 31 | B4AADB112DFE3BBC00EDAAC2 /* MountMate.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MountMate.app; sourceTree = BUILT_PRODUCTS_DIR; }; 32 | B4AADB1F2DFE3BBD00EDAAC2 /* MountMateTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MountMateTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 33 | B4AADB292DFE3BBD00EDAAC2 /* MountMateUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MountMateUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 34 | B4B37DE32DFE47F600A60D0D /* ServiceManagement.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ServiceManagement.framework; path = System/Library/Frameworks/ServiceManagement.framework; sourceTree = SDKROOT; }; 35 | /* End PBXFileReference section */ 36 | 37 | /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ 38 | B4AADB3E2DFE3C2A00EDAAC2 /* Exceptions for "MountMate" folder in "MountMate" target */ = { 39 | isa = PBXFileSystemSynchronizedBuildFileExceptionSet; 40 | membershipExceptions = ( 41 | Info.plist, 42 | ); 43 | target = B4AADB102DFE3BBC00EDAAC2 /* MountMate */; 44 | }; 45 | /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ 46 | 47 | /* Begin PBXFileSystemSynchronizedRootGroup section */ 48 | B4AADB132DFE3BBC00EDAAC2 /* MountMate */ = { 49 | isa = PBXFileSystemSynchronizedRootGroup; 50 | exceptions = ( 51 | B4AADB3E2DFE3C2A00EDAAC2 /* Exceptions for "MountMate" folder in "MountMate" target */, 52 | ); 53 | path = MountMate; 54 | sourceTree = "<group>"; 55 | }; 56 | /* End PBXFileSystemSynchronizedRootGroup section */ 57 | 58 | /* Begin PBXFrameworksBuildPhase section */ 59 | B4AADB0E2DFE3BBC00EDAAC2 /* Frameworks */ = { 60 | isa = PBXFrameworksBuildPhase; 61 | buildActionMask = 2147483647; 62 | files = ( 63 | B4B37DEB2DFE68A600A60D0D /* Sparkle in Frameworks */, 64 | ); 65 | runOnlyForDeploymentPostprocessing = 0; 66 | }; 67 | B4AADB1C2DFE3BBD00EDAAC2 /* Frameworks */ = { 68 | isa = PBXFrameworksBuildPhase; 69 | buildActionMask = 2147483647; 70 | files = ( 71 | ); 72 | runOnlyForDeploymentPostprocessing = 0; 73 | }; 74 | B4AADB262DFE3BBD00EDAAC2 /* Frameworks */ = { 75 | isa = PBXFrameworksBuildPhase; 76 | buildActionMask = 2147483647; 77 | files = ( 78 | ); 79 | runOnlyForDeploymentPostprocessing = 0; 80 | }; 81 | /* End PBXFrameworksBuildPhase section */ 82 | 83 | /* Begin PBXGroup section */ 84 | B4AADB082DFE3BBC00EDAAC2 = { 85 | isa = PBXGroup; 86 | children = ( 87 | B4AADB132DFE3BBC00EDAAC2 /* MountMate */, 88 | B4AADB4E2DFE3E1600EDAAC2 /* Frameworks */, 89 | B4AADB122DFE3BBC00EDAAC2 /* Products */, 90 | ); 91 | sourceTree = "<group>"; 92 | }; 93 | B4AADB122DFE3BBC00EDAAC2 /* Products */ = { 94 | isa = PBXGroup; 95 | children = ( 96 | B4AADB112DFE3BBC00EDAAC2 /* MountMate.app */, 97 | B4AADB1F2DFE3BBD00EDAAC2 /* MountMateTests.xctest */, 98 | B4AADB292DFE3BBD00EDAAC2 /* MountMateUITests.xctest */, 99 | ); 100 | name = Products; 101 | sourceTree = "<group>"; 102 | }; 103 | B4AADB4E2DFE3E1600EDAAC2 /* Frameworks */ = { 104 | isa = PBXGroup; 105 | children = ( 106 | B4B37DE32DFE47F600A60D0D /* ServiceManagement.framework */, 107 | ); 108 | name = Frameworks; 109 | sourceTree = "<group>"; 110 | }; 111 | /* End PBXGroup section */ 112 | 113 | /* Begin PBXNativeTarget section */ 114 | B4AADB102DFE3BBC00EDAAC2 /* MountMate */ = { 115 | isa = PBXNativeTarget; 116 | buildConfigurationList = B4AADB332DFE3BBD00EDAAC2 /* Build configuration list for PBXNativeTarget "MountMate" */; 117 | buildPhases = ( 118 | B4AADB0D2DFE3BBC00EDAAC2 /* Sources */, 119 | B4AADB0E2DFE3BBC00EDAAC2 /* Frameworks */, 120 | B4AADB0F2DFE3BBC00EDAAC2 /* Resources */, 121 | ); 122 | buildRules = ( 123 | ); 124 | dependencies = ( 125 | ); 126 | fileSystemSynchronizedGroups = ( 127 | B4AADB132DFE3BBC00EDAAC2 /* MountMate */, 128 | ); 129 | name = MountMate; 130 | packageProductDependencies = ( 131 | B4B37DEA2DFE68A600A60D0D /* Sparkle */, 132 | ); 133 | productName = MountMate; 134 | productReference = B4AADB112DFE3BBC00EDAAC2 /* MountMate.app */; 135 | productType = "com.apple.product-type.application"; 136 | }; 137 | B4AADB1E2DFE3BBD00EDAAC2 /* MountMateTests */ = { 138 | isa = PBXNativeTarget; 139 | buildConfigurationList = B4AADB362DFE3BBD00EDAAC2 /* Build configuration list for PBXNativeTarget "MountMateTests" */; 140 | buildPhases = ( 141 | B4AADB1B2DFE3BBD00EDAAC2 /* Sources */, 142 | B4AADB1C2DFE3BBD00EDAAC2 /* Frameworks */, 143 | B4AADB1D2DFE3BBD00EDAAC2 /* Resources */, 144 | ); 145 | buildRules = ( 146 | ); 147 | dependencies = ( 148 | B4AADB212DFE3BBD00EDAAC2 /* PBXTargetDependency */, 149 | ); 150 | name = MountMateTests; 151 | packageProductDependencies = ( 152 | ); 153 | productName = MountMateTests; 154 | productReference = B4AADB1F2DFE3BBD00EDAAC2 /* MountMateTests.xctest */; 155 | productType = "com.apple.product-type.bundle.unit-test"; 156 | }; 157 | B4AADB282DFE3BBD00EDAAC2 /* MountMateUITests */ = { 158 | isa = PBXNativeTarget; 159 | buildConfigurationList = B4AADB392DFE3BBD00EDAAC2 /* Build configuration list for PBXNativeTarget "MountMateUITests" */; 160 | buildPhases = ( 161 | B4AADB252DFE3BBD00EDAAC2 /* Sources */, 162 | B4AADB262DFE3BBD00EDAAC2 /* Frameworks */, 163 | B4AADB272DFE3BBD00EDAAC2 /* Resources */, 164 | ); 165 | buildRules = ( 166 | ); 167 | dependencies = ( 168 | B4AADB2B2DFE3BBD00EDAAC2 /* PBXTargetDependency */, 169 | ); 170 | name = MountMateUITests; 171 | packageProductDependencies = ( 172 | ); 173 | productName = MountMateUITests; 174 | productReference = B4AADB292DFE3BBD00EDAAC2 /* MountMateUITests.xctest */; 175 | productType = "com.apple.product-type.bundle.ui-testing"; 176 | }; 177 | /* End PBXNativeTarget section */ 178 | 179 | /* Begin PBXProject section */ 180 | B4AADB092DFE3BBC00EDAAC2 /* Project object */ = { 181 | isa = PBXProject; 182 | attributes = { 183 | BuildIndependentTargetsInParallel = 1; 184 | LastSwiftUpdateCheck = 1640; 185 | LastUpgradeCheck = 1640; 186 | TargetAttributes = { 187 | B4AADB102DFE3BBC00EDAAC2 = { 188 | CreatedOnToolsVersion = 16.4; 189 | }; 190 | B4AADB1E2DFE3BBD00EDAAC2 = { 191 | CreatedOnToolsVersion = 16.4; 192 | TestTargetID = B4AADB102DFE3BBC00EDAAC2; 193 | }; 194 | B4AADB282DFE3BBD00EDAAC2 = { 195 | CreatedOnToolsVersion = 16.4; 196 | TestTargetID = B4AADB102DFE3BBC00EDAAC2; 197 | }; 198 | }; 199 | }; 200 | buildConfigurationList = B4AADB0C2DFE3BBC00EDAAC2 /* Build configuration list for PBXProject "MountMate" */; 201 | developmentRegion = en; 202 | hasScannedForEncodings = 0; 203 | knownRegions = ( 204 | en, 205 | Base, 206 | vi, 207 | "zh-Hans", 208 | ); 209 | mainGroup = B4AADB082DFE3BBC00EDAAC2; 210 | minimizedProjectReferenceProxies = 1; 211 | packageReferences = ( 212 | B4B37DE92DFE681700A60D0D /* XCRemoteSwiftPackageReference "Sparkle" */, 213 | ); 214 | preferredProjectObjectVersion = 77; 215 | productRefGroup = B4AADB122DFE3BBC00EDAAC2 /* Products */; 216 | projectDirPath = ""; 217 | projectRoot = ""; 218 | targets = ( 219 | B4AADB102DFE3BBC00EDAAC2 /* MountMate */, 220 | B4AADB1E2DFE3BBD00EDAAC2 /* MountMateTests */, 221 | B4AADB282DFE3BBD00EDAAC2 /* MountMateUITests */, 222 | ); 223 | }; 224 | /* End PBXProject section */ 225 | 226 | /* Begin PBXResourcesBuildPhase section */ 227 | B4AADB0F2DFE3BBC00EDAAC2 /* Resources */ = { 228 | isa = PBXResourcesBuildPhase; 229 | buildActionMask = 2147483647; 230 | files = ( 231 | ); 232 | runOnlyForDeploymentPostprocessing = 0; 233 | }; 234 | B4AADB1D2DFE3BBD00EDAAC2 /* Resources */ = { 235 | isa = PBXResourcesBuildPhase; 236 | buildActionMask = 2147483647; 237 | files = ( 238 | ); 239 | runOnlyForDeploymentPostprocessing = 0; 240 | }; 241 | B4AADB272DFE3BBD00EDAAC2 /* Resources */ = { 242 | isa = PBXResourcesBuildPhase; 243 | buildActionMask = 2147483647; 244 | files = ( 245 | ); 246 | runOnlyForDeploymentPostprocessing = 0; 247 | }; 248 | /* End PBXResourcesBuildPhase section */ 249 | 250 | /* Begin PBXSourcesBuildPhase section */ 251 | B4AADB0D2DFE3BBC00EDAAC2 /* Sources */ = { 252 | isa = PBXSourcesBuildPhase; 253 | buildActionMask = 2147483647; 254 | files = ( 255 | ); 256 | runOnlyForDeploymentPostprocessing = 0; 257 | }; 258 | B4AADB1B2DFE3BBD00EDAAC2 /* Sources */ = { 259 | isa = PBXSourcesBuildPhase; 260 | buildActionMask = 2147483647; 261 | files = ( 262 | ); 263 | runOnlyForDeploymentPostprocessing = 0; 264 | }; 265 | B4AADB252DFE3BBD00EDAAC2 /* Sources */ = { 266 | isa = PBXSourcesBuildPhase; 267 | buildActionMask = 2147483647; 268 | files = ( 269 | ); 270 | runOnlyForDeploymentPostprocessing = 0; 271 | }; 272 | /* End PBXSourcesBuildPhase section */ 273 | 274 | /* Begin PBXTargetDependency section */ 275 | B4AADB212DFE3BBD00EDAAC2 /* PBXTargetDependency */ = { 276 | isa = PBXTargetDependency; 277 | target = B4AADB102DFE3BBC00EDAAC2 /* MountMate */; 278 | targetProxy = B4AADB202DFE3BBD00EDAAC2 /* PBXContainerItemProxy */; 279 | }; 280 | B4AADB2B2DFE3BBD00EDAAC2 /* PBXTargetDependency */ = { 281 | isa = PBXTargetDependency; 282 | target = B4AADB102DFE3BBC00EDAAC2 /* MountMate */; 283 | targetProxy = B4AADB2A2DFE3BBD00EDAAC2 /* PBXContainerItemProxy */; 284 | }; 285 | /* End PBXTargetDependency section */ 286 | 287 | /* Begin XCBuildConfiguration section */ 288 | B4AADB312DFE3BBD00EDAAC2 /* Debug */ = { 289 | isa = XCBuildConfiguration; 290 | buildSettings = { 291 | ALWAYS_SEARCH_USER_PATHS = NO; 292 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 293 | CLANG_ANALYZER_NONNULL = YES; 294 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 295 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 296 | CLANG_ENABLE_MODULES = YES; 297 | CLANG_ENABLE_OBJC_ARC = YES; 298 | CLANG_ENABLE_OBJC_WEAK = YES; 299 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 300 | CLANG_WARN_BOOL_CONVERSION = YES; 301 | CLANG_WARN_COMMA = YES; 302 | CLANG_WARN_CONSTANT_CONVERSION = YES; 303 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 304 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 305 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 306 | CLANG_WARN_EMPTY_BODY = YES; 307 | CLANG_WARN_ENUM_CONVERSION = YES; 308 | CLANG_WARN_INFINITE_RECURSION = YES; 309 | CLANG_WARN_INT_CONVERSION = YES; 310 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 311 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 312 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 313 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 314 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 315 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 316 | CLANG_WARN_STRICT_PROTOTYPES = YES; 317 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 318 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 319 | CLANG_WARN_UNREACHABLE_CODE = YES; 320 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 321 | COPY_PHASE_STRIP = NO; 322 | DEBUG_INFORMATION_FORMAT = dwarf; 323 | ENABLE_STRICT_OBJC_MSGSEND = YES; 324 | ENABLE_TESTABILITY = YES; 325 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 326 | GCC_C_LANGUAGE_STANDARD = gnu17; 327 | GCC_DYNAMIC_NO_PIC = NO; 328 | GCC_NO_COMMON_BLOCKS = YES; 329 | GCC_OPTIMIZATION_LEVEL = 0; 330 | GCC_PREPROCESSOR_DEFINITIONS = ( 331 | "DEBUG=1", 332 | "$(inherited)", 333 | ); 334 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 335 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 336 | GCC_WARN_UNDECLARED_SELECTOR = YES; 337 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 338 | GCC_WARN_UNUSED_FUNCTION = YES; 339 | GCC_WARN_UNUSED_VARIABLE = YES; 340 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 341 | MACOSX_DEPLOYMENT_TARGET = 15.5; 342 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 343 | MTL_FAST_MATH = YES; 344 | ONLY_ACTIVE_ARCH = YES; 345 | SDKROOT = macosx; 346 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; 347 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 348 | }; 349 | name = Debug; 350 | }; 351 | B4AADB322DFE3BBD00EDAAC2 /* Release */ = { 352 | isa = XCBuildConfiguration; 353 | buildSettings = { 354 | ALWAYS_SEARCH_USER_PATHS = NO; 355 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 356 | CLANG_ANALYZER_NONNULL = YES; 357 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 358 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 359 | CLANG_ENABLE_MODULES = YES; 360 | CLANG_ENABLE_OBJC_ARC = YES; 361 | CLANG_ENABLE_OBJC_WEAK = YES; 362 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 363 | CLANG_WARN_BOOL_CONVERSION = YES; 364 | CLANG_WARN_COMMA = YES; 365 | CLANG_WARN_CONSTANT_CONVERSION = YES; 366 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 367 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 368 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 369 | CLANG_WARN_EMPTY_BODY = YES; 370 | CLANG_WARN_ENUM_CONVERSION = YES; 371 | CLANG_WARN_INFINITE_RECURSION = YES; 372 | CLANG_WARN_INT_CONVERSION = YES; 373 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 374 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 375 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 376 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 377 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 378 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 379 | CLANG_WARN_STRICT_PROTOTYPES = YES; 380 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 381 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 382 | CLANG_WARN_UNREACHABLE_CODE = YES; 383 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 384 | COPY_PHASE_STRIP = NO; 385 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 386 | ENABLE_NS_ASSERTIONS = NO; 387 | ENABLE_STRICT_OBJC_MSGSEND = YES; 388 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 389 | GCC_C_LANGUAGE_STANDARD = gnu17; 390 | GCC_NO_COMMON_BLOCKS = YES; 391 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 392 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 393 | GCC_WARN_UNDECLARED_SELECTOR = YES; 394 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 395 | GCC_WARN_UNUSED_FUNCTION = YES; 396 | GCC_WARN_UNUSED_VARIABLE = YES; 397 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 398 | MACOSX_DEPLOYMENT_TARGET = 15.5; 399 | MTL_ENABLE_DEBUG_INFO = NO; 400 | MTL_FAST_MATH = YES; 401 | SDKROOT = macosx; 402 | SWIFT_COMPILATION_MODE = wholemodule; 403 | }; 404 | name = Release; 405 | }; 406 | B4AADB342DFE3BBD00EDAAC2 /* Debug */ = { 407 | isa = XCBuildConfiguration; 408 | buildSettings = { 409 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 410 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 411 | CODE_SIGN_ENTITLEMENTS = MountMate/MountMate.entitlements; 412 | CODE_SIGN_IDENTITY = "Apple Development"; 413 | CODE_SIGN_STYLE = Automatic; 414 | COMBINE_HIDPI_IMAGES = YES; 415 | CURRENT_PROJECT_VERSION = 6; 416 | DEVELOPMENT_TEAM = 79LQ4MHVMG; 417 | ENABLE_HARDENED_RUNTIME = YES; 418 | ENABLE_PREVIEWS = YES; 419 | GENERATE_INFOPLIST_FILE = YES; 420 | INFOPLIST_FILE = MountMate/Info.plist; 421 | INFOPLIST_KEY_CFBundleDisplayName = MountMate; 422 | INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; 423 | INFOPLIST_KEY_LSUIElement = YES; 424 | INFOPLIST_KEY_NSHumanReadableCopyright = ""; 425 | LD_RUNPATH_SEARCH_PATHS = ( 426 | "$(inherited)", 427 | "@executable_path/../Frameworks", 428 | ); 429 | MACOSX_DEPLOYMENT_TARGET = 13.0; 430 | MARKETING_VERSION = 1.6; 431 | PRODUCT_BUNDLE_IDENTIFIER = com.homielab.mountmate; 432 | PRODUCT_NAME = "$(TARGET_NAME)"; 433 | PROVISIONING_PROFILE_SPECIFIER = ""; 434 | REGISTER_APP_GROUPS = YES; 435 | SWIFT_EMIT_LOC_STRINGS = YES; 436 | SWIFT_VERSION = 5.0; 437 | }; 438 | name = Debug; 439 | }; 440 | B4AADB352DFE3BBD00EDAAC2 /* Release */ = { 441 | isa = XCBuildConfiguration; 442 | buildSettings = { 443 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 444 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 445 | CODE_SIGN_ENTITLEMENTS = MountMate/MountMate.entitlements; 446 | CODE_SIGN_IDENTITY = "Apple Development"; 447 | CODE_SIGN_STYLE = Automatic; 448 | COMBINE_HIDPI_IMAGES = YES; 449 | CURRENT_PROJECT_VERSION = 6; 450 | DEVELOPMENT_TEAM = 79LQ4MHVMG; 451 | ENABLE_HARDENED_RUNTIME = YES; 452 | ENABLE_PREVIEWS = YES; 453 | GENERATE_INFOPLIST_FILE = YES; 454 | INFOPLIST_FILE = MountMate/Info.plist; 455 | INFOPLIST_KEY_CFBundleDisplayName = MountMate; 456 | INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; 457 | INFOPLIST_KEY_LSUIElement = YES; 458 | INFOPLIST_KEY_NSHumanReadableCopyright = ""; 459 | LD_RUNPATH_SEARCH_PATHS = ( 460 | "$(inherited)", 461 | "@executable_path/../Frameworks", 462 | ); 463 | MACOSX_DEPLOYMENT_TARGET = 13.0; 464 | MARKETING_VERSION = 1.6; 465 | PRODUCT_BUNDLE_IDENTIFIER = com.homielab.mountmate; 466 | PRODUCT_NAME = "$(TARGET_NAME)"; 467 | PROVISIONING_PROFILE_SPECIFIER = ""; 468 | REGISTER_APP_GROUPS = YES; 469 | SWIFT_EMIT_LOC_STRINGS = YES; 470 | SWIFT_VERSION = 5.0; 471 | }; 472 | name = Release; 473 | }; 474 | B4AADB372DFE3BBD00EDAAC2 /* Debug */ = { 475 | isa = XCBuildConfiguration; 476 | buildSettings = { 477 | BUNDLE_LOADER = "$(TEST_HOST)"; 478 | CODE_SIGN_STYLE = Automatic; 479 | CURRENT_PROJECT_VERSION = 1; 480 | GENERATE_INFOPLIST_FILE = YES; 481 | MACOSX_DEPLOYMENT_TARGET = 13.0; 482 | MARKETING_VERSION = 1.0; 483 | PRODUCT_BUNDLE_IDENTIFIER = com.homielab.MountMateTests; 484 | PRODUCT_NAME = "$(TARGET_NAME)"; 485 | SWIFT_EMIT_LOC_STRINGS = NO; 486 | SWIFT_VERSION = 5.0; 487 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/MountMate.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/MountMate"; 488 | }; 489 | name = Debug; 490 | }; 491 | B4AADB382DFE3BBD00EDAAC2 /* Release */ = { 492 | isa = XCBuildConfiguration; 493 | buildSettings = { 494 | BUNDLE_LOADER = "$(TEST_HOST)"; 495 | CODE_SIGN_STYLE = Automatic; 496 | CURRENT_PROJECT_VERSION = 1; 497 | GENERATE_INFOPLIST_FILE = YES; 498 | MACOSX_DEPLOYMENT_TARGET = 13.0; 499 | MARKETING_VERSION = 1.0; 500 | PRODUCT_BUNDLE_IDENTIFIER = com.homielab.MountMateTests; 501 | PRODUCT_NAME = "$(TARGET_NAME)"; 502 | SWIFT_EMIT_LOC_STRINGS = NO; 503 | SWIFT_VERSION = 5.0; 504 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/MountMate.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/MountMate"; 505 | }; 506 | name = Release; 507 | }; 508 | B4AADB3A2DFE3BBD00EDAAC2 /* Debug */ = { 509 | isa = XCBuildConfiguration; 510 | buildSettings = { 511 | CODE_SIGN_STYLE = Automatic; 512 | CURRENT_PROJECT_VERSION = 1; 513 | GENERATE_INFOPLIST_FILE = YES; 514 | MACOSX_DEPLOYMENT_TARGET = 13.0; 515 | MARKETING_VERSION = 1.0; 516 | PRODUCT_BUNDLE_IDENTIFIER = com.homielab.MountMateUITests; 517 | PRODUCT_NAME = "$(TARGET_NAME)"; 518 | SWIFT_EMIT_LOC_STRINGS = NO; 519 | SWIFT_VERSION = 5.0; 520 | TEST_TARGET_NAME = MountMate; 521 | }; 522 | name = Debug; 523 | }; 524 | B4AADB3B2DFE3BBD00EDAAC2 /* Release */ = { 525 | isa = XCBuildConfiguration; 526 | buildSettings = { 527 | CODE_SIGN_STYLE = Automatic; 528 | CURRENT_PROJECT_VERSION = 1; 529 | GENERATE_INFOPLIST_FILE = YES; 530 | MACOSX_DEPLOYMENT_TARGET = 13.0; 531 | MARKETING_VERSION = 1.0; 532 | PRODUCT_BUNDLE_IDENTIFIER = com.homielab.MountMateUITests; 533 | PRODUCT_NAME = "$(TARGET_NAME)"; 534 | SWIFT_EMIT_LOC_STRINGS = NO; 535 | SWIFT_VERSION = 5.0; 536 | TEST_TARGET_NAME = MountMate; 537 | }; 538 | name = Release; 539 | }; 540 | /* End XCBuildConfiguration section */ 541 | 542 | /* Begin XCConfigurationList section */ 543 | B4AADB0C2DFE3BBC00EDAAC2 /* Build configuration list for PBXProject "MountMate" */ = { 544 | isa = XCConfigurationList; 545 | buildConfigurations = ( 546 | B4AADB312DFE3BBD00EDAAC2 /* Debug */, 547 | B4AADB322DFE3BBD00EDAAC2 /* Release */, 548 | ); 549 | defaultConfigurationIsVisible = 0; 550 | defaultConfigurationName = Release; 551 | }; 552 | B4AADB332DFE3BBD00EDAAC2 /* Build configuration list for PBXNativeTarget "MountMate" */ = { 553 | isa = XCConfigurationList; 554 | buildConfigurations = ( 555 | B4AADB342DFE3BBD00EDAAC2 /* Debug */, 556 | B4AADB352DFE3BBD00EDAAC2 /* Release */, 557 | ); 558 | defaultConfigurationIsVisible = 0; 559 | defaultConfigurationName = Release; 560 | }; 561 | B4AADB362DFE3BBD00EDAAC2 /* Build configuration list for PBXNativeTarget "MountMateTests" */ = { 562 | isa = XCConfigurationList; 563 | buildConfigurations = ( 564 | B4AADB372DFE3BBD00EDAAC2 /* Debug */, 565 | B4AADB382DFE3BBD00EDAAC2 /* Release */, 566 | ); 567 | defaultConfigurationIsVisible = 0; 568 | defaultConfigurationName = Release; 569 | }; 570 | B4AADB392DFE3BBD00EDAAC2 /* Build configuration list for PBXNativeTarget "MountMateUITests" */ = { 571 | isa = XCConfigurationList; 572 | buildConfigurations = ( 573 | B4AADB3A2DFE3BBD00EDAAC2 /* Debug */, 574 | B4AADB3B2DFE3BBD00EDAAC2 /* Release */, 575 | ); 576 | defaultConfigurationIsVisible = 0; 577 | defaultConfigurationName = Release; 578 | }; 579 | /* End XCConfigurationList section */ 580 | 581 | /* Begin XCRemoteSwiftPackageReference section */ 582 | B4B37DE92DFE681700A60D0D /* XCRemoteSwiftPackageReference "Sparkle" */ = { 583 | isa = XCRemoteSwiftPackageReference; 584 | repositoryURL = "https://github.com/sparkle-project/Sparkle"; 585 | requirement = { 586 | kind = upToNextMajorVersion; 587 | minimumVersion = 2.7.0; 588 | }; 589 | }; 590 | /* End XCRemoteSwiftPackageReference section */ 591 | 592 | /* Begin XCSwiftPackageProductDependency section */ 593 | B4B37DEA2DFE68A600A60D0D /* Sparkle */ = { 594 | isa = XCSwiftPackageProductDependency; 595 | package = B4B37DE92DFE681700A60D0D /* XCRemoteSwiftPackageReference "Sparkle" */; 596 | productName = Sparkle; 597 | }; 598 | /* End XCSwiftPackageProductDependency section */ 599 | }; 600 | rootObject = B4AADB092DFE3BBC00EDAAC2 /* Project object */; 601 | } 602 | -------------------------------------------------------------------------------- /MountMate.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | <?xml version="1.0" encoding="UTF-8"?> 2 | <Workspace 3 | version = "1.0"> 4 | <FileRef 5 | location = "self:"> 6 | </FileRef> 7 | </Workspace> 8 | -------------------------------------------------------------------------------- /MountMate.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "e721da7f9826abdffcb6185e886155efa2514bd6234475f1afa893e29eb258d6", 3 | "pins" : [ 4 | { 5 | "identity" : "sparkle", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/sparkle-project/Sparkle", 8 | "state" : { 9 | "revision" : "0ca3004e98712ea2b39dd881d28448630cce1c99", 10 | "version" : "2.7.0" 11 | } 12 | } 13 | ], 14 | "version" : 3 15 | } 16 | -------------------------------------------------------------------------------- /MountMate/App/MountMateApp.swift: -------------------------------------------------------------------------------- 1 | // Created by homielab.com 2 | 3 | import SwiftUI 4 | import Sparkle 5 | 6 | @main 7 | struct MountMateApp: App { 8 | @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate 9 | 10 | @State private var initialLoadCompleted = false 11 | 12 | @StateObject private var launchManager = LaunchAtLoginManager() 13 | @StateObject private var diskMounter = DiskMounter() 14 | @StateObject private var updaterViewModel: UpdaterController 15 | 16 | init() { 17 | let updater = SPUStandardUpdaterController(startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil) 18 | _updaterViewModel = StateObject(wrappedValue: UpdaterController(updater: updater.updater)) 19 | } 20 | 21 | var body: some Scene { 22 | MenuBarExtra("MountMate", systemImage: "externaldrive.fill.badge.plus") { 23 | if initialLoadCompleted { 24 | MainView() 25 | } else { 26 | LoadingView() 27 | .onReceive(DriveManager.shared.$isInitialLoadComplete) { isComplete in 28 | if isComplete { 29 | self.initialLoadCompleted = true 30 | } 31 | } 32 | } 33 | } 34 | .menuBarExtraStyle(.window) 35 | 36 | Window(NSLocalizedString("MountMate Settings", comment: ""), id: "settings-window") { 37 | SettingsView() 38 | .environmentObject(launchManager) 39 | .environmentObject(diskMounter) 40 | .environmentObject(updaterViewModel) 41 | } 42 | .windowResizability(.contentSize) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /MountMate/Info.plist: -------------------------------------------------------------------------------- 1 | <?xml version="1.0" encoding="UTF-8"?> 2 | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 3 | <plist version="1.0"> 4 | <dict> 5 | <key>SUPublicEDKey</key> 6 | <string>UN9VpzA76tcbRJA1pvVliMnMPcYyEiUGRugKM7ISucY=</string> 7 | <key>SUFeedURL</key> 8 | <string>https://homielab.github.io/mountmate/appcast.xml</string> 9 | </dict> 10 | </plist> 11 | -------------------------------------------------------------------------------- /MountMate/Managers/DiskMounter.swift: -------------------------------------------------------------------------------- 1 | // Created by homielab.com 2 | 3 | import Foundation 4 | import DiskArbitration 5 | 6 | class DiskMounter: ObservableObject { 7 | @Published var blockUSBAutoMount: Bool = UserDefaults.standard.bool(forKey: "blockUSBAutoMount") { 8 | didSet { 9 | UserDefaults.standard.set(blockUSBAutoMount, forKey: "blockUSBAutoMount") 10 | if blockUSBAutoMount { 11 | startDiskArbitration() 12 | } else { 13 | stopDiskArbitration() 14 | } 15 | } 16 | } 17 | 18 | private var session: DASession? 19 | private var approvingManualMountFor: String? 20 | private var clearApprovalWorkItem: DispatchWorkItem? 21 | 22 | init() { 23 | NotificationCenter.default.addObserver(self, selector: #selector(handleWillMount), name: .willManuallyMount, object: nil) 24 | if blockUSBAutoMount { 25 | startDiskArbitration() 26 | } 27 | } 28 | 29 | deinit { 30 | NotificationCenter.default.removeObserver(self) 31 | stopDiskArbitration() 32 | } 33 | 34 | @objc private func handleWillMount(notification: Notification) { 35 | clearApprovalWorkItem?.cancel() 36 | if let identifier = notification.userInfo?["deviceIdentifier"] as? String { 37 | self.approvingManualMountFor = identifier 38 | let workItem = DispatchWorkItem { [weak self] in 39 | self?.approvingManualMountFor = nil 40 | } 41 | self.clearApprovalWorkItem = workItem 42 | DispatchQueue.main.asyncAfter(deadline: .now() + 3.0, execute: workItem) 43 | } 44 | } 45 | 46 | private func startDiskArbitration() { 47 | guard session == nil else { return } 48 | session = DASessionCreate(kCFAllocatorDefault) 49 | guard let session = session else { return } 50 | 51 | let mountCallback: DADiskMountApprovalCallback = { (disk, context) -> Unmanaged<DADissenter>? in 52 | guard let context = context else { return nil } 53 | let this = Unmanaged<DiskMounter>.fromOpaque(context).takeUnretainedValue() 54 | 55 | if let bsdName = DADiskGetBSDName(disk).map({ String(cString: $0) }) { 56 | if let approvedDisk = this.approvingManualMountFor, approvedDisk == bsdName { 57 | return nil 58 | } 59 | } 60 | 61 | if let desc = DADiskCopyDescription(disk) { 62 | let description = desc as! [String: Any] 63 | let protocolName = description[kDADiskDescriptionDeviceProtocolKey as String] as? String 64 | if protocolName == "USB" { 65 | let dissenter = DADissenterCreate(kCFAllocatorDefault, DAReturn(kDAReturnNotPermitted), nil) 66 | return Unmanaged.passRetained(dissenter) 67 | } 68 | } 69 | return nil 70 | } 71 | 72 | let matching: [String: Any] = [kDADiskDescriptionVolumeMountableKey as String: kCFBooleanTrue!] 73 | let context = Unmanaged.passUnretained(self).toOpaque() 74 | DARegisterDiskMountApprovalCallback(session, matching as CFDictionary, mountCallback, context) 75 | DASessionSetDispatchQueue(session, DispatchQueue.main) 76 | } 77 | 78 | private func stopDiskArbitration() { 79 | guard let session = session else { return } 80 | DASessionSetDispatchQueue(session, nil) 81 | self.session = nil 82 | } 83 | } -------------------------------------------------------------------------------- /MountMate/Managers/DriveManager.swift: -------------------------------------------------------------------------------- 1 | // Created by homielab.com 2 | 3 | import SwiftUI 4 | import Foundation 5 | 6 | class DriveManager: ObservableObject { 7 | static let shared = DriveManager() 8 | 9 | @Published var physicalDisks: [PhysicalDisk] = [] 10 | @Published var isInitialLoadComplete = false 11 | @Published var isRefreshing = false 12 | @Published var busyVolumeIdentifier: String? = nil 13 | @Published var busyEjectingIdentifier: String? = nil 14 | @Published var isUnmountingAll = false 15 | @Published var operationError: AppAlert? = nil 16 | 17 | private var refreshDebounceTimer: Timer? 18 | 19 | private init() { 20 | setupDiskChangeObservers() 21 | refreshDrives() 22 | } 23 | 24 | deinit { 25 | NSWorkspace.shared.notificationCenter.removeObserver(self) 26 | refreshDebounceTimer?.invalidate() 27 | } 28 | 29 | // MARK: - Public Actions 30 | 31 | func refreshDrives(qos: DispatchQoS.QoSClass = .background) { 32 | if isInitialLoadComplete { 33 | DispatchQueue.main.async { self.isRefreshing = true } 34 | } 35 | 36 | DispatchQueue.global(qos: qos).async { [weak self] in 37 | guard let self = self else { return } 38 | let allDisksOutput = runShell("diskutil list -plist").output 39 | guard let allDisksData = allDisksOutput?.data(using: .utf8) else { 40 | DispatchQueue.main.async { 41 | self.physicalDisks = [] 42 | self.isRefreshing = false 43 | if !self.isInitialLoadComplete { self.isInitialLoadComplete = true } 44 | } 45 | return 46 | } 47 | 48 | do { 49 | if let plist = try PropertyListSerialization.propertyList(from: allDisksData, options: [], format: nil) as? [String: Any] { 50 | let newPhysicalDisks = self.parseDisks(from: plist) 51 | DispatchQueue.main.async { 52 | self.physicalDisks = newPhysicalDisks 53 | self.isRefreshing = false 54 | if !self.isInitialLoadComplete { self.isInitialLoadComplete = true } 55 | 56 | self.busyVolumeIdentifier = nil 57 | self.busyEjectingIdentifier = nil 58 | self.isUnmountingAll = false 59 | } 60 | } 61 | } catch { 62 | print("Error parsing diskutil list plist: \(error)") 63 | DispatchQueue.main.async { 64 | self.physicalDisks = [] 65 | self.isRefreshing = false 66 | if !self.isInitialLoadComplete { self.isInitialLoadComplete = true } 67 | } 68 | } 69 | } 70 | } 71 | 72 | func unmountAllDrives() { 73 | let drivesToUnmount = self.physicalDisks.flatMap { $0.volumes }.filter { $0.isMounted && $0.category == .user && !$0.isProtected } 74 | guard !drivesToUnmount.isEmpty else { return } 75 | 76 | DispatchQueue.main.async { self.isUnmountingAll = true } 77 | 78 | DispatchQueue.global(qos: .userInitiated).async { 79 | for drive in drivesToUnmount { 80 | _ = runShell("diskutil unmount \(drive.id)") 81 | } 82 | 83 | DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in 84 | self?.refreshDrives(qos: .userInitiated) 85 | } 86 | } 87 | } 88 | 89 | func eject(disk: PhysicalDisk) { 90 | DispatchQueue.main.async { self.busyEjectingIdentifier = disk.id } 91 | DispatchQueue.global(qos: .userInitiated).async { 92 | let result = runShell("diskutil eject \(disk.id)") 93 | DispatchQueue.main.async { 94 | if let error = result.error, !error.isEmpty { 95 | let friendlyMessage = self.parseDiskUtilError(error, for: disk.name ?? disk.id, operation: .eject) 96 | self.operationError = AppAlert(title: NSLocalizedString("Eject Failed", comment: "Alert title"), message: friendlyMessage) 97 | } 98 | self.busyEjectingIdentifier = nil 99 | self.refreshDrives(qos: .userInitiated) 100 | } 101 | } 102 | } 103 | 104 | func mount(volume: Volume) { 105 | let userInfo = ["deviceIdentifier": volume.id] 106 | NotificationCenter.default.post(name: .willManuallyMount, object: nil, userInfo: userInfo) 107 | DispatchQueue.main.async { self.busyVolumeIdentifier = volume.id } 108 | DispatchQueue.global(qos: .userInitiated).async { 109 | let result = runShell("diskutil mount \(volume.id)") 110 | DispatchQueue.main.async { 111 | if let error = result.error, !error.isEmpty { 112 | let friendlyMessage = self.parseDiskUtilError(error, for: volume.name, operation: .mount) 113 | self.operationError = AppAlert(title: NSLocalizedString("Mount Failed", comment: "Alert title"), message: friendlyMessage) 114 | } 115 | self.busyVolumeIdentifier = nil 116 | self.refreshDrives(qos: .userInitiated) 117 | } 118 | } 119 | } 120 | 121 | func unmount(volume: Volume) { 122 | DispatchQueue.main.async { self.busyVolumeIdentifier = volume.id } 123 | DispatchQueue.global(qos: .userInitiated).async { 124 | let result = runShell("diskutil unmount \(volume.id)") 125 | DispatchQueue.main.async { 126 | if let error = result.error, !error.isEmpty { 127 | let friendlyMessage = self.parseDiskUtilError(error, for: volume.name, operation: .unmount) 128 | self.operationError = AppAlert(title: NSLocalizedString("Unmount Failed", comment: "Alert title"), message: friendlyMessage) 129 | } 130 | self.busyVolumeIdentifier = nil 131 | self.refreshDrives(qos: .userInitiated) 132 | } 133 | } 134 | } 135 | 136 | // MARK: - Parsing and Data Creation Helpers 137 | 138 | private func parseDisks(from plist: [String: Any]) -> [PhysicalDisk] { 139 | guard let allDisksAndPartitions = plist["AllDisksAndPartitions"] as? [[String: Any]] else { return [] } 140 | let ignoredDiskIDs = PersistenceManager.shared.ignoredDisks 141 | var newDisks: [PhysicalDisk] = [] 142 | 143 | for diskData in allDisksAndPartitions { 144 | guard let physicalIdentifier = diskData["DeviceIdentifier"] as? String else { continue } 145 | 146 | if ignoredDiskIDs.contains(physicalIdentifier) { 147 | continue 148 | } 149 | 150 | let infoPlist = getInfoForDisk(for: diskData["DeviceIdentifier"] as? String ?? "") 151 | if (infoPlist?["Internal"] as? Bool) ?? false { 152 | continue 153 | } 154 | 155 | let isContainer = diskData["Content"] as? String == "Apple_APFS_Container" 156 | if isContainer { 157 | continue 158 | } 159 | 160 | guard let physicalIdentifier = diskData["DeviceIdentifier"] as? String else { continue } 161 | 162 | var allVolumes: [Volume] = [] 163 | 164 | if let partitions = diskData["Partitions"] as? [[String: Any]] { 165 | for partitionData in partitions { 166 | if let contentType = partitionData["Content"] as? String, contentType == "Apple_APFS" { 167 | let storeID = partitionData["DeviceIdentifier"] as? String ?? "" 168 | if let container = findAPFSContainer(forStore: storeID, in: allDisksAndPartitions), 169 | let apfsVolumes = container["APFSVolumes"] as? [[String: Any]] { 170 | allVolumes.append(contentsOf: apfsVolumes.compactMap { createVolume(from: $0) }) 171 | } 172 | } else { 173 | if let volume = createVolume(from: partitionData) { allVolumes.append(volume) } 174 | } 175 | } 176 | } 177 | 178 | if let apfsVolumes = diskData["APFSVolumes"] as? [[String: Any]] { 179 | allVolumes.append(contentsOf: apfsVolumes.compactMap { createVolume(from: $0) }) 180 | } 181 | 182 | if !allVolumes.isEmpty { 183 | let connectionInfo = getConnectionInfo(from: infoPlist) 184 | let diskName = infoPlist?["IORegistryEntryName"] as? String ?? infoPlist?["MediaName"] as? String ?? allVolumes.first(where: { $0.category == .user })?.name 185 | let (totalSizeStr, freeSpaceStr, usagePercentage) = calculateParentDiskStats(totalBytes: diskData["Size"] as? Int64 ?? 0, volumes: allVolumes) 186 | 187 | let physicalDisk = PhysicalDisk(id: physicalIdentifier, connectionType: connectionInfo.type, volumes: allVolumes, name: diskName, totalSize: totalSizeStr, freeSpace: freeSpaceStr, usagePercentage: usagePercentage, type: connectionInfo.diskType) 188 | newDisks.append(physicalDisk) 189 | } 190 | } 191 | return newDisks.sorted { 192 | if $0.type == .physical && $1.type == .diskImage { return true } 193 | if $0.type == .diskImage && $1.type == .physical { return false } 194 | return ($0.name ?? "") < ($1.name ?? "") 195 | } 196 | } 197 | 198 | private func getInfoForDisk(for identifier: String) -> [String: Any]? { 199 | guard !identifier.isEmpty else { return nil } 200 | let infoOutput = runShell("diskutil info -plist \(identifier)").output 201 | return infoOutput?.data(using: .utf8) 202 | .flatMap { try? PropertyListSerialization.propertyList(from: $0, options: [], format: nil) as? [String: Any] } 203 | } 204 | 205 | private func findAPFSContainer(forStore storeID: String, in allDisks: [[String: Any]]) -> [String: Any]? { 206 | return allDisks.first { disk in 207 | if let physicalStores = disk["APFSPhysicalStores"] as? [[String: Any]], 208 | let deviceID = physicalStores.first?["DeviceIdentifier"] as? String { 209 | return deviceID == storeID 210 | } 211 | return false 212 | } 213 | } 214 | 215 | private func calculateParentDiskStats(totalBytes: Int64, volumes: [Volume]) -> (String?, String?, Double?) { 216 | var usedBytes: Int64 = 0 217 | let hasMountedVolume = volumes.contains { $0.isMounted } 218 | 219 | for volume in volumes { 220 | if volume.isMounted, let mountPoint = volume.mountPoint, let attributes = getFileSystemAttributes(for: mountPoint) { 221 | usedBytes += (attributes.total - attributes.free) 222 | } 223 | } 224 | 225 | if hasMountedVolume && totalBytes > 0 { 226 | let formatter = ByteCountFormatter(); formatter.allowedUnits = [.useGB, .useMB, .useKB, .useTB]; formatter.countStyle = .file 227 | let totalSizeStr = formatter.string(fromByteCount: totalBytes) 228 | let freeSpaceStr = formatter.string(fromByteCount: totalBytes - usedBytes) 229 | let usagePercentage = Double(usedBytes) / Double(totalBytes) 230 | return (totalSizeStr, freeSpaceStr, usagePercentage) 231 | } 232 | return (nil, nil, nil) 233 | } 234 | 235 | private func createVolume(from volumeData: [String: Any]) -> Volume? { 236 | guard let deviceIdentifier = volumeData["DeviceIdentifier"] as? String, 237 | let volumeName = volumeData["VolumeName"] as? String else { return nil } 238 | 239 | let isProtected = PersistenceManager.shared.protectedVolumes.contains(deviceIdentifier) 240 | 241 | var isVirtualDisk = false 242 | if let session = DASessionCreate(kCFAllocatorDefault) { 243 | if let disk = DADiskCreateFromBSDName(kCFAllocatorDefault, session, deviceIdentifier) { 244 | if let desc = DADiskCopyDescription(disk) { 245 | let description = desc as! [String: Any] 246 | if description[kDADiskDescriptionDeviceModelKey as String] as? String == "Disk Image" { 247 | isVirtualDisk = true 248 | } 249 | } 250 | } 251 | } 252 | 253 | let contentType = volumeData["Content"] as? String 254 | let category: DriveCategory = (contentType == "EFI" && isVirtualDisk) ? .system : .user 255 | let isMounted = volumeData["MountPoint"] != nil 256 | let mountPoint = volumeData["MountPoint"] as? String 257 | let fileSystemType = contentType ?? (volumeData["FilesystemName"] as? String) ?? "Unknown" 258 | var freeSpaceStr: String?, totalSizeStr: String?, usagePercentage: Double? 259 | 260 | if isMounted, let mountPoint = mountPoint, let attributes = getFileSystemAttributes(for: mountPoint) { 261 | let formatter = ByteCountFormatter(); formatter.allowedUnits = [.useGB, .useMB, .useKB, .useTB]; formatter.countStyle = .file 262 | freeSpaceStr = formatter.string(fromByteCount: attributes.free) 263 | totalSizeStr = formatter.string(fromByteCount: attributes.total) 264 | if attributes.total > 0 { usagePercentage = Double(attributes.total - attributes.free) / Double(attributes.total) } 265 | } 266 | 267 | return Volume(id: deviceIdentifier, name: volumeName, isMounted: isMounted, mountPoint: mountPoint, 268 | freeSpace: freeSpaceStr, totalSize: totalSizeStr, fileSystemType: fileSystemType, 269 | usagePercentage: usagePercentage, category: category, isProtected: isProtected) 270 | } 271 | 272 | private func getConnectionInfo(from infoPlist: [String: Any]?) -> (type: String, diskType: PhysicalDiskType) { 273 | let defaultType = NSLocalizedString("Unknown", comment: "Unknown connection type") 274 | guard let info = infoPlist else { return (defaultType, .physical) } 275 | if info["VirtualOrPhysical"] as? String == "Virtual" { 276 | return (NSLocalizedString("Disk Image", comment: "Disk Image"), .diskImage) 277 | } 278 | let connectionType = info["BusProtocol"] as? String ?? defaultType 279 | return (connectionType, .physical) 280 | } 281 | 282 | // MARK: - Error Parsing 283 | 284 | private enum DiskOperation { case mount, unmount, eject } 285 | 286 | private func parseDiskUtilError(_ rawError: String, for name: String, operation: DiskOperation) -> String { 287 | let lowercasedError = rawError.lowercased() 288 | 289 | if operation == .mount && name.uppercased() == "EFI" { 290 | let formatString = NSLocalizedString("The “EFI” partition cannot be mounted directly. This is a special system partition and this behavior is normal.", comment: "User-friendly error for a failed EFI mount.") 291 | return String(format: formatString, name) 292 | } 293 | 294 | if lowercasedError.contains("at least one volume could not be unmounted") { 295 | let formatString = NSLocalizedString("Failed to eject “%@” because one of its volumes is busy or in use.", comment: "User-friendly error for a partial eject failure. %@ is disk name.") 296 | return String(format: formatString, name) 297 | } 298 | 299 | if lowercasedError.contains("busy") || lowercasedError.contains("in use") { 300 | let actionString: String 301 | switch operation { 302 | case .unmount: actionString = NSLocalizedString("unmount", comment: "verb") 303 | case .eject: actionString = NSLocalizedString("eject", comment: "verb") 304 | case .mount: actionString = NSLocalizedString("mount", comment: "verb") 305 | } 306 | let formatString = NSLocalizedString("Failed to %@ “%@” because it is currently in use by another application.", comment: "Error message") 307 | return String(format: formatString, actionString, name) 308 | } 309 | 310 | let genericFormatString = NSLocalizedString("An unknown error occurred while trying to %@ “%@”.", comment: "Error message") 311 | let actionString: String 312 | switch operation { 313 | case .mount: actionString = "mount" 314 | case .unmount: actionString = "unmount" 315 | case .eject: actionString = "eject" 316 | } 317 | 318 | return "\(String(format: genericFormatString, actionString, name))\n\nDetails:\n\(rawError)" 319 | } 320 | 321 | private func getFileSystemAttributes(for path: String) -> (free: Int64, total: Int64)? { 322 | do { 323 | let attributes = try FileManager.default.attributesOfFileSystem(forPath: path) 324 | if let freeSpace = attributes[.systemFreeSize] as? NSNumber, 325 | let totalSize = attributes[.systemSize] as? NSNumber { 326 | return (free: freeSpace.int64Value, total: totalSize.int64Value) 327 | } 328 | } catch { 329 | print("Error getting file system attributes for \(path): \(error)") 330 | } 331 | return nil 332 | } 333 | 334 | // MARK: - Notification Handling 335 | 336 | private func setupDiskChangeObservers() { 337 | let notificationCenter = NSWorkspace.shared.notificationCenter 338 | notificationCenter.addObserver(self, selector: #selector(handleDiskNotification), name: NSWorkspace.didMountNotification, object: nil) 339 | notificationCenter.addObserver(self, selector: #selector(handleDiskNotification), name: NSWorkspace.didUnmountNotification, object: nil) 340 | } 341 | 342 | @objc private func handleDiskNotification(notification: NSNotification) { 343 | refreshDebounceTimer?.invalidate() 344 | refreshDebounceTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { [weak self] _ in 345 | if let volumeURL = notification.userInfo?[NSWorkspace.volumeURLUserInfoKey] as? URL { 346 | print("Disk notification received for volume: \(volumeURL.lastPathComponent). Refreshing list.") 347 | } else { 348 | print("Disk notification received. Refreshing list.") 349 | } 350 | self?.refreshDrives() 351 | } 352 | } 353 | } -------------------------------------------------------------------------------- /MountMate/Managers/LaunchAtLoginManager.swift: -------------------------------------------------------------------------------- 1 | // Created by homielab.com 2 | 3 | import Foundation 4 | import ServiceManagement 5 | 6 | class LaunchAtLoginManager: ObservableObject { 7 | @Published var isEnabled: Bool { 8 | didSet { 9 | UserDefaults.standard.set(isEnabled, forKey: "launchAtLoginEnabled") 10 | updateLoginItemStatus() 11 | } 12 | } 13 | 14 | private let service: SMAppService 15 | 16 | init() { 17 | self.service = SMAppService() 18 | if UserDefaults.standard.object(forKey: "launchAtLoginEnabled") == nil { 19 | self.isEnabled = true 20 | } else { 21 | self.isEnabled = UserDefaults.standard.bool(forKey: "launchAtLoginEnabled") 22 | } 23 | } 24 | 25 | private func updateLoginItemStatus() { 26 | guard #available(macOS 13.0, *) else { return } 27 | do { 28 | if isEnabled { 29 | try service.register() 30 | } else { 31 | try service.unregister() 32 | } 33 | } catch { 34 | print("Failed to update login item status: \(error)") 35 | DispatchQueue.main.async { self.isEnabled.toggle() } 36 | } 37 | } 38 | } -------------------------------------------------------------------------------- /MountMate/Managers/PersistenceManager.swift: -------------------------------------------------------------------------------- 1 | // Created by homielab.com 2 | 3 | import Foundation 4 | import Combine 5 | 6 | class PersistenceManager: ObservableObject { 7 | static let shared = PersistenceManager() 8 | 9 | private let ignoredDisksKey = "mountmate_ignoredDisks" 10 | private let protectedVolumesKey = "mountmate_protectedVolumes" 11 | 12 | @Published var ignoredDisks: [String] 13 | @Published var protectedVolumes: [String] 14 | 15 | private init() { 16 | self.ignoredDisks = UserDefaults.standard.stringArray(forKey: ignoredDisksKey) ?? [] 17 | self.protectedVolumes = UserDefaults.standard.stringArray(forKey: protectedVolumesKey) ?? [] 18 | } 19 | 20 | func ignore(diskID: String) { 21 | guard !ignoredDisks.contains(diskID) else { return } 22 | ignoredDisks.append(diskID) 23 | saveIgnoredDisks() 24 | } 25 | 26 | func unignore(diskID: String) { 27 | ignoredDisks.removeAll { $0 == diskID } 28 | saveIgnoredDisks() 29 | } 30 | 31 | func protect(volumeID: String) { 32 | guard !protectedVolumes.contains(volumeID) else { return } 33 | protectedVolumes.append(volumeID) 34 | saveProtectedVolumes() 35 | } 36 | 37 | func unprotect(volumeID: String) { 38 | protectedVolumes.removeAll { $0 == volumeID } 39 | saveProtectedVolumes() 40 | } 41 | 42 | private func saveIgnoredDisks() { 43 | UserDefaults.standard.set(ignoredDisks, forKey: ignoredDisksKey) 44 | } 45 | 46 | private func saveProtectedVolumes() { 47 | UserDefaults.standard.set(protectedVolumes, forKey: protectedVolumesKey) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /MountMate/Managers/UpdaterController.swift: -------------------------------------------------------------------------------- 1 | // Created by homielab.com 2 | 3 | import Foundation 4 | import Sparkle 5 | import SwiftUI 6 | 7 | final class UpdaterController: NSObject, ObservableObject { 8 | private let updater: SPUUpdater 9 | 10 | init(updater: SPUUpdater) { 11 | self.updater = updater 12 | super.init() 13 | } 14 | 15 | @objc func checkForUpdates() { 16 | updater.checkForUpdates() 17 | } 18 | } 19 | 20 | -------------------------------------------------------------------------------- /MountMate/Models/DiskModels.swift: -------------------------------------------------------------------------------- 1 | // Created by homielab.com 2 | 3 | import Foundation 4 | 5 | struct Volume: Identifiable, Hashable { 6 | let id: String 7 | let name: String 8 | let isMounted: Bool 9 | let mountPoint: String? 10 | let freeSpace: String? 11 | let totalSize: String? 12 | let fileSystemType: String? 13 | let usagePercentage: Double? 14 | let category: DriveCategory 15 | var isProtected: Bool 16 | } 17 | 18 | enum PhysicalDiskType { 19 | case physical 20 | case diskImage 21 | } 22 | 23 | struct PhysicalDisk: Identifiable { 24 | let id: String 25 | let connectionType: String 26 | var volumes: [Volume] 27 | let name: String? 28 | let totalSize: String? 29 | let freeSpace: String? 30 | let usagePercentage: Double? 31 | let type: PhysicalDiskType 32 | } 33 | 34 | enum DriveCategory: String { 35 | case user 36 | case system 37 | } -------------------------------------------------------------------------------- /MountMate/MountMate.entitlements: -------------------------------------------------------------------------------- 1 | <?xml version="1.0" encoding="UTF-8"?> 2 | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 3 | <plist version="1.0"> 4 | <dict/> 5 | </plist> 6 | -------------------------------------------------------------------------------- /MountMate/Resources/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 | -------------------------------------------------------------------------------- /MountMate/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon_16x16.png", 5 | "idiom" : "mac", 6 | "scale" : "1x", 7 | "size" : "16x16" 8 | }, 9 | { 10 | "filename" : "icon_16x16@2x.png", 11 | "idiom" : "mac", 12 | "scale" : "2x", 13 | "size" : "16x16" 14 | }, 15 | { 16 | "filename" : "icon_32x32.png", 17 | "idiom" : "mac", 18 | "scale" : "1x", 19 | "size" : "32x32" 20 | }, 21 | { 22 | "filename" : "icon_32x32@2x.png", 23 | "idiom" : "mac", 24 | "scale" : "2x", 25 | "size" : "32x32" 26 | }, 27 | { 28 | "filename" : "icon_128x128.png", 29 | "idiom" : "mac", 30 | "scale" : "1x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "filename" : "icon_128x128@2x.png", 35 | "idiom" : "mac", 36 | "scale" : "2x", 37 | "size" : "128x128" 38 | }, 39 | { 40 | "filename" : "icon_256x256.png", 41 | "idiom" : "mac", 42 | "scale" : "1x", 43 | "size" : "256x256" 44 | }, 45 | { 46 | "filename" : "icon_256x256@2x.png", 47 | "idiom" : "mac", 48 | "scale" : "2x", 49 | "size" : "256x256" 50 | }, 51 | { 52 | "filename" : "icon_512x512.png", 53 | "idiom" : "mac", 54 | "scale" : "1x", 55 | "size" : "512x512" 56 | }, 57 | { 58 | "filename" : "icon_512x512@2x.png", 59 | "idiom" : "mac", 60 | "scale" : "2x", 61 | "size" : "512x512" 62 | } 63 | ], 64 | "info" : { 65 | "author" : "xcode", 66 | "version" : 1 67 | } 68 | } -------------------------------------------------------------------------------- /MountMate/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/homielab/mountmate/5709f82855dbde154c559ddc5ead9b08a6e3ebb8/MountMate/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128.png -------------------------------------------------------------------------------- /MountMate/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/homielab/mountmate/5709f82855dbde154c559ddc5ead9b08a6e3ebb8/MountMate/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png -------------------------------------------------------------------------------- /MountMate/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/homielab/mountmate/5709f82855dbde154c559ddc5ead9b08a6e3ebb8/MountMate/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16.png -------------------------------------------------------------------------------- /MountMate/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/homielab/mountmate/5709f82855dbde154c559ddc5ead9b08a6e3ebb8/MountMate/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png -------------------------------------------------------------------------------- /MountMate/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/homielab/mountmate/5709f82855dbde154c559ddc5ead9b08a6e3ebb8/MountMate/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256.png -------------------------------------------------------------------------------- /MountMate/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/homielab/mountmate/5709f82855dbde154c559ddc5ead9b08a6e3ebb8/MountMate/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png -------------------------------------------------------------------------------- /MountMate/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/homielab/mountmate/5709f82855dbde154c559ddc5ead9b08a6e3ebb8/MountMate/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32.png -------------------------------------------------------------------------------- /MountMate/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/homielab/mountmate/5709f82855dbde154c559ddc5ead9b08a6e3ebb8/MountMate/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png -------------------------------------------------------------------------------- /MountMate/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/homielab/mountmate/5709f82855dbde154c559ddc5ead9b08a6e3ebb8/MountMate/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512.png -------------------------------------------------------------------------------- /MountMate/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/homielab/mountmate/5709f82855dbde154c559ddc5ead9b08a6e3ebb8/MountMate/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png -------------------------------------------------------------------------------- /MountMate/Resources/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /MountMate/Resources/en.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | /* 2 | Localizable.strings (English) 3 | */ 4 | 5 | // MARK: - Main View & Actions 6 | //============================================= 7 | 8 | "No Drives Found" = "No Drives Found"; 9 | "Connect a USB drive, SD card, or mount a disk image to see it here." = "Connect a USB drive, SD card, or mount a disk image to see it here."; 10 | "External Disks" = "External Disks"; 11 | "Disk Images" = "Disk Images"; 12 | 13 | 14 | // MARK: - Action Buttons & Tooltips 15 | //============================================= 16 | 17 | "Unmount All" = "Unmount All User Volumes"; 18 | "Eject" = "Eject"; 19 | "Mount" = "Mount"; 20 | "Unmount" = "Unmount"; 21 | "Quit MountMate" = "Quit MountMate"; 22 | 23 | 24 | // MARK: - Volume & Disk Details 25 | //============================================= 26 | 27 | "free of" = "free of"; 28 | "Unknown" = "Unknown"; 29 | "Unmounted" = "Unmounted"; 30 | 31 | 32 | // MARK: - Context Menus 33 | //============================================= 34 | 35 | "Ignore This Disk" = "Ignore This Disk"; 36 | "Protect from 'Unmount All'" = "Protect from 'Unmount All'"; 37 | "Unprotect from 'Unmount All'" = "Unprotect from 'Unmount All'"; 38 | "Open in Finder" = "Open in Finder"; 39 | 40 | 41 | // MARK: - Settings View 42 | //============================================= 43 | 44 | "MountMate Settings" = "MountMate Settings"; 45 | 46 | // Tabs 47 | "General" = "General"; 48 | "Management" = "Management"; 49 | 50 | // General Tab 51 | "Start MountMate at Login" = "Start MountMate at Login"; 52 | "Block USB Auto-Mount" = "Block USB Auto-Mount"; 53 | "Unmount All Disks on Sleep" = "Unmount All Disks on Sleep"; 54 | "Language" = "Language"; 55 | 56 | // About & Updates Section 57 | "Homepage" = "Homepage"; 58 | "Support Email" = "Support Email"; 59 | "Donate" = "Donate"; 60 | "Check for Updates..." = "Check for Updates..."; 61 | 62 | // Management Tab 63 | "Ignored Disks" = "Ignored Disks"; 64 | "No ignored disks. Right-click a disk in the main list to ignore it." = "No ignored disks. Right-click a disk in the main list to ignore it."; 65 | "Protected Volumes" = "Protected Volumes"; 66 | "No protected volumes. Right-click a volume to protect it from 'Unmount All' and system sleep actions." = "No protected volumes. Right-click a volume to protect it from 'Unmount All' and system sleep actions."; 67 | 68 | 69 | // MARK: - Alerts & Errors 70 | //============================================= 71 | 72 | "OK" = "OK"; 73 | "Eject Failed" = "Eject Failed"; 74 | "Mount Failed" = "Mount Failed"; 75 | "Unmount Failed" = "Unmount Failed"; 76 | 77 | // Verbs for error messages 78 | "unmount" = "unmount"; 79 | "eject" = "eject"; 80 | "mount" = "mount"; 81 | 82 | // Error message formats 83 | "Failed to eject “%@” because one of its volumes is busy or in use." = "Failed to eject “%@” because one of its volumes is busy or in use."; 84 | "The “EFI” partition cannot be mounted directly. This is a special system partition and this behavior is normal." = "The “EFI” partition cannot be mounted directly. This is a special system partition and this behavior is normal."; 85 | "Failed to %@ “%@” because it is currently in use by another application." = "Failed to %@ “%@” because it is currently in use by another application."; 86 | "An unknown error occurred while trying to %@ “%@”." = "An unknown error occurred while trying to %@ “%@”."; 87 | 88 | 89 | // MARK: - App Lifecycle 90 | //============================================= 91 | 92 | "Restart Required" = "Restart Required"; 93 | "Please restart MountMate for the language change to take effect." = "Please restart MountMate for the language change to take effect."; 94 | "Restart Now" = "Restart Now"; 95 | "Later" = "Later"; -------------------------------------------------------------------------------- /MountMate/Resources/vi.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | /* 2 | Localizable.strings (Vietnamese) 3 | */ 4 | 5 | // MARK: - Main View & Actions 6 | //============================================= 7 | 8 | "No Drives Found" = "Không tìm thấy ổ đĩa"; 9 | "Connect a USB drive, SD card, or mount a disk image to see it here." = "Kết nối ổ USB, thẻ SD, hoặc gắn ảnh đĩa để xem tại đây."; 10 | "External Disks" = "Ổ đĩa ngoài"; 11 | "Disk Images" = "Ảnh đĩa"; 12 | 13 | 14 | // MARK: - Action Buttons & Tooltips 15 | //============================================= 16 | 17 | "Unmount All" = "Tháo tất cả phân vùng người dùng"; 18 | "Eject" = "Đẩy ra an toàn"; 19 | "Mount" = "Gắn"; 20 | "Unmount" = "Tháo"; 21 | "Quit MountMate" = "Thoát MountMate"; 22 | 23 | 24 | // MARK: - Volume & Disk Details 25 | //============================================= 26 | 27 | "free of" = "trống trong tổng số"; 28 | "Unknown" = "Không rõ"; 29 | "Unmounted" = "Chưa gắn"; 30 | 31 | 32 | // MARK: - Context Menus 33 | //============================================= 34 | 35 | "Ignore This Disk" = "Bỏ qua ổ đĩa này"; 36 | "Protect from 'Unmount All'" = "Bảo vệ khỏi 'Tháo tất cả'"; 37 | "Unprotect from 'Unmount All'" = "Bỏ bảo vệ khỏi 'Tháo tất cả'"; 38 | "Open in Finder" = "Mở trong Finder"; 39 | 40 | 41 | // MARK: - Settings View 42 | //============================================= 43 | 44 | "MountMate Settings" = "Cài đặt MountMate"; 45 | 46 | // Tabs 47 | "General" = "Chung"; 48 | "Management" = "Quản lý"; 49 | 50 | // General Tab 51 | "Start MountMate at Login" = "Khởi động cùng máy"; 52 | "Block USB Auto-Mount" = "Chặn tự động gắn USB"; 53 | "Unmount All Disks on Sleep" = "Tháo tất cả ổ đĩa khi ngủ"; 54 | "Language" = "Ngôn ngữ"; 55 | 56 | // About & Updates Section 57 | "Homepage" = "Trang chủ"; 58 | "Support Email" = "Email hỗ trợ"; 59 | "Donate" = "Ủng hộ"; 60 | "Check for Updates..." = "Kiểm tra cập nhật..."; 61 | 62 | // Management Tab 63 | "Ignored Disks" = "Ổ đĩa bị bỏ qua"; 64 | "No ignored disks. Right-click a disk in the main list to ignore it." = "Không có ổ đĩa nào bị bỏ qua. Nhấp chuột phải vào một ổ đĩa trong danh sách chính để bỏ qua nó."; 65 | "Protected Volumes" = "Phân vùng được bảo vệ"; 66 | "No protected volumes. Right-click a volume to protect it from 'Unmount All' and system sleep actions." = "Không có phân vùng nào được bảo vệ. Nhấp chuột phải vào một phân vùng để bảo vệ nó khỏi 'Tháo tất cả' và khi máy ngủ."; 67 | 68 | 69 | // MARK: - Alerts & Errors 70 | //============================================= 71 | 72 | "OK" = "OK"; 73 | "Eject Failed" = "Đẩy ra thất bại"; 74 | "Mount Failed" = "Gắn thất bại"; 75 | "Unmount Failed" = "Tháo thất bại"; 76 | 77 | // Verbs for error messages 78 | "unmount" = "tháo"; 79 | "eject" = "đẩy ra"; 80 | "mount" = "gắn"; 81 | 82 | // Error message formats 83 | "Failed to eject “%@” because one of its volumes is busy or in use." = "Không thể đẩy ra “%@” vì một trong các phân vùng của nó đang bận hoặc đang được sử dụng."; 84 | "The “EFI” partition cannot be mounted directly. This is a special system partition and this behavior is normal." = "Không thể gắn trực tiếp phân vùng “EFI”. Đây là một phân vùng hệ thống đặc biệt và hành vi này là bình thường."; 85 | "Failed to %@ “%@” because it is currently in use by another application." = "Không thể %@ “%@” vì đang được sử dụng bởi một ứng dụng khác."; 86 | "An unknown error occurred while trying to %@ “%@”." = "Đã xảy ra lỗi không xác định khi đang cố gắng %@ “%@”."; 87 | 88 | 89 | // MARK: - App Lifecycle 90 | //============================================= 91 | 92 | "Restart Required" = "Cần khởi động lại"; 93 | "Please restart MountMate for the language change to take effect." = "Vui lòng khởi động lại MountMate để thay đổi ngôn ngữ có hiệu lực."; 94 | "Restart Now" = "Khởi động lại ngay"; 95 | "Later" = "Để sau"; -------------------------------------------------------------------------------- /MountMate/Resources/zh-Hans.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | /* 2 | Localizable.strings (Chinese (Simplified)) 3 | */ 4 | 5 | // MARK: - Main View & Actions 6 | //============================================= 7 | 8 | "No Drives Found" = "未找到磁盘"; 9 | "Connect a USB drive, SD card, or mount a disk image to see it here." = "连接 USB 设备、SD 卡或挂载磁盘镜像后将在此显示。"; 10 | "External Disks" = "外置磁盘"; 11 | "Disk Images" = "磁盘镜像"; 12 | 13 | // MARK: - Action Buttons & Tooltips 14 | //============================================= 15 | 16 | "Unmount All" = "卸载所有用户卷"; 17 | "Eject" = "弹出"; 18 | "Mount" = "挂载"; 19 | "Unmount" = "卸载"; 20 | "Quit MountMate" = "退出 MountMate"; 21 | 22 | // MARK: - Volume & Disk Details 23 | //============================================= 24 | 25 | "free of" = "剩余空间"; 26 | "Unknown" = "未知"; 27 | "Unmounted" = "已卸载"; 28 | 29 | // MARK: - Context Menus 30 | //============================================= 31 | 32 | "Ignore This Disk" = "忽略此磁盘"; 33 | "Protect from 'Unmount All'" = "保护,防止“卸载所有”"; 34 | "Unprotect from 'Unmount All'" = "取消保护,允许“卸载所有”"; 35 | "Open in Finder" = "在访达中打开"; 36 | 37 | // MARK: - Settings View 38 | //============================================= 39 | 40 | "MountMate Settings" = "MountMate 设置"; 41 | 42 | // Tabs 43 | "General" = "常规"; 44 | "Management" = "管理"; 45 | 46 | // General Tab 47 | "Start MountMate at Login" = "登录时启动 MountMate"; 48 | "Block USB Auto-Mount" = "阻止 USB 自动挂载"; 49 | "Unmount All Disks on Sleep" = "休眠时卸载所有磁盘"; 50 | "Language" = "语言"; 51 | 52 | // About & Updates Section 53 | "Homepage" = "主页"; 54 | "Support Email" = "支持邮箱"; 55 | "Donate" = "捐赠"; 56 | "Check for Updates..." = "检查更新..."; 57 | 58 | // Management Tab 59 | "Ignored Disks" = "已忽略磁盘"; 60 | "No ignored disks. Right-click a disk in the main list to ignore it." = "暂无已忽略磁盘。右键点击磁盘可选择忽略。"; 61 | "Protected Volumes" = "受保护卷"; 62 | "No protected volumes. Right-click a volume to protect it from 'Unmount All' and system sleep actions." = "暂无受保护卷。右键点击卷可设置保护,防止被“卸载所有”或系统休眠时卸载。"; 63 | 64 | // MARK: - Alerts & Errors 65 | //============================================= 66 | 67 | "OK" = "确定"; 68 | "Eject Failed" = "弹出失败"; 69 | "Mount Failed" = "挂载失败"; 70 | "Unmount Failed" = "卸载失败"; 71 | 72 | // Verbs for error messages 73 | "unmount" = "卸载"; 74 | "eject" = "弹出"; 75 | "mount" = "挂载"; 76 | 77 | // Error message formats 78 | "Failed to eject “%@” because one of its volumes is busy or in use." = "由于部分卷正在使用,无法弹出“%@”。"; 79 | "The “EFI” partition cannot be mounted directly. This is a special system partition and this behavior is normal." = "“EFI” 分区为系统特殊分区,无法直接挂载,属正常现象。"; 80 | "Failed to %@ “%@” because it is currently in use by another application." = "由于“%@”正被其他应用占用,无法 %@。"; 81 | "An unknown error occurred while trying to %@ “%@”." = "尝试 %@ “%@” 时发生未知错误。"; 82 | 83 | // MARK: - App Lifecycle 84 | //============================================= 85 | 86 | "Restart Required" = "需要重启"; 87 | "Please restart MountMate for the language change to take effect." = "请重启 MountMate 以应用语言更改。"; 88 | "Restart Now" = "立即重启"; 89 | "Later" = "稍后"; 90 | -------------------------------------------------------------------------------- /MountMate/Utilities/AppAlert.swift: -------------------------------------------------------------------------------- 1 | // Created by homielab.com 2 | 3 | import Foundation 4 | 5 | struct AppAlert: Identifiable { 6 | let id = UUID() 7 | let title: String 8 | let message: String 9 | } 10 | -------------------------------------------------------------------------------- /MountMate/Utilities/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // Created by homielab.com 2 | 3 | import AppKit 4 | import Combine 5 | import SwiftUI 6 | 7 | class AppDelegate: NSObject, NSApplicationDelegate { 8 | private var cancellables = Set<AnyCancellable>() 9 | 10 | func applicationDidFinishLaunching(_ aNotification: Notification) { 11 | DriveManager.shared.$operationError 12 | .receive(on: DispatchQueue.main) 13 | .compactMap { $0 } 14 | .sink { appAlert in self.showAlert(appAlert) } 15 | .store(in: &cancellables) 16 | 17 | NSWorkspace.shared.notificationCenter.addObserver( 18 | self, 19 | selector: #selector(systemWillSleep), 20 | name: NSWorkspace.willSleepNotification, 21 | object: nil 22 | ) 23 | } 24 | 25 | @objc private func systemWillSleep(_ notification: Notification) { 26 | if UserDefaults.standard.bool(forKey: "ejectOnSleepEnabled") { 27 | print("System will sleep. Ejecting all user volumes.") 28 | DriveManager.shared.unmountAllDrives() 29 | } 30 | } 31 | 32 | private func showAlert(_ appAlert: AppAlert) { 33 | let alert = NSAlert() 34 | alert.messageText = appAlert.title 35 | alert.informativeText = appAlert.message 36 | alert.alertStyle = .warning 37 | alert.addButton(withTitle: NSLocalizedString("OK", comment: "OK button")) 38 | NSApp.activate(ignoringOtherApps: true) 39 | alert.runModal() 40 | DriveManager.shared.operationError = nil 41 | } 42 | } -------------------------------------------------------------------------------- /MountMate/Utilities/Notifications.swift: -------------------------------------------------------------------------------- 1 | // Created by homielab.com 2 | 3 | import Foundation 4 | 5 | extension Notification.Name { 6 | static let willManuallyMount = Notification.Name("com.homielab.mountmate.willManuallyMount") 7 | } -------------------------------------------------------------------------------- /MountMate/Utilities/Shell.swift: -------------------------------------------------------------------------------- 1 | // Created by homielab.com 2 | 3 | import Foundation 4 | 5 | @discardableResult 6 | func runShell(_ command: String) -> (output: String?, error: String?) { 7 | let task = Process() 8 | let outPipe = Pipe() 9 | let errPipe = Pipe() 10 | 11 | task.standardOutput = outPipe 12 | task.standardError = errPipe 13 | task.arguments = ["-c", command] 14 | task.launchPath = "/bin/zsh" 15 | task.standardInput = nil 16 | 17 | do { 18 | try task.run() 19 | } catch { 20 | return (nil, "Failed to run shell task: \(error)") 21 | } 22 | 23 | let outData = outPipe.fileHandleForReading.readDataToEndOfFile() 24 | let errData = errPipe.fileHandleForReading.readDataToEndOfFile() 25 | 26 | let output = String(data: outData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) 27 | let error = String(data: errData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) 28 | 29 | return (output, error) 30 | } -------------------------------------------------------------------------------- /MountMate/Views/Components/CircularProgressRing.swift: -------------------------------------------------------------------------------- 1 | // Created by homielab.com 2 | 3 | import SwiftUI 4 | 5 | struct CircularProgressRing: View { 6 | let progress: Double 7 | let color: Color 8 | let lineWidth: CGFloat 9 | 10 | var body: some View { 11 | ZStack { 12 | Circle() 13 | .stroke( 14 | color.opacity(0.3), 15 | lineWidth: lineWidth 16 | ) 17 | Circle() 18 | .trim(from: 0, to: progress) 19 | .stroke( 20 | color, 21 | style: StrokeStyle( 22 | lineWidth: lineWidth, 23 | lineCap: .round 24 | ) 25 | ) 26 | .rotationEffect(.degrees(-90)) 27 | } 28 | } 29 | } -------------------------------------------------------------------------------- /MountMate/Views/Main/LoadingView.swift: -------------------------------------------------------------------------------- 1 | // Created by homielab.com 2 | 3 | import SwiftUI 4 | 5 | struct LoadingView: View { 6 | var body: some View { 7 | VStack { 8 | ProgressView() 9 | Text(NSLocalizedString("Loading Disks...", comment: "Initial loading text")) 10 | .padding(.top, 8) 11 | .foregroundColor(.secondary) 12 | } 13 | .frame(width: 370, height: 200) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /MountMate/Views/Main/MainView.swift: -------------------------------------------------------------------------------- 1 | // Created by homielab.com 2 | 3 | import SwiftUI 4 | 5 | struct HeaderActionsView: View { 6 | @ObservedObject var driveManager: DriveManager 7 | var onShowSettings: () -> Void 8 | var onRefresh: () -> Void 9 | 10 | private var canUnmountAll: Bool { 11 | driveManager.physicalDisks.flatMap { $0.volumes }.contains { $0.isMounted && $0.category == .user } 12 | } 13 | 14 | var body: some View { 15 | HStack { 16 | Text("MountMate").font(.headline) 17 | Spacer() 18 | 19 | Button(action: { driveManager.unmountAllDrives() }) { 20 | Image(systemName: "eject.circle.fill").opacity(driveManager.isUnmountingAll ? 0 : 1) 21 | } 22 | .buttonStyle(.plain).help(NSLocalizedString("Unmount All", comment: "Unmount All button tooltip")) 23 | .disabled(!canUnmountAll || driveManager.isUnmountingAll) 24 | .overlay { if driveManager.isUnmountingAll { ProgressView().controlSize(.small) } } 25 | 26 | Button(action: onShowSettings) { Image(systemName: "gearshape.fill") } 27 | .buttonStyle(.plain).help("Settings") 28 | 29 | Button(action: onRefresh) { 30 | if driveManager.isRefreshing { 31 | ProgressView().controlSize(.small) 32 | } else { 33 | Image(systemName: "arrow.clockwise") 34 | } 35 | } 36 | .buttonStyle(.plain).help("Refresh Drives") 37 | .disabled(driveManager.isRefreshing) 38 | 39 | Button(action: { NSApplication.shared.terminate(nil) }) { Image(systemName: "power").foregroundColor(.red) } 40 | .buttonStyle(.plain).help(NSLocalizedString("Quit MountMate", comment: "Quit button tooltip")) 41 | } 42 | .padding() 43 | } 44 | } 45 | 46 | struct MainView: View { 47 | @StateObject private var driveManager = DriveManager.shared 48 | @Environment(\.openWindow) var openWindow 49 | 50 | private var externalDisks: [PhysicalDisk] { 51 | driveManager.physicalDisks.filter { $0.type == .physical } 52 | } 53 | 54 | private var diskImages: [PhysicalDisk] { 55 | driveManager.physicalDisks.filter { $0.type == .diskImage } 56 | } 57 | 58 | var body: some View { 59 | VStack(spacing: 0) { 60 | HeaderActionsView( 61 | driveManager: driveManager, 62 | onShowSettings: openAndFocusSettingsWindow, 63 | onRefresh: { driveManager.refreshDrives() } 64 | ) 65 | 66 | if driveManager.physicalDisks.isEmpty { 67 | noDrivesView 68 | } else { 69 | driveListView 70 | } 71 | } 72 | .frame(width: 370) 73 | .padding(.bottom, 8) 74 | } 75 | 76 | private func openAndFocusSettingsWindow() { 77 | let settingsWindowTitle = NSLocalizedString("MountMate Settings", comment: "") 78 | if let window = NSApp.windows.first(where: { $0.title == settingsWindowTitle }) { 79 | window.makeKeyAndOrderFront(nil) 80 | NSApp.activate(ignoringOtherApps: true) 81 | } else { 82 | openWindow(id: "settings-window") 83 | } 84 | } 85 | 86 | private var driveListView: some View { 87 | List { 88 | if !externalDisks.isEmpty { 89 | Section(header: Text(NSLocalizedString("External Disks", comment: "Section header"))) { 90 | ForEach(externalDisks) { disk in 91 | DiskHeaderRow(disk: disk, manager: driveManager) 92 | 93 | ForEach(disk.volumes) { volume in 94 | VolumeRowView(volume: volume, manager: driveManager) 95 | .padding(.leading, 24) 96 | } 97 | } 98 | } 99 | } 100 | if !diskImages.isEmpty { 101 | Section(header: Text(NSLocalizedString("Disk Images", comment: "Section header"))) { 102 | ForEach(diskImages) { disk in 103 | DiskHeaderRow(disk: disk, manager: driveManager) 104 | 105 | ForEach(disk.volumes) { volume in 106 | VolumeRowView(volume: volume, manager: driveManager) 107 | .padding(.leading, 24) 108 | } 109 | } 110 | } 111 | } 112 | } 113 | .listStyle(.sidebar) 114 | .frame(maxHeight: 400) 115 | .listRowSeparator(.hidden) 116 | .listRowInsets(EdgeInsets(top: 0, leading: 8, bottom: 0, trailing: 8)) 117 | } 118 | 119 | private var noDrivesView: some View { 120 | VStack(spacing: 8) { 121 | Image(systemName: "externaldrive.fill.badge.questionmark").font(.system(size: 40)).foregroundColor(.secondary) 122 | Text(NSLocalizedString("No Drives Found", comment: "Empty state title")).font(.headline) 123 | Text(NSLocalizedString("Connect a USB drive, SD card, or mount a disk image to see it here.", comment: "Empty state description")) 124 | .font(.caption).foregroundColor(.secondary).multilineTextAlignment(.center).padding(.horizontal) 125 | } 126 | .frame(height: 150) 127 | } 128 | } 129 | 130 | struct DiskHeaderRow: View { 131 | let disk: PhysicalDisk 132 | @ObservedObject var manager: DriveManager 133 | 134 | var body: some View { 135 | HStack(spacing: 0) { 136 | HStack { 137 | ZStack { 138 | Image(systemName: "internaldrive.fill").font(.title2) 139 | if let percentage = disk.usagePercentage { 140 | CircularProgressRing(progress: percentage, color: .purple, lineWidth: 3.5).frame(width: 32, height: 32) 141 | } 142 | } 143 | .frame(width: 40, height: 40) 144 | 145 | VStack(alignment: .leading, spacing: 2) { 146 | Text(disk.name ?? disk.connectionType).font(.headline) 147 | if let total = disk.totalSize, let free = disk.freeSpace { 148 | Text("\(disk.connectionType) • \(free) free of \(total)").font(.caption).foregroundColor(.secondary) 149 | } else { 150 | Text(disk.connectionType).font(.caption).foregroundColor(.secondary) 151 | } 152 | } 153 | } 154 | .contentShape(Rectangle()) 155 | .contextMenu { 156 | Button(NSLocalizedString("Ignore This Disk", comment: "Context menu action")) { 157 | PersistenceManager.shared.ignore(diskID: disk.id) 158 | DriveManager.shared.refreshDrives() 159 | } 160 | Button(NSLocalizedString("Eject", comment: "Context menu action")) { 161 | manager.eject(disk: disk) 162 | } 163 | } 164 | 165 | Spacer() 166 | 167 | let isEjecting = manager.busyEjectingIdentifier == disk.id 168 | Button(action: { manager.eject(disk: disk) }) { 169 | Image(systemName: "eject.fill").opacity(isEjecting ? 0 : 1) 170 | } 171 | .buttonStyle(.bordered).tint(.purple).disabled(isEjecting) 172 | .overlay { if isEjecting { ProgressView().controlSize(.small) } } 173 | .help(NSLocalizedString("Eject", comment: "Eject button tooltip")) 174 | } 175 | .padding(.vertical, 8) 176 | } 177 | } 178 | 179 | struct VolumeRowView: View { 180 | let volume: Volume 181 | @ObservedObject var manager: DriveManager 182 | 183 | private var isLoading: Bool { manager.busyVolumeIdentifier == volume.id } 184 | 185 | private func usageColor(for percentage: Double) -> Color { 186 | if percentage > 0.9 { return .red } 187 | else if percentage > 0.75 { return .orange } 188 | return .accentColor 189 | } 190 | 191 | var body: some View { 192 | HStack(spacing: 0) { 193 | HStack { 194 | ZStack { 195 | Image(systemName: "externaldrive") 196 | .font(.body) 197 | .foregroundColor(volume.isMounted ? .accentColor : .secondary.opacity(0.6)) 198 | 199 | if volume.isMounted, let percentage = volume.usagePercentage { 200 | CircularProgressRing(progress: percentage, color: usageColor(for: percentage), lineWidth: 3.0) 201 | .frame(width: 26, height: 26) 202 | } 203 | } 204 | .frame(width: 24, alignment: .center) 205 | .padding(.trailing, 8) 206 | 207 | VStack(alignment: .leading, spacing: 2) { 208 | Text(volume.name) 209 | .fontWeight(.semibold) 210 | .foregroundColor(volume.isMounted ? .primary : .secondary) 211 | 212 | if !volume.isMounted { 213 | Text("Unmounted").font(.caption).foregroundColor(.secondary) 214 | } else if let fsType = volume.fileSystemType { 215 | Text(fsType).font(.caption).foregroundColor(.secondary) 216 | } 217 | } 218 | Spacer() 219 | } 220 | .contentShape(Rectangle()) 221 | .onTapGesture { 222 | if volume.isMounted, let mountPoint = volume.mountPoint { 223 | NSWorkspace.shared.open(URL(fileURLWithPath: mountPoint)) 224 | } 225 | } 226 | .contextMenu { 227 | if volume.isMounted { 228 | if volume.isProtected { 229 | Button { 230 | PersistenceManager.shared.unprotect(volumeID: volume.id) 231 | DriveManager.shared.refreshDrives() 232 | } label: { 233 | Label("Unprotect from 'Unmount All'", systemImage: "lock.open.fill") 234 | } 235 | } else { 236 | Button { 237 | PersistenceManager.shared.protect(volumeID: volume.id) 238 | DriveManager.shared.refreshDrives() 239 | } label: { 240 | Label("Protect from 'Unmount All'", systemImage: "lock.fill") 241 | } 242 | } 243 | Divider() 244 | Button { manager.unmount(volume: volume) } label: { Label("Unmount", systemImage: "xmark.circle") } 245 | Button { 246 | if let path = volume.mountPoint { NSWorkspace.shared.open(URL(fileURLWithPath: path)) } 247 | } label: { 248 | Label("Open in Finder", systemImage: "folder") 249 | } 250 | } else { 251 | Button { manager.mount(volume: volume) } label: { Label("Mount", systemImage: "arrow.up.circle") } 252 | } 253 | } 254 | 255 | Button(action: { 256 | if volume.isMounted { manager.unmount(volume: volume) } 257 | else { manager.mount(volume: volume) } 258 | }) { 259 | Image(systemName: volume.isMounted ? "xmark.circle.fill" : "arrow.up.circle.fill") 260 | .opacity(isLoading ? 0 : 1) 261 | } 262 | .buttonStyle(.bordered) 263 | .tint(volume.isMounted ? .red : .blue) 264 | .disabled(isLoading) 265 | .overlay { if isLoading { ProgressView().controlSize(.small) } } 266 | .help(volume.isMounted ? NSLocalizedString("Unmount", comment: "Unmount button tooltip") : NSLocalizedString("Mount", comment: "Mount button tooltip")) 267 | .padding(.leading, 8) 268 | } 269 | .padding(.vertical, 4) 270 | } 271 | } 272 | -------------------------------------------------------------------------------- /MountMate/Views/Settings/SettingsView.swift: -------------------------------------------------------------------------------- 1 | // Created by homielab.com 2 | 3 | import SwiftUI 4 | 5 | struct SettingsView: View { 6 | @EnvironmentObject var launchManager: LaunchAtLoginManager 7 | @EnvironmentObject var diskMounter: DiskMounter 8 | @EnvironmentObject var updaterViewModel: UpdaterController 9 | 10 | @StateObject private var persistence = PersistenceManager.shared 11 | @AppStorage("ejectOnSleepEnabled") private var ejectOnSleepEnabled = false 12 | 13 | @State private var selectedLanguage: String = { 14 | guard let preferredLanguages = UserDefaults.standard.array(forKey: "AppleLanguages") as? [String], 15 | let firstLanguage = preferredLanguages.first else { return "en" } 16 | if firstLanguage.starts(with: "vi") { return "vi" } 17 | if firstLanguage.starts(with: "zh") { return "zh-Hans" } 18 | return "en" 19 | }() 20 | 21 | @State private var showRestartAlert = false 22 | 23 | private var appVersion: String { 24 | let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "N/A" 25 | let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "N/A" 26 | return "Version \(version) (\(build))" 27 | } 28 | 29 | var body: some View { 30 | TabView { 31 | generalSettings.tabItem { Label("General", systemImage: "gear") } 32 | managementSettings.tabItem { Label("Management", systemImage: "slider.horizontal.3") } 33 | } 34 | .frame(width: 450, height: 350) 35 | .alert("Restart Required", isPresented: $showRestartAlert) { 36 | Button("Restart Now", role: .destructive) { 37 | UserDefaults.standard.set([selectedLanguage], forKey: "AppleLanguages") 38 | relaunchApp() 39 | } 40 | Button("Later", role: .cancel) {} 41 | } message: { 42 | Text("Please restart MountMate for the language change to take effect.") 43 | } 44 | } 45 | 46 | private var generalSettings: some View { 47 | Form { 48 | Section { 49 | Toggle("Start MountMate at Login", isOn: $launchManager.isEnabled) 50 | Toggle("Block USB Auto-Mount", isOn: $diskMounter.blockUSBAutoMount) 51 | Toggle("Unmount All Disks on Sleep", isOn: $ejectOnSleepEnabled) 52 | Picker("Language", selection: $selectedLanguage) { 53 | Text("English").tag("en") 54 | Text("Tiếng Việt").tag("vi") 55 | Text("中文").tag("zh-Hans") 56 | } 57 | .pickerStyle(.menu) 58 | .onChange(of: selectedLanguage) { _ in showRestartAlert = true } 59 | } 60 | 61 | Section("About & Updates") { 62 | Link(destination: URL(string: "https://homielab.com/page/mountmate")!) { 63 | Label("Homepage", systemImage: "house.fill") 64 | } 65 | Link(destination: URL(string: "mailto:contact@homielab.com")!) { 66 | Label("Support Email", systemImage: "envelope.fill") 67 | } 68 | Link(destination: URL(string: "https://ko-fi.com/homielab")!) { 69 | Label(title: { Text("Donate") }, icon: { Image(systemName: "heart.fill").foregroundColor(.red) }) 70 | } 71 | Button(action: { updaterViewModel.checkForUpdates() }) { 72 | Label("Check for Updates...", systemImage: "arrow.down.circle.fill") 73 | } 74 | } 75 | .foregroundColor(.primary) 76 | 77 | Spacer() 78 | Text(appVersion).font(.caption).foregroundColor(.secondary).frame(maxWidth: .infinity, alignment: .center) 79 | } 80 | .formStyle(.grouped).padding() 81 | } 82 | 83 | private var managementSettings: some View { 84 | Form { 85 | Section(header: Text("Ignored Disks"), footer: Text("Right-click a disk to ignore it. Useful for disk readers or hubs that appear as empty devices.")) { 86 | if persistence.ignoredDisks.isEmpty { 87 | CenteredContent { 88 | Image(systemName: "eye.slash.circle").font(.title).foregroundColor(.secondary) 89 | Text("No Ignored Disks").fontWeight(.semibold) 90 | } 91 | } else { 92 | List { 93 | ForEach(persistence.ignoredDisks, id: \.self) { id in 94 | HStack { 95 | Text(id); Spacer() 96 | Button(role: .destructive) { 97 | persistence.unignore(diskID: id) 98 | DriveManager.shared.refreshDrives(qos: .userInitiated) 99 | } label: { Image(systemName: "trash") }.buttonStyle(.borderless) 100 | } 101 | } 102 | } 103 | } 104 | } 105 | 106 | Section(header: Text("Protected Volumes"), footer: Text("Right-click a volume to protect it from 'Unmount All' and sleep actions.")) { 107 | if persistence.protectedVolumes.isEmpty { 108 | CenteredContent { 109 | Image(systemName: "lock.shield").font(.title).foregroundColor(.secondary) 110 | Text("No Protected Volumes").fontWeight(.semibold) 111 | } 112 | } else { 113 | List { 114 | ForEach(persistence.protectedVolumes, id: \.self) { id in 115 | HStack { 116 | Text(id); Spacer() 117 | Button(role: .destructive) { 118 | persistence.unprotect(volumeID: id) 119 | DriveManager.shared.refreshDrives(qos: .userInitiated) 120 | } label: { Image(systemName: "trash") }.buttonStyle(.borderless) 121 | } 122 | } 123 | } 124 | } 125 | } 126 | } 127 | .padding() 128 | } 129 | 130 | private func relaunchApp() { 131 | let url = URL(fileURLWithPath: Bundle.main.resourcePath!) 132 | let path = url.deletingLastPathComponent().deletingLastPathComponent().absoluteString 133 | let task = Process() 134 | task.launchPath = "/usr/bin/open" 135 | task.arguments = ["-n", path] 136 | task.launch() 137 | NSApplication.shared.terminate(self) 138 | } 139 | } 140 | 141 | struct CenteredContent<Content: View>: View { 142 | let content: Content 143 | 144 | init(@ViewBuilder content: () -> Content) { 145 | self.content = content() 146 | } 147 | 148 | var body: some View { 149 | VStack { 150 | Spacer() 151 | HStack { 152 | Spacer() 153 | VStack(spacing: 8) { 154 | content 155 | } 156 | Spacer() 157 | } 158 | Spacer() 159 | } 160 | } 161 | } -------------------------------------------------------------------------------- /README-vi.md: -------------------------------------------------------------------------------- 1 | # 🚀 MountMate 2 | 3 | _Một ứng dụng đơn giản trên thanh menu macOS giúp bạn quản lý ổ đĩa ngoài._ 4 | 5 | --- 6 | 7 | <img src="https://raw.githubusercontent.com/homielab/mountmate/main/docs/assets/icon.png" alt="MountMate Icon" width="100" height="100" style="border-radius: 22%; border: 0.5px solid rgba(0,0,0,0.1);" /> 8 | 9 | ## 🧩 MountMate là gì? 10 | 11 | MountMate là một tiện ích nhẹ dành cho macOS, chạy trên thanh menu và cho phép bạn **mount (gắn) hoặc unmount (tháo) ổ đĩa ngoài chỉ với một cú nhấp chuột** – không cần Terminal, không cần mở Disk Utility, hoàn toàn đơn giản. 12 | 13 | Nếu bạn đang sử dụng ổ HDD, muốn kiểm soát khi nào nó hoạt động để tránh gây ồn hoặc làm chậm hệ thống, MountMate là giải pháp gọn nhẹ dành cho bạn. 14 | 15 | ## 🧠 Tại sao tôi tạo ra ứng dụng này? 16 | 17 | Tôi có một ổ cứng ngoài 4TB được cắm thường trực vào Mac mini tại nhà. Vì là ổ HDD, mỗi lần tôi mở Finder, Spotlight hay thực hiện một số thao tác hệ thống, ổ sẽ quay lên – gây tiếng ồn, làm chậm hệ thống và không cần thiết khi tôi không dùng đến. 18 | 19 | Các giải pháp: 20 | 21 | - Dùng Disk Utility – quá chậm và bất tiện 22 | - Viết script bằng shell – không thân thiện 23 | - Tìm ứng dụng bên thứ ba – phức tạp hoặc không hiệu quả 24 | 25 | Vì vậy, tôi đã tạo **MountMate**. 26 | 27 | ## ✅ Tính năng nổi bật 28 | 29 | - Xem tất cả ổ đĩa ngoài đang kết nối 30 | - Biết được ổ nào đang được **mount** 31 | - **Mount/unmount** nhanh chóng chỉ với 1 cú click 32 | - Hiển thị **dung lượng trống** còn lại 33 | - Chạy gọn nhẹ trên **thanh menu** 34 | - 100% native – không dùng Electron, không phụ thuộc nặng nề 35 | 36 | ## ✨ MountMate dành cho ai? 37 | 38 | macOS sẽ tự động mount ổ đĩa khi bạn cắm vào – nhưng **không cho phép bạn mount lại một cách dễ dàng nếu đã unmount**. MountMate đặc biệt hữu ích nếu bạn: 39 | 40 | - Sử dụng ổ ngoài chỉ để sao lưu hoặc lưu trữ tạm thời 41 | - Không muốn ổ cứng quay suốt cả ngày 42 | - Muốn giảm tiếng ồn, tăng hiệu năng hệ thống 43 | 44 | ## 🔐 An toàn, nhanh, và riêng tư 45 | 46 | MountMate hoạt động **hoàn toàn ngoại tuyến**, sử dụng lệnh và API tích hợp sẵn của macOS. Ứng dụng: 47 | 48 | - **Không theo dõi** hay gửi dữ liệu 49 | - **Không yêu cầu kết nối mạng** 50 | - **Không truy cập dữ liệu cá nhân** 51 | - **Không cần quyền root** 52 | 53 | Chỉ là một tiện ích nhỏ gọn, làm đúng một việc – và làm tốt. 54 | 55 | ## 🖼️ Hình ảnh minh họa 56 | 57 | <img src="https://raw.githubusercontent.com/homielab/mountmate/main/docs/screenshots/light.png" width="300" /><img src="https://raw.githubusercontent.com/homielab/mountmate/main/docs/screenshots/dark.png" width="300" /> 58 | 59 |  60 | 61 | ## 🛠️ Hướng dẫn cài đặt 62 | 63 | ### Cài thủ công (dành cho người mới hoặc cập nhật thủ công) 64 | 65 | 1. [Tải về `.dmg` bản mới nhất](https://github.com/homielab/mountmate/releases) 66 | 2. Mở file `.dmg` 67 | 3. Kéo biểu tượng `MountMate.app` vào thư mục **Applications** 68 | 4. Eject (gỡ) ổ đĩa cài đặt 69 | 5. Mở MountMate từ thư mục **Applications** 70 | 71 | ### Lần đầu sử dụng 72 | 73 | - Nếu macOS cảnh báo ứng dụng không rõ nguồn gốc, hãy vào: 74 | **System Settings → Privacy & Security → Open Anyway** 75 | - Đảm bảo bạn kết nối mạng để macOS xác minh và tự động cập nhật 76 | 77 | ## 📫 Đóng góp & phản hồi 78 | 79 | MountMate được tạo để giải quyết nhu cầu cá nhân của tôi – nhưng tôi rất sẵn lòng cải thiện nó cho cộng đồng. 80 | Nếu bạn có góp ý hoặc muốn tham gia phát triển, [hãy mở issue tại đây](https://github.com/homielab/mountmate/issues)! 81 | 82 | ## 🤝 Hỗ trợ 83 | 84 | Nếu bạn thấy MountMate hữu ích, hãy ủng hộ phát triển: 85 | 86 | [](https://ko-fi.com/homielab) 87 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🚀 MountMate 2 | 3 | _A simple macOS menu bar app to manage your external drives._ 4 | 5 | <p align="center"> 6 | <img src="https://raw.githubusercontent.com/homielab/mountmate/main/docs/assets/icon.png" alt="MountMate Icon" width="100" height="100" style="border-radius: 22%; border: 0.5px solid rgba(0,0,0,0.1);" /> 7 | </p> 8 | 9 | <p align="center"> 10 | <a href="https://github.com/homielab/mountmate/releases"> 11 | <img src="https://img.shields.io/github/v/release/homielab/mountmate?label=release&style=flat-square" /> 12 | </a> 13 | <a href="https://github.com/homielab/mountmate"> 14 | <img src="https://img.shields.io/github/downloads/homielab/mountmate/total?style=flat-square" /> 15 | </a> 16 | <a href="https://brew.sh"> 17 | <img src="https://img.shields.io/badge/homebrew-supported-blue?style=flat-square" /> 18 | </a> 19 | </p> 20 | 21 | --- 22 | 23 | ## ⚡️ Quick Start 24 | 25 | Install via [Homebrew](https://brew.sh): 26 | 27 | ```bash 28 | brew tap homielab/mountmate https://github.com/homielab/mountmate 29 | brew install --cask mountmate 30 | ``` 31 | 32 | Or [download the latest .dmg](https://github.com/homielab/mountmate/releases) and drag MountMate.app into your Applications folder. 33 | 34 | ## 🧩 What is MountMate? 35 | 36 | MountMate is a lightweight macOS menu bar utility that lets you **mount and unmount external drives with a single click** – no Terminal, no Disk Utility, no hassle. 37 | 38 | Whether you're dealing with a noisy spinning HDD or want finer control over when your drives are active, MountMate gives you a clean, no-nonsense solution right from your menu bar. 39 | 40 | ## 🧠 Why I Built It 41 | 42 | I have a 4TB external HDD plugged into my Mac mini 24/7. Since it's a spinning drive, macOS constantly spins it up – just for trivial things like opening Finder or running Spotlight. That meant: 43 | 44 | - Unwanted noise 45 | - System slowdowns 46 | - Wasted energy 47 | 48 | I tried: 49 | 50 | - Disk Utility – too slow and clunky 51 | - Custom shell scripts – too technical 52 | - Existing third-party apps – too bloated or didn’t work right 53 | 54 | So I built **MountMate**. 55 | 56 | ## ✅ Features 57 | 58 | - View all connected **external drives** 59 | - See which ones are **mounted** 60 | - **Mount/unmount** any drive with a click 61 | - Check available **free space** 62 | - Runs quietly in the **menu bar** 63 | - Fully native – no Electron, no dependencies 64 | 65 | ## ✨ Why Use MountMate? 66 | 67 | macOS automatically mounts drives when they’re plugged in – but gives you **no easy way to remount them later** unless you use Terminal or Disk Utility. MountMate is perfect for: 68 | 69 | - External HDDs you don’t always need 70 | - Drives used only for backup 71 | - Reducing wear and tear or noise 72 | - Improving system responsiveness 73 | 74 | ## 🔐 Private, Fast, and Safe 75 | 76 | MountMate runs **entirely offline**, using native macOS APIs and command-line tools. It: 77 | 78 | - Does **not** track anything 79 | - Does **not** require connect to the internet 80 | - Does **not** access your files 81 | - Does **not** require root permissions 82 | 83 | Just a clean utility that does one job well. 84 | 85 | ## 🖼️ Screenshots 86 | 87 | <img src="https://raw.githubusercontent.com/homielab/mountmate/main/docs/screenshots/light.png" width="300" /><img src="https://raw.githubusercontent.com/homielab/mountmate/main/docs/screenshots/dark.png" width="300" /> 88 | 89 |  90 | 91 | ## 🛠️ Installation 92 | 93 | ### Manual Installation 94 | 95 | 1. [Download the latest `.dmg` release](https://github.com/homielab/mountmate/releases) 96 | 2. Open the `.dmg` file 97 | 3. Drag `MountMate.app` into the **Applications** folder 98 | 4. Eject the installer disk image 99 | 5. Launch MountMate from **Applications** 100 | 101 | ### Install via Homebrew 102 | 103 | If you have [Homebrew](https://brew.sh) installed, you can install MountMate directly from this repository: 104 | 105 | ```bash 106 | brew tap homielab/mountmate https://github.com/homielab/mountmate 107 | brew install --cask mountmate 108 | ``` 109 | 110 | ### First-Time Use on macOS 111 | 112 | - If you see a warning that MountMate is from an unidentified developer, go to: 113 | **System Settings → Privacy & Security → Open Anyway** 114 | - Make sure you're connected to the internet to allow macOS to verify the app and receive updates 115 | 116 | ## 📫 Feedback & Contributions 117 | 118 | MountMate was built to solve my personal workflow issue, but I’d love to improve it for others too. 119 | Feel free to [open an issue](https://github.com/homielab/mountmate/issues) or suggest improvements! 120 | 121 | ## 🤝 Support 122 | 123 | If you found MountMate helpful, please consider supporting its development: 124 | 125 | [](https://ko-fi.com/homielab) 126 | -------------------------------------------------------------------------------- /docs/appcast.xml: -------------------------------------------------------------------------------- 1 | <?xml version="1.0" standalone="yes"?> 2 | <rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0"> 3 | <channel> 4 | <title>MountMate</title> 5 | <item> 6 | <title>1.6</title> 7 | <pubDate>Tue, 24 Jun 2025 20:55:29 +0700</pubDate> 8 | <sparkle:version>6</sparkle:version> 9 | <sparkle:shortVersionString>1.6</sparkle:shortVersionString> 10 | <sparkle:minimumSystemVersion>13.0</sparkle:minimumSystemVersion> 11 | <enclosure url="https://github.com/homielab/mountmate/releases/download/v1.6/MountMate_1.6.zip" length="1978870" type="application/octet-stream" sparkle:edSignature="zzca+GN5Abyjd0HtFaf5lzuLukyj5riNmo+tMmKc3KYXz8QJw3ybs1z/gMFzhhXpVctWIKlGdPk+nQ0EhMmcDw=="/> 12 | </item> 13 | </channel> 14 | </rss> -------------------------------------------------------------------------------- /docs/assets/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/homielab/mountmate/5709f82855dbde154c559ddc5ead9b08a6e3ebb8/docs/assets/icon.icns -------------------------------------------------------------------------------- /docs/assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/homielab/mountmate/5709f82855dbde154c559ddc5ead9b08a6e3ebb8/docs/assets/icon.png -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | <!DOCTYPE html> 2 | <html lang="en"> 3 | <head> 4 | <meta charset="UTF-8" /> 5 | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 | <meta 7 | name="description" 8 | content="MountMate - A simple macOS menubar app to manage your external drives." 9 | /> 10 | <title>MountMate – macOS Drive Manager</title> 11 | <link 12 | rel="stylesheet" 13 | href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap" 14 | /> 15 | <style> 16 | :root { 17 | --bg: #f9fafb; 18 | --text: #111827; 19 | --accent: #3b82f6; 20 | } 21 | @media (prefers-color-scheme: dark) { 22 | :root { 23 | --bg: #0f172a; 24 | --text: #f1f5f9; 25 | --accent: #60a5fa; 26 | } 27 | } 28 | body { 29 | margin: 0; 30 | font-family: "Inter", sans-serif; 31 | background-color: var(--bg); 32 | color: var(--text); 33 | display: flex; 34 | flex-direction: column; 35 | align-items: center; 36 | padding: 4rem 1rem; 37 | } 38 | h1 { 39 | font-size: 2.5rem; 40 | font-weight: 600; 41 | margin-bottom: 1rem; 42 | text-align: center; 43 | } 44 | p { 45 | max-width: 600px; 46 | font-size: 1.125rem; 47 | text-align: center; 48 | margin-bottom: 2rem; 49 | } 50 | .btn { 51 | display: inline-block; 52 | background-color: var(--accent); 53 | color: white; 54 | padding: 0.75rem 1.5rem; 55 | font-size: 1rem; 56 | border-radius: 0.5rem; 57 | text-decoration: none; 58 | transition: background 0.3s ease; 59 | } 60 | .btn:hover { 61 | background-color: #2563eb; 62 | } 63 | .screenshot { 64 | margin-top: 3rem; 65 | max-width: 100%; 66 | border-radius: 1rem; 67 | box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1); 68 | } 69 | .icon { 70 | width: 128px; 71 | margin-bottom: 1rem; 72 | } 73 | footer { 74 | margin-top: 4rem; 75 | font-size: 0.875rem; 76 | color: #64748b; 77 | text-align: center; 78 | } 79 | </style> 80 | </head> 81 | <body> 82 | <h1>🚀 MountMate</h1> 83 | <p> 84 | A lightweight and elegant macOS menubar app to mount and unmount your 85 | external drives effortlessly. 86 | </p> 87 | 88 | <img src="assets/icon.png" alt="MountMate Icon" class="icon" /> 89 | 90 | <a 91 | class="btn" 92 | href="https://github.com/homielab/mountmate/releases/latest" 93 | target="_blank" 94 | >Download MountMate</a 95 | > 96 | <a 97 | class="btn" 98 | href="https://github.com/homielab/mountmate" 99 | target="_blank" 100 | style="margin-top: 1rem" 101 | >⭐ Star on GitHub</a 102 | > 103 | 104 | <a href="https://ko-fi.com/homielab" target="_blank" 105 | ><img 106 | height="36" 107 | style="margin-top: 1rem; border: 0px; height: 36px" 108 | src="https://storage.ko-fi.com/cdn/kofi6.png?v=6" 109 | border="0" 110 | alt="Buy Me a Coffee at ko-fi.com" 111 | /></a> 112 | 113 | <img 114 | src="screenshots/dark.png" 115 | alt="MountMate Screenshot" 116 | class="screenshot" 117 | /> 118 | 119 | <img 120 | src="screenshots/light.png" 121 | alt="MountMate Screenshot" 122 | class="screenshot" 123 | /> 124 | 125 | <img 126 | src="screenshots/dark-full.png" 127 | alt="MountMate Screenshot" 128 | class="screenshot" 129 | /> 130 | 131 | <footer> 132 | © 2025 MountMate. Built with ❤️ by 133 | <a 134 | href="https://github.com/homielab" 135 | target="_blank" 136 | style="color: var(--accent); text-decoration: none" 137 | >@homielab</a 138 | > 139 | </footer> 140 | </body> 141 | </html> 142 | -------------------------------------------------------------------------------- /docs/screenshots/dark-full.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/homielab/mountmate/5709f82855dbde154c559ddc5ead9b08a6e3ebb8/docs/screenshots/dark-full.png -------------------------------------------------------------------------------- /docs/screenshots/dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/homielab/mountmate/5709f82855dbde154c559ddc5ead9b08a6e3ebb8/docs/screenshots/dark.png -------------------------------------------------------------------------------- /docs/screenshots/light-full.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/homielab/mountmate/5709f82855dbde154c559ddc5ead9b08a6e3ebb8/docs/screenshots/light-full.png -------------------------------------------------------------------------------- /docs/screenshots/light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/homielab/mountmate/5709f82855dbde154c559ddc5ead9b08a6e3ebb8/docs/screenshots/light.png -------------------------------------------------------------------------------- /scripts/1-create-app.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -euo pipefail 4 | 5 | PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)" 6 | PROJECT_PATH="$PROJECT_ROOT/MountMate.xcodeproj" 7 | SCHEME="MountMate" 8 | CONFIGURATION="Release" 9 | BUILD_DIR="$PROJECT_ROOT/Dist/Build" 10 | DIST_DIR="$PROJECT_ROOT/Dist/Release" 11 | APP_NAME="MountMate" 12 | APP_PATH="$DIST_DIR/${APP_NAME}.app" 13 | BUNDLE_ID="com.homielab.mountmate" 14 | 15 | if [[ -f "$PROJECT_ROOT/.env.local" ]]; then 16 | source "$PROJECT_ROOT/.env.local" 17 | else 18 | echo "❌ .env.local not found in root directory." 19 | exit 1 20 | fi 21 | : "${CERTIFICATE_NAME:?CERTIFICATE_NAME is required}" 22 | : "${NOTARY_PROFILE:?NOTARY_PROFILE is required}" 23 | 24 | # === Build === 25 | 26 | echo "🧹 Cleaning previous builds..." 27 | rm -rf "$BUILD_DIR" "$APP_PATH" 28 | 29 | echo "🛠️ Building $APP_NAME..." 30 | xcodebuild \ 31 | -project "$PROJECT_PATH" \ 32 | -scheme "$SCHEME" \ 33 | -configuration "$CONFIGURATION" \ 34 | -derivedDataPath "$BUILD_DIR" \ 35 | "ONLY_ACTIVE_ARCH=NO" \ 36 | -destination "generic/platform=macOS" \ 37 | clean build 38 | 39 | BUILT_APP_PATH=$(find "$BUILD_DIR/Build/Products/$CONFIGURATION" -name "${APP_NAME}.app" -type d | head -n 1) 40 | if [ -z "$BUILT_APP_PATH" ]; then 41 | echo "❌ Failed to find built .app." 42 | exit 1 43 | fi 44 | 45 | echo "🔏 Signing $APP_NAME with identity: $CERTIFICATE_NAME" 46 | codesign --deep --force --verbose \ 47 | --options runtime \ 48 | --sign "$CERTIFICATE_NAME" \ 49 | "$BUILT_APP_PATH" 50 | 51 | echo "🚀 Submitting for notarization..." 52 | ZIP_PATH="$DIST_DIR/${APP_NAME}.zip" 53 | mkdir -p "$DIST_DIR" 54 | rm -f "$ZIP_PATH" 55 | ditto -c -k --sequesterRsrc --keepParent "$BUILT_APP_PATH" "$ZIP_PATH" 56 | 57 | xcrun notarytool submit "$ZIP_PATH" \ 58 | --keychain-profile "$NOTARY_PROFILE" \ 59 | --wait 60 | 61 | echo "📎 Stapling notarization ticket..." 62 | xcrun stapler staple "$BUILT_APP_PATH" 63 | 64 | echo "📦 Exporting notarized .app to $APP_PATH" 65 | cp -R "$BUILT_APP_PATH" "$APP_PATH" 66 | 67 | echo "Cleaning up..." 68 | rm -rf "$BUILD_DIR" 69 | rm -f "$ZIP_PATH" 70 | 71 | echo "✅ Done. Notarized and exported to $APP_PATH" -------------------------------------------------------------------------------- /scripts/2-build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -euo pipefail 4 | 5 | PROJECT_ROOT="$(cd "$(dirname -- "${SCRIPT_DIR}")" && pwd)" 6 | SOURCE_FOLDER="${PROJECT_ROOT}/Dist/Release" 7 | FINAL_DIR="${PROJECT_ROOT}/Dist/Final" 8 | APP_NAME="MountMate" 9 | 10 | if [[ -f "$PROJECT_ROOT/.env.local" ]]; then 11 | source "$PROJECT_ROOT/.env.local" 12 | else 13 | echo "❌ .env.local not found in root directory." 14 | exit 1 15 | fi 16 | : "${CERTIFICATE_NAME:?CERTIFICATE_NAME is required}" 17 | : "${NOTARY_PROFILE:?NOTARY_PROFILE is required}" 18 | : "${SPARKLE_PRIVATE_KEY:?SPARKLE_PRIVATE_KEY is required}" 19 | 20 | APP_PATH="${SOURCE_FOLDER}/${APP_NAME}.app" 21 | if [[ ! -d "${APP_PATH}" ]]; then 22 | echo "❌ ${APP_PATH} not found. Build your app first." 23 | exit 1 24 | fi 25 | 26 | VERSION=$(defaults read "${APP_PATH}/Contents/Info.plist" CFBundleShortVersionString) 27 | if [[ -z "${VERSION}" ]]; then 28 | echo "❌ Could not read version from Info.plist" 29 | exit 1 30 | fi 31 | ZIP_NAME="${APP_NAME}_${VERSION}.zip" 32 | DMG_NAME="${APP_NAME}_${VERSION}.dmg" 33 | 34 | # Build Release 35 | 36 | APPCAST_NAME="appcast.xml" 37 | UPDATE_URL="https://github.com/homielab/mountmate/releases/download/v${VERSION}/" 38 | DOCS_DIR="$PROJECT_ROOT/docs" 39 | ASSETS_DIR="$PROJECT_ROOT/docs/assets" 40 | 41 | echo "📦 Building ${APP_NAME} v${VERSION}" 42 | echo "🧹 Cleaning up old files..." 43 | rm -rf "${FINAL_DIR}" 44 | mkdir -p "${FINAL_DIR}" 45 | 46 | cd "${SOURCE_FOLDER}" 47 | 48 | echo "📦 Creating Sparkle-compatible zip..." 49 | zip -r --symlinks "${ZIP_NAME}" "${APP_NAME}.app" 50 | mv "${ZIP_NAME}" "${FINAL_DIR}/" 51 | 52 | echo "🛰️ Generating Sparkle appcast..." 53 | generate_appcast "${FINAL_DIR}" \ 54 | --download-url-prefix "${UPDATE_URL}" \ 55 | --ed-key-file "${SPARKLE_PRIVATE_KEY}" \ 56 | -o "${APPCAST_NAME}" 57 | mv "${APPCAST_NAME}" "${DOCS_DIR}/${APPCAST_NAME}" 58 | 59 | echo "📀 Creating DMG..." 60 | create-dmg \ 61 | --volicon "${ASSETS_DIR}/icon.icns" \ 62 | --volname "${APP_NAME} v${VERSION}" \ 63 | --background "${ASSETS_DIR}/icon.png" \ 64 | --window-pos 200 120 \ 65 | --window-size 640 480 \ 66 | --icon-size 128 \ 67 | --icon "${APP_NAME}.app" 160 240 \ 68 | --hide-extension "${APP_NAME}.app" \ 69 | --app-drop-link 480 240 \ 70 | "${DMG_NAME}" "${SOURCE_FOLDER}" 71 | 72 | echo "🔏 Signing and notarizing DMG..." 73 | codesign --force --sign "${CERTIFICATE_NAME}" "${DMG_NAME}" 74 | xcrun notarytool submit "${DMG_NAME}" --keychain-profile "${NOTARY_PROFILE}" --wait 75 | xcrun stapler staple "${DMG_NAME}" 76 | mv "${DMG_NAME}" "${FINAL_DIR}/" 77 | 78 | echo "" 79 | echo "✅ Build complete! Files are in the '${FINAL_DIR}' directory:" 80 | echo " - ${ZIP_NAME} (for Sparkle updates)" 81 | echo " - ${DMG_NAME} (for manual download)" 82 | echo " - ${APPCAST_NAME} (Sparkle feed)" 83 | echo "" 84 | 85 | -------------------------------------------------------------------------------- /scripts/3-release.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)" 5 | SOURCE_FOLDER="${PROJECT_ROOT}/Dist/Release" 6 | FINAL_DIR="${PROJECT_ROOT}/Dist/Final" 7 | APP_NAME="MountMate" 8 | 9 | APP_PATH="${SOURCE_FOLDER}/${APP_NAME}.app" 10 | if [[ ! -d "${APP_PATH}" ]]; then 11 | echo "❌ ${APP_PATH} not found. Build your app first." 12 | exit 1 13 | fi 14 | 15 | VERSION=$(defaults read "${APP_PATH}/Contents/Info.plist" CFBundleShortVersionString) 16 | if [[ -z "${VERSION}" ]]; then 17 | echo "❌ Could not read version from Info.plist" 18 | exit 1 19 | fi 20 | ZIP_NAME="${APP_NAME}_${VERSION}.zip" 21 | DMG_NAME="${APP_NAME}_${VERSION}.dmg" 22 | 23 | # === Create GitHub release === 24 | 25 | TAG="v${VERSION}" 26 | GITHUB_REPO="homielab/mountmate" 27 | 28 | echo "🚀 Publishing GitHub release ${TAG}..." 29 | git tag | grep -q "${TAG}" || git tag "${TAG}" 30 | git push origin "${TAG}" 31 | 32 | if ! gh release view "${TAG}" --repo "${GITHUB_REPO}" &>/dev/null; then 33 | gh release create "${TAG}" \ 34 | --repo "${GITHUB_REPO}" \ 35 | --title "MountMate ${VERSION}" \ 36 | --notes "Release for MountMate version ${VERSION}. 37 | 38 | Download the DMG file and drag MountMate.app into your Applications folder. 39 | Please report any bugs at https://github.com/homielab/mountmate/issues" \ 40 | --target main 41 | fi 42 | 43 | gh release upload "${TAG}" \ 44 | "${FINAL_DIR}/${ZIP_NAME}" \ 45 | "${FINAL_DIR}/${DMG_NAME}" \ 46 | --repo "${GITHUB_REPO}" --clobber 47 | 48 | echo "✅ Done!" 49 | echo "🔗 GitHub Release: https://github.com/${GITHUB_REPO}/releases/tag/${TAG}" -------------------------------------------------------------------------------- /scripts/4-generate_cask.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -euo pipefail 4 | 5 | PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)" 6 | FINAL_DIR="${PROJECT_ROOT}/Dist/Final" 7 | DMG_FILE=$(find "$FINAL_DIR" -maxdepth 1 -name "MountMate_*.dmg" | head -n 1) 8 | 9 | if [[ ! -f "$DMG_FILE" ]]; then 10 | echo "❌ DMG file not found in $FINAL_DIR" 11 | exit 1 12 | fi 13 | 14 | FILENAME=$(basename "$DMG_FILE") 15 | VERSION=$(echo "$FILENAME" | sed -E 's/MountMate_([0-9.]+)\.dmg/\1/') 16 | 17 | SHA256=$(shasum -a 256 "$DMG_FILE" | awk '{print $1}') 18 | 19 | echo "📦 Version detected: $VERSION" 20 | echo "🔐 SHA256: $SHA256" 21 | 22 | mkdir -p "Casks" 23 | OUTPUT="Casks/mountmate.rb" 24 | rm -f "$OUTPUT" 25 | 26 | cat > "$OUTPUT" <<EOF 27 | cask "mountmate" do 28 | version "$VERSION" 29 | sha256 "$SHA256" 30 | 31 | url "https://github.com/homielab/mountmate/releases/download/v#{version}/MountMate_#{version}.dmg" 32 | name "MountMate" 33 | desc "A menubar app to easily manage external drives" 34 | homepage "https://homielab.com/page/mountmate" 35 | 36 | auto_updates true 37 | app "MountMate.app" 38 | 39 | zap trash: [ 40 | "~/Library/Preferences/com.homielab.mountmate.plist", 41 | ] 42 | end 43 | EOF 44 | 45 | echo "✅ Cask file generated: $OUTPUT" --------------------------------------------------------------------------------