The response has been limited to 50k tokens of the smallest files in the repo. You can remove this limitation by removing the max tokens filter.
├── .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 | ![Toàn bộ giao diện](https://raw.githubusercontent.com/homielab/mountmate/main/docs/screenshots/light-full.png)
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 | [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](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 | ![Full Screenshot](https://raw.githubusercontent.com/homielab/mountmate/main/docs/screenshots/light-full.png)
 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 | [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](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 |       &copy; 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"


--------------------------------------------------------------------------------