diff --git a/PureMac.xcodeproj/project.pbxproj b/PureMac.xcodeproj/project.pbxproj index 1e7df29..694d547 100644 --- a/PureMac.xcodeproj/project.pbxproj +++ b/PureMac.xcodeproj/project.pbxproj @@ -7,28 +7,44 @@ objects = { /* Begin PBXBuildFile section */ + 013EF7B96BF046D4DB38FBC5 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 241E0895B09C71AB423B2F9E /* Localizable.strings */; }; 015C7A8CE16D49F2C445C02A /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01E751C3B9ED986F1E6D3C8F /* SettingsView.swift */; }; 0A7B70CBA747ED9FEE20C51A /* SidebarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5402892366B3F00417FD05F9 /* SidebarView.swift */; }; + 2253F11BDF561B617439C96B /* FullDiskAccessManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E866A1541D289C69144A5E62 /* FullDiskAccessManager.swift */; }; 27F449EDD1B082FE11FEC9DF /* SchedulerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28181F034530331A550C6A3D /* SchedulerService.swift */; }; + 2BE887AD15DB6A8CA2DDE6F7 /* ItemSelectionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6581D692BBC8677DAF411BED /* ItemSelectionState.swift */; }; 340E424F759ACCDE7372F99F /* Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5ADDC35F31E40780FB5D017 /* Theme.swift */; }; 48D1431A7C99C19EEBDB056B /* CleaningEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A8DE5D45BA19E2670B57DC5 /* CleaningEngine.swift */; }; + 4F754D89F4CE5142BE384062 /* AppConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = F31F91226CDCFBB8E303B7DA /* AppConstants.swift */; }; + 6348617E1C556B5150CA2130 /* ScheduleSafetyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EF58F1F04D1E08A1D34D99B /* ScheduleSafetyTests.swift */; }; + 6959EA445895ABDC8D2A8E80 /* CleaningConfirmationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAE127B891ACA0251B74FCDA /* CleaningConfirmationTests.swift */; }; 826A750D2D7EC14C2AE306A3 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3667D46D8E2004EB4D73835A /* Models.swift */; }; A221744A723582A6D075A7A0 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86105573F4051764E9E46626 /* ContentView.swift */; }; A89DF967EC9E5E8123B2925A /* SmartScanView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11BA091943263913276F3CCF /* SmartScanView.swift */; }; - AA0000000000000000000004 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000003 /* Localizable.strings */; }; + B04396EE28C9756FD5960E5D /* ItemSelectionStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB2321CCF183DC373337FD39 /* ItemSelectionStateTests.swift */; }; B52938BBD11842631314543D /* CategoryDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10B0AF194677EAC1D5568785 /* CategoryDetailView.swift */; }; B8A524AF7F7DCDFCFCA9EDF6 /* AppViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7FDA1B749FFA61DFB52AB5 /* AppViewModel.swift */; }; D9445C2641A8637B65DA5ACE /* ScanEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = A711CDF5285F68775D9B5513 /* ScanEngine.swift */; }; - D9D4E0FA2F8C135400ABB13C /* AppConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9D4E0F92F8C135400ABB13C /* AppConstants.swift */; }; DDD6BA35DBF32E7A6B5F6F8B /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B2EA41E1096FA8E3B916AD13 /* Assets.xcassets */; }; EDEF28CAD23E936FBED1783B /* PureMacApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63581B70F9B10231964E3602 /* PureMacApp.swift */; }; - F1A2B3C4D5E6F70819203142 /* FullDiskAccessManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F607182930A1B2 /* FullDiskAccessManager.swift */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + 8D91DC174C821BE74C4B518C /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 37BE87544E2EC9F6FF1CD280 /* Project object */; + proxyType = 1; + remoteGlobalIDString = B19E38DEAA0144A77120F39C; + remoteInfo = PureMac; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ 01E751C3B9ED986F1E6D3C8F /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + 02E502E2B5C6AECC76E5CFEF /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/Localizable.strings"; sourceTree = ""; }; 10B0AF194677EAC1D5568785 /* CategoryDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryDetailView.swift; sourceTree = ""; }; 11BA091943263913276F3CCF /* SmartScanView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmartScanView.swift; sourceTree = ""; }; + 2641C6376DD6F5889F35510E /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = ""; }; 28181F034530331A550C6A3D /* SchedulerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SchedulerService.swift; sourceTree = ""; }; 2A8DE5D45BA19E2670B57DC5 /* CleaningEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CleaningEngine.swift; sourceTree = ""; }; 311078221878708524283765 /* PureMac.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PureMac.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -36,17 +52,20 @@ 46660271CFF167AB0FE7371D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 5402892366B3F00417FD05F9 /* SidebarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarView.swift; sourceTree = ""; }; 5664D2BDAEAA9AE3A53DB364 /* PureMac.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = PureMac.entitlements; sourceTree = ""; }; + 607B9A8C7B34D6A7DCF4FFE2 /* PureMacTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = PureMacTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 63581B70F9B10231964E3602 /* PureMacApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PureMacApp.swift; sourceTree = ""; }; + 6581D692BBC8677DAF411BED /* ItemSelectionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemSelectionState.swift; sourceTree = ""; }; + 6EF58F1F04D1E08A1D34D99B /* ScheduleSafetyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScheduleSafetyTests.swift; sourceTree = ""; }; 86105573F4051764E9E46626 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; - A1B2C3D4E5F607182930A1B2 /* FullDiskAccessManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullDiskAccessManager.swift; sourceTree = ""; }; + 9F04B811BB0012F6D2F07F91 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; A711CDF5285F68775D9B5513 /* ScanEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanEngine.swift; sourceTree = ""; }; - AA0000000000000000000001 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; - AA0000000000000000000002 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = ""; }; - AA0000000000000000000005 /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/Localizable.strings"; sourceTree = ""; }; + AB2321CCF183DC373337FD39 /* ItemSelectionStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemSelectionStateTests.swift; sourceTree = ""; }; B2EA41E1096FA8E3B916AD13 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; C5ADDC35F31E40780FB5D017 /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = ""; }; - D9D4E0F92F8C135400ABB13C /* AppConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppConstants.swift; sourceTree = ""; }; + DAE127B891ACA0251B74FCDA /* CleaningConfirmationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CleaningConfirmationTests.swift; sourceTree = ""; }; DD7FDA1B749FFA61DFB52AB5 /* AppViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppViewModel.swift; sourceTree = ""; }; + E866A1541D289C69144A5E62 /* FullDiskAccessManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullDiskAccessManager.swift; sourceTree = ""; }; + F31F91226CDCFBB8E303B7DA /* AppConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppConstants.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXGroup section */ @@ -54,10 +73,21 @@ isa = PBXGroup; children = ( E383F69184F4552E5A41D010 /* PureMac */, + 261FB8D961FEC76F15EA523A /* PureMacTests */, 4562CA9E5625FA4EEEFECB6D /* Products */, ); sourceTree = ""; }; + 261FB8D961FEC76F15EA523A /* PureMacTests */ = { + isa = PBXGroup; + children = ( + DAE127B891ACA0251B74FCDA /* CleaningConfirmationTests.swift */, + AB2321CCF183DC373337FD39 /* ItemSelectionStateTests.swift */, + 6EF58F1F04D1E08A1D34D99B /* ScheduleSafetyTests.swift */, + ); + path = PureMacTests; + sourceTree = ""; + }; 3CF46713F75B81F0F86D1C6F /* Models */ = { isa = PBXGroup; children = ( @@ -70,6 +100,7 @@ isa = PBXGroup; children = ( 311078221878708524283765 /* PureMac.app */, + 607B9A8C7B34D6A7DCF4FFE2 /* PureMacTests.xctest */, ); name = Products; sourceTree = ""; @@ -90,7 +121,7 @@ isa = PBXGroup; children = ( 2A8DE5D45BA19E2670B57DC5 /* CleaningEngine.swift */, - A1B2C3D4E5F607182930A1B2 /* FullDiskAccessManager.swift */, + E866A1541D289C69144A5E62 /* FullDiskAccessManager.swift */, A711CDF5285F68775D9B5513 /* ScanEngine.swift */, 28181F034530331A550C6A3D /* SchedulerService.swift */, ); @@ -109,14 +140,15 @@ isa = PBXGroup; children = ( DD7FDA1B749FFA61DFB52AB5 /* AppViewModel.swift */, + 6581D692BBC8677DAF411BED /* ItemSelectionState.swift */, ); path = ViewModels; sourceTree = ""; }; - D9D4E0F82F8C135000ABB13C /* Core */ = { + D4333B07691BD85CAE0E5B15 /* Core */ = { isa = PBXGroup; children = ( - D9D4E0F92F8C135400ABB13C /* AppConstants.swift */, + F31F91226CDCFBB8E303B7DA /* AppConstants.swift */, ); path = Core; sourceTree = ""; @@ -124,13 +156,13 @@ E383F69184F4552E5A41D010 /* PureMac */ = { isa = PBXGroup; children = ( - D9D4E0F82F8C135000ABB13C /* Core */, B2EA41E1096FA8E3B916AD13 /* Assets.xcassets */, - AA0000000000000000000003 /* Localizable.strings */, 46660271CFF167AB0FE7371D /* Info.plist */, 5664D2BDAEAA9AE3A53DB364 /* PureMac.entitlements */, 63581B70F9B10231964E3602 /* PureMacApp.swift */, + D4333B07691BD85CAE0E5B15 /* Core */, 7C1729F88C0E5563E1A3DB40 /* Extensions */, + 241E0895B09C71AB423B2F9E /* Localizable.strings */, 3CF46713F75B81F0F86D1C6F /* Models */, 6184B2EC3D01E6E95633406E /* Services */, A8F1C251567E5321E1CA2484 /* ViewModels */, @@ -160,6 +192,24 @@ productReference = 311078221878708524283765 /* PureMac.app */; productType = "com.apple.product-type.application"; }; + FBA8B0DE95746207A043B802 /* PureMacTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 25F40856927F2EE5BC50A0FB /* Build configuration list for PBXNativeTarget "PureMacTests" */; + buildPhases = ( + A6847DD7D5552F4AA6B17BA8 /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + 0E97FDF7F6FA15845FCE6737 /* PBXTargetDependency */, + ); + name = PureMacTests; + packageProductDependencies = ( + ); + productName = PureMacTests; + productReference = 607B9A8C7B34D6A7DCF4FFE2 /* PureMacTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -173,6 +223,10 @@ DevelopmentTeam = H3WXHVTP97; ProvisioningStyle = Automatic; }; + FBA8B0DE95746207A043B802 = { + DevelopmentTeam = H3WXHVTP97; + ProvisioningStyle = Automatic; + }; }; }; buildConfigurationList = 2ABAFAE07AA42044AE58F688 /* Build configuration list for PBXProject "PureMac" */; @@ -192,6 +246,7 @@ projectRoot = ""; targets = ( B19E38DEAA0144A77120F39C /* PureMac */, + FBA8B0DE95746207A043B802 /* PureMacTests */, ); }; /* End PBXProject section */ @@ -202,7 +257,7 @@ buildActionMask = 2147483647; files = ( DDD6BA35DBF32E7A6B5F6F8B /* Assets.xcassets in Resources */, - AA0000000000000000000004 /* Localizable.strings in Resources */, + 013EF7B96BF046D4DB38FBC5 /* Localizable.strings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -213,32 +268,51 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 4F754D89F4CE5142BE384062 /* AppConstants.swift in Sources */, B8A524AF7F7DCDFCFCA9EDF6 /* AppViewModel.swift in Sources */, B52938BBD11842631314543D /* CategoryDetailView.swift in Sources */, 48D1431A7C99C19EEBDB056B /* CleaningEngine.swift in Sources */, A221744A723582A6D075A7A0 /* ContentView.swift in Sources */, + 2253F11BDF561B617439C96B /* FullDiskAccessManager.swift in Sources */, + 2BE887AD15DB6A8CA2DDE6F7 /* ItemSelectionState.swift in Sources */, 826A750D2D7EC14C2AE306A3 /* Models.swift in Sources */, - F1A2B3C4D5E6F70819203142 /* FullDiskAccessManager.swift in Sources */, EDEF28CAD23E936FBED1783B /* PureMacApp.swift in Sources */, D9445C2641A8637B65DA5ACE /* ScanEngine.swift in Sources */, 27F449EDD1B082FE11FEC9DF /* SchedulerService.swift in Sources */, 015C7A8CE16D49F2C445C02A /* SettingsView.swift in Sources */, 0A7B70CBA747ED9FEE20C51A /* SidebarView.swift in Sources */, - D9D4E0FA2F8C135400ABB13C /* AppConstants.swift in Sources */, A89DF967EC9E5E8123B2925A /* SmartScanView.swift in Sources */, 340E424F759ACCDE7372F99F /* Theme.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; + A6847DD7D5552F4AA6B17BA8 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 6959EA445895ABDC8D2A8E80 /* CleaningConfirmationTests.swift in Sources */, + B04396EE28C9756FD5960E5D /* ItemSelectionStateTests.swift in Sources */, + 6348617E1C556B5150CA2130 /* ScheduleSafetyTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 0E97FDF7F6FA15845FCE6737 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = B19E38DEAA0144A77120F39C /* PureMac */; + targetProxy = 8D91DC174C821BE74C4B518C /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin PBXVariantGroup section */ - AA0000000000000000000003 /* Localizable.strings */ = { + 241E0895B09C71AB423B2F9E /* Localizable.strings */ = { isa = PBXVariantGroup; children = ( - AA0000000000000000000001 /* en */, - AA0000000000000000000002 /* zh-Hans */, - AA0000000000000000000005 /* zh-Hant */, + 9F04B811BB0012F6D2F07F91 /* en */, + 2641C6376DD6F5889F35510E /* zh-Hans */, + 02E502E2B5C6AECC76E5CFEF /* zh-Hant */, ); name = Localizable.strings; sourceTree = ""; @@ -250,6 +324,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ARCHS = "arm64 x86_64"; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; @@ -285,7 +360,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 3; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = H3WXHVTP97; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -307,10 +382,10 @@ GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = PureMac/Info.plist; MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = 1.0.0; + MARKETING_VERSION = 1.0.1; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; - ONLY_ACTIVE_ARCH = YES; + ONLY_ACTIVE_ARCH = NO; PRODUCT_BUNDLE_IDENTIFIER = com.puremac.app; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = macosx; @@ -326,20 +401,54 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; COMBINE_HIDPI_IMAGES = YES; INFOPLIST_FILE = PureMac/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks"; + PRODUCT_NAME = PureMac; + SDKROOT = macosx; + SWIFT_EMIT_LOC_STRINGS = YES; + }; + name = Debug; + }; + 6AC7FF3E26B642C05D435ADB /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + COMBINE_HIDPI_IMAGES = YES; + GENERATE_INFOPLIST_FILE = YES; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", + "@loader_path/../Frameworks", ); - PRODUCT_NAME = PureMac; + PRODUCT_BUNDLE_IDENTIFIER = com.puremac.app.tests; + PRODUCT_NAME = PureMacTests; SDKROOT = macosx; - SWIFT_EMIT_LOC_STRINGS = YES; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/PureMac.app/Contents/MacOS/PureMac"; }; name = Debug; }; + 8632219D8F7DC9932C9DCC9F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + COMBINE_HIDPI_IMAGES = YES; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@loader_path/../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.puremac.app.tests; + PRODUCT_NAME = PureMacTests; + SDKROOT = macosx; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/PureMac.app/Contents/MacOS/PureMac"; + }; + name = Release; + }; D1B9D379DD6CC0DA24E58BCF /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ARCHS = "arm64 x86_64"; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; @@ -375,7 +484,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 3; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = H3WXHVTP97; ENABLE_NS_ASSERTIONS = NO; @@ -391,9 +500,10 @@ GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = PureMac/Info.plist; MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = 1.0.0; + MARKETING_VERSION = 1.0.1; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = NO; PRODUCT_BUNDLE_IDENTIFIER = com.puremac.app; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = macosx; @@ -409,10 +519,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; COMBINE_HIDPI_IMAGES = YES; INFOPLIST_FILE = PureMac/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - ); + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks"; PRODUCT_NAME = PureMac; SDKROOT = macosx; SWIFT_EMIT_LOC_STRINGS = YES; @@ -422,6 +529,15 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 25F40856927F2EE5BC50A0FB /* Build configuration list for PBXNativeTarget "PureMacTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 6AC7FF3E26B642C05D435ADB /* Debug */, + 8632219D8F7DC9932C9DCC9F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 2754925F163D15C85EDF494D /* Build configuration list for PBXNativeTarget "PureMac" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/PureMac/Models/Models.swift b/PureMac/Models/Models.swift index 78a9572..03e27f4 100644 --- a/PureMac/Models/Models.swift +++ b/PureMac/Models/Models.swift @@ -65,6 +65,16 @@ enum CleaningCategory: String, CaseIterable, Identifiable, Codable { static var scannable: [CleaningCategory] { allCases.filter { $0 != .smartScan } } + + // Categories safe for unattended cleanup. Personal-file categories and + // purgeable space require explicit manual action or the separate purge toggle. + static var automaticCleaningCategories: [CleaningCategory] { + [.systemJunk, .userCache, .aiApps, .trashBins, .xcodeJunk, .brewCache] + } + + var isAutomaticCleaningAllowed: Bool { + Self.automaticCleaningCategories.contains(self) + } } // MARK: - Scan State @@ -156,7 +166,7 @@ struct ScheduleConfig: Codable { var interval: ScheduleInterval = .daily var autoClean: Bool = false var autoPurge: Bool = false - var categoriesToScan: [CleaningCategory] = CleaningCategory.scannable + var categoriesToScan: [CleaningCategory] = CleaningCategory.automaticCleaningCategories var lastRunDate: Date? var nextRunDate: Date? var notifyOnCompletion: Bool = true diff --git a/PureMac/ViewModels/AppViewModel.swift b/PureMac/ViewModels/AppViewModel.swift index 8f32f25..40fe249 100644 --- a/PureMac/ViewModels/AppViewModel.swift +++ b/PureMac/ViewModels/AppViewModel.swift @@ -3,6 +3,18 @@ import Combine @MainActor class AppViewModel: ObservableObject { + private enum PendingCleanAction { + case all(items: [CleanableItem]) + case category(CleaningCategory, items: [CleanableItem]) + + var items: [CleanableItem] { + switch self { + case .all(let items), .category(_, let items): + return items + } + } + } + // MARK: - State @Published var selectedCategory: CleaningCategory = .smartScan @Published var scanState: ScanState = .idle @@ -15,13 +27,14 @@ class AppViewModel: ObservableObject { @Published var currentScanCategory: String = "" @Published var showCleanConfirmation = false @Published var lastCleanedDate: Date? - @Published var deselectedItems: Set = [] @Published var hasFullDiskAccess: Bool = true @Published var fdaBannerDismissed: Bool = false + @Published private var itemSelection = ItemSelectionState() var scheduler = SchedulerService() private let scanEngine = ScanEngine() private let cleaningEngine = CleaningEngine() + private var pendingCleanAction: PendingCleanAction? // MARK: - Computed @@ -40,29 +53,21 @@ class AppViewModel: ObservableObject { // MARK: - Selection func isItemSelected(_ item: CleanableItem) -> Bool { - !deselectedItems.contains(item.id) + itemSelection.isSelected(item) } func toggleItem(_ item: CleanableItem) { - if deselectedItems.contains(item.id) { - deselectedItems.remove(item.id) - } else { - deselectedItems.insert(item.id) - } + itemSelection.toggle(item) } func selectAllInCategory(_ category: CleaningCategory) { guard let result = categoryResults[category] else { return } - for item in result.items { - deselectedItems.remove(item.id) - } + itemSelection.selectAll(result.items) } func deselectAllInCategory(_ category: CleaningCategory) { guard let result = categoryResults[category] else { return } - for item in result.items { - deselectedItems.insert(item.id) - } + itemSelection.deselectAll(result.items) } func selectedSizeInCategory(_ category: CleaningCategory) -> Int64 { @@ -79,6 +84,14 @@ class AppViewModel: ObservableObject { allResults.flatMap { $0.items }.filter { isItemSelected($0) }.reduce(0) { $0 + $1.size } } + var cleanConfirmationMessage: String { + let items = pendingCleanAction?.items ?? [] + let size = items.reduce(0) { $0 + $1.size } + let formattedSize = ByteCountFormatter.string(fromByteCount: size, countStyle: .file) + + return "This will permanently delete \(items.count) selected items (\(formattedSize)). This cannot be undone." + } + // MARK: - Init init() { @@ -123,7 +136,7 @@ class AppViewModel: ObservableObject { categoryResults = [:] totalJunkSize = 0 scanProgress = 0 - deselectedItems.removeAll() + itemSelection.clear() Task { let categories = CleaningCategory.scannable @@ -154,7 +167,7 @@ class AppViewModel: ObservableObject { Task { scanProgress = 0.5 - deselectedItems.removeAll() + itemSelection.clear() let result = await scanEngine.scanCategory(category) categoryResults[category] = result @@ -168,9 +181,69 @@ class AppViewModel: ObservableObject { // MARK: - Cleaning func cleanAll() { + requestCleanAll() + } + + func cleanCategory(_ category: CleaningCategory) { + requestCleanCategory(category) + } + + func requestCleanAll() { + guard !scanState.isActive else { return } + + let items = selectedItemsForAll() + guard !items.isEmpty else { return } + + pendingCleanAction = .all(items: items) + showCleanConfirmation = true + } + + func requestCleanCategory(_ category: CleaningCategory) { + guard !scanState.isActive else { return } + + let items = selectedItems(in: category) + guard !items.isEmpty else { return } + + pendingCleanAction = .category(category, items: items) + showCleanConfirmation = true + } + + func cancelClean() { + pendingCleanAction = nil + showCleanConfirmation = false + } + + func confirmClean() { + let action = pendingCleanAction + pendingCleanAction = nil + showCleanConfirmation = false + + switch action { + case .all(let items): + performCleanAll(itemsToClean: items) + case .category(let category, let items): + performCleanCategory(category, itemsToClean: items) + case nil: + break + } + } + + private func performCleanAll( + itemsToClean providedItems: [CleanableItem]? = nil, + limitingTo categories: Set? = nil + ) { guard !scanState.isActive else { return } - let itemsToClean = allResults.flatMap { $0.items }.filter { isItemSelected($0) } + let itemsToClean: [CleanableItem] + if let providedItems { + itemsToClean = providedItems + } else { + let resultsToClean = allResults.filter { result in + categories?.contains(result.category) ?? true + } + itemsToClean = resultsToClean.flatMap { $0.items }.filter { isItemSelected($0) } + } + guard !itemsToClean.isEmpty else { return } scanState = .cleaning(progress: 0) @@ -187,9 +260,17 @@ class AppViewModel: ObservableObject { totalFreedSpace = result.freedSpace lastCleanedDate = Date() - // Clear results - categoryResults = [:] - totalJunkSize = 0 + if let categories { + for category in categories { + categoryResults.removeValue(forKey: category) + } + totalJunkSize = categoryResults.values.reduce(0) { $0 + $1.totalSize } + } else { + categoryResults = [:] + totalJunkSize = 0 + } + + itemSelection.clear() scanState = .cleaned loadDiskInfo() @@ -200,10 +281,20 @@ class AppViewModel: ObservableObject { } } - func cleanCategory(_ category: CleaningCategory) { - guard let result = categoryResults[category], !scanState.isActive else { return } + private func performCleanCategory( + _ category: CleaningCategory, + itemsToClean providedItems: [CleanableItem]? = nil + ) { + guard !scanState.isActive else { return } + + let selectedItems: [CleanableItem] + if let providedItems { + selectedItems = providedItems + } else { + guard let result = categoryResults[category] else { return } + selectedItems = result.items.filter { isItemSelected($0) } + } - let selectedItems = result.items.filter { isItemSelected($0) } guard !selectedItems.isEmpty else { return } scanState = .cleaning(progress: 0) @@ -222,6 +313,7 @@ class AppViewModel: ObservableObject { categoryResults.removeValue(forKey: category) totalJunkSize = categoryResults.values.reduce(0) { $0 + $1.totalSize } + itemSelection.clear() scanState = .cleaned loadDiskInfo() @@ -254,7 +346,7 @@ class AppViewModel: ObservableObject { // MARK: - Scheduled Scan private func runScheduledScan() async { - let categories = scheduler.config.categoriesToScan + let categories = scheduler.config.categoriesToScan.filter { $0.isAutomaticCleaningAllowed } var totalFound: Int64 = 0 for category in categories { @@ -266,7 +358,7 @@ class AppViewModel: ObservableObject { totalJunkSize = totalFound if scheduler.config.autoClean && totalFound >= scheduler.config.minimumCleanSize { - cleanAll() + performCleanAll(limitingTo: Set(categories)) } if scheduler.config.autoPurge { @@ -293,6 +385,16 @@ class AppViewModel: ObservableObject { UNUserNotificationCenter.current().add(request) } + + private func selectedItemsForAll() -> [CleanableItem] { + allResults.flatMap { $0.items }.filter { isItemSelected($0) } + } + + private func selectedItems(in category: CleaningCategory) -> [CleanableItem] { + guard let result = categoryResults[category] else { return [] } + return result.items.filter { isItemSelected($0) } + } + } import UserNotifications diff --git a/PureMac/ViewModels/ItemSelectionState.swift b/PureMac/ViewModels/ItemSelectionState.swift new file mode 100644 index 0000000..f1581cf --- /dev/null +++ b/PureMac/ViewModels/ItemSelectionState.swift @@ -0,0 +1,37 @@ +import Foundation + +struct ItemSelectionState { + private var overrides: [UUID: Bool] = [:] + + func isSelected(_ item: CleanableItem) -> Bool { + overrides[item.id] ?? item.isSelected + } + + mutating func toggle(_ item: CleanableItem) { + setSelected(!isSelected(item), for: item) + } + + mutating func selectAll(_ items: [CleanableItem]) { + for item in items { + setSelected(true, for: item) + } + } + + mutating func deselectAll(_ items: [CleanableItem]) { + for item in items { + setSelected(false, for: item) + } + } + + mutating func clear() { + overrides.removeAll() + } + + private mutating func setSelected(_ isSelected: Bool, for item: CleanableItem) { + if isSelected == item.isSelected { + overrides.removeValue(forKey: item.id) + } else { + overrides[item.id] = isSelected + } + } +} diff --git a/PureMac/Views/CategoryDetailView.swift b/PureMac/Views/CategoryDetailView.swift index 92bf7dc..f7c7c18 100644 --- a/PureMac/Views/CategoryDetailView.swift +++ b/PureMac/Views/CategoryDetailView.swift @@ -220,7 +220,7 @@ struct CategoryDetailView: View { gradient: AppGradients.accent ) { withAnimation(.pmSpring) { - vm.cleanCategory(category) + vm.requestCleanCategory(category) } } } diff --git a/PureMac/Views/ContentView.swift b/PureMac/Views/ContentView.swift index e99f78f..1c7ba23 100644 --- a/PureMac/Views/ContentView.swift +++ b/PureMac/Views/ContentView.swift @@ -51,6 +51,16 @@ struct ContentView: View { .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in vm.checkFullDiskAccess() } + .alert("Confirm Cleaning", isPresented: $vm.showCleanConfirmation) { + Button("Cancel", role: .cancel) { + vm.cancelClean() + } + Button("Delete Permanently", role: .destructive) { + vm.confirmClean() + } + } message: { + Text(vm.cleanConfirmationMessage) + } } } diff --git a/PureMac/Views/SmartScanView.swift b/PureMac/Views/SmartScanView.swift index f5c6204..494d4ec 100644 --- a/PureMac/Views/SmartScanView.swift +++ b/PureMac/Views/SmartScanView.swift @@ -356,7 +356,7 @@ struct SmartScanView: View { gradient: AppGradients.accent ) { withAnimation(.pmSpring) { - vm.cleanAll() + vm.requestCleanAll() } } diff --git a/PureMacTests/CleaningConfirmationTests.swift b/PureMacTests/CleaningConfirmationTests.swift new file mode 100644 index 0000000..7d24a41 --- /dev/null +++ b/PureMacTests/CleaningConfirmationTests.swift @@ -0,0 +1,106 @@ +import XCTest +@testable import PureMac + +@MainActor +final class CleaningConfirmationTests: XCTestCase { + func testRequestCleanAllShowsConfirmationWithoutStartingCleaning() { + let viewModel = AppViewModel() + let item = CleanableItem( + name: "cache.db", + path: "/Users/me/Library/Caches/cache.db", + size: 20_000, + category: .userCache, + isSelected: true, + lastModified: nil + ) + viewModel.categoryResults[.userCache] = CategoryResult( + category: .userCache, + items: [item], + totalSize: item.size + ) + viewModel.totalJunkSize = item.size + + viewModel.requestCleanAll() + + XCTAssertTrue(viewModel.showCleanConfirmation) + XCTAssertEqual(viewModel.scanState, .idle) + XCTAssertEqual(viewModel.categoryResults[.userCache]?.items.count, 1) + XCTAssertTrue(viewModel.cleanConfirmationMessage.contains("1 selected items")) + } + + func testCancelCleanClearsPendingConfirmation() { + let viewModel = AppViewModel() + let item = CleanableItem( + name: "cache.db", + path: "/Users/me/Library/Caches/cache.db", + size: 20_000, + category: .userCache, + isSelected: true, + lastModified: nil + ) + viewModel.categoryResults[.userCache] = CategoryResult( + category: .userCache, + items: [item], + totalSize: item.size + ) + + viewModel.requestCleanAll() + viewModel.cancelClean() + + XCTAssertFalse(viewModel.showCleanConfirmation) + XCTAssertTrue(viewModel.cleanConfirmationMessage.contains("0 selected items")) + } + + func testConfirmCleanUsesItemsCapturedAtRequestTime() async throws { + let fileManager = FileManager.default + let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try fileManager.createDirectory(at: tempDirectory, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: tempDirectory) } + + let originalURL = tempDirectory.appendingPathComponent("original.cache") + let replacementURL = tempDirectory.appendingPathComponent("replacement.cache") + try Data("old".utf8).write(to: originalURL) + try Data("new".utf8).write(to: replacementURL) + + let originalItem = CleanableItem( + name: "original.cache", + path: originalURL.path, + size: 3, + category: .userCache, + isSelected: true, + lastModified: nil + ) + let replacementItem = CleanableItem( + name: "replacement.cache", + path: replacementURL.path, + size: 3, + category: .userCache, + isSelected: true, + lastModified: nil + ) + + let viewModel = AppViewModel() + viewModel.categoryResults[.userCache] = CategoryResult( + category: .userCache, + items: [originalItem], + totalSize: originalItem.size + ) + + viewModel.requestCleanAll() + viewModel.categoryResults[.userCache] = CategoryResult( + category: .userCache, + items: [replacementItem], + totalSize: replacementItem.size + ) + + viewModel.confirmClean() + + let deadline = Date().addingTimeInterval(2) + while fileManager.fileExists(atPath: originalURL.path), Date() < deadline { + try await Task.sleep(nanoseconds: 50_000_000) + } + + XCTAssertFalse(fileManager.fileExists(atPath: originalURL.path)) + XCTAssertTrue(fileManager.fileExists(atPath: replacementURL.path)) + } +} diff --git a/PureMacTests/ItemSelectionStateTests.swift b/PureMacTests/ItemSelectionStateTests.swift new file mode 100644 index 0000000..dcab432 --- /dev/null +++ b/PureMacTests/ItemSelectionStateTests.swift @@ -0,0 +1,66 @@ +import XCTest +@testable import PureMac + +final class ItemSelectionStateTests: XCTestCase { + func testDefaultDeselectedItemsStartDeselected() { + var selection = ItemSelectionState() + let item = CleanableItem( + name: "movie.mov", + path: "/Users/me/Desktop/movie.mov", + size: 200_000_000, + category: .largeFiles, + isSelected: false, + lastModified: nil + ) + + XCTAssertFalse(selection.isSelected(item)) + } + + func testDefaultDeselectedItemsCanBeSelectedExplicitly() { + var selection = ItemSelectionState() + let item = CleanableItem( + name: "movie.mov", + path: "/Users/me/Desktop/movie.mov", + size: 200_000_000, + category: .largeFiles, + isSelected: false, + lastModified: nil + ) + + selection.toggle(item) + + XCTAssertTrue(selection.isSelected(item)) + } + + func testSelectAllSelectsDefaultDeselectedItems() { + var selection = ItemSelectionState() + let item = CleanableItem( + name: "movie.mov", + path: "/Users/me/Desktop/movie.mov", + size: 200_000_000, + category: .largeFiles, + isSelected: false, + lastModified: nil + ) + + selection.selectAll([item]) + + XCTAssertTrue(selection.isSelected(item)) + } + + func testDeselectAllDeselectsDefaultSelectedItems() { + var selection = ItemSelectionState() + let item = CleanableItem( + name: "cache.db", + path: "/Users/me/Library/Caches/cache.db", + size: 20_000, + category: .userCache, + isSelected: true, + lastModified: nil + ) + + selection.deselectAll([item]) + + XCTAssertFalse(selection.isSelected(item)) + } +} diff --git a/PureMacTests/ScheduleSafetyTests.swift b/PureMacTests/ScheduleSafetyTests.swift new file mode 100644 index 0000000..675f1b8 --- /dev/null +++ b/PureMacTests/ScheduleSafetyTests.swift @@ -0,0 +1,19 @@ +import XCTest +@testable import PureMac + +final class ScheduleSafetyTests: XCTestCase { + func testDefaultScheduledCategoriesExcludePersonalFileCategories() { + let categories = ScheduleConfig().categoriesToScan + + XCTAssertFalse(categories.contains(.largeFiles)) + XCTAssertFalse(categories.contains(.mailAttachments)) + XCTAssertFalse(categories.contains(.purgeableSpace)) + } + + func testAutomaticCleaningCategoriesAreOnlyCacheLikeTargets() { + XCTAssertEqual( + CleaningCategory.automaticCleaningCategories, + [.systemJunk, .userCache, .aiApps, .trashBins, .xcodeJunk, .brewCache] + ) + } +} diff --git a/docs/superpowers/plans/2026-04-14-cleaning-safety-fixes.md b/docs/superpowers/plans/2026-04-14-cleaning-safety-fixes.md new file mode 100644 index 0000000..ad136f5 --- /dev/null +++ b/docs/superpowers/plans/2026-04-14-cleaning-safety-fixes.md @@ -0,0 +1,197 @@ +# Cleaning Safety Fixes Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking. + +**Goal:** Prevent PureMac from deleting personal files unexpectedly and add visible confirmation before manual destructive cleaning. + +**Architecture:** Move item-selection state into a small testable model, keep manual cleaning behind an explicit confirmation request, and restrict scheduled auto-clean to categories that are cache/log/temp cleanup targets. The cleaner still uses the existing scan and delete engines, but the ViewModel becomes the gatekeeper for selection, confirmation, and scheduled-clean safety. + +**Tech Stack:** Swift 5.9, SwiftUI, XCTest, XcodeGen. + +--- + +### Task 1: Regression Tests for Selection and Scheduled Auto-Clean + +**Files:** +- Modify: `project.yml` +- Create: `PureMacTests/ItemSelectionStateTests.swift` +- Create: `PureMacTests/ScheduleSafetyTests.swift` +- Regenerate: `PureMac.xcodeproj/project.pbxproj` + +- [x] **Step 1: Add an XCTest target in `project.yml`** + +Add a `PureMacTests` target with `platform: macOS`, `type: bundle.unit-test`, source path `PureMacTests`, and dependency on the `PureMac` application target. + +- [x] **Step 2: Write failing selection tests** + +```swift +import XCTest +@testable import PureMac + +final class ItemSelectionStateTests: XCTestCase { + func testDefaultDeselectedItemsStartDeselected() { + var selection = ItemSelectionState() + let item = CleanableItem(name: "movie.mov", path: "/Users/me/Desktop/movie.mov", size: 200_000_000, category: .largeFiles, isSelected: false, lastModified: nil) + + XCTAssertFalse(selection.isSelected(item)) + } + + func testDefaultDeselectedItemsCanBeSelectedExplicitly() { + var selection = ItemSelectionState() + let item = CleanableItem(name: "movie.mov", path: "/Users/me/Desktop/movie.mov", size: 200_000_000, category: .largeFiles, isSelected: false, lastModified: nil) + + selection.toggle(item) + + XCTAssertTrue(selection.isSelected(item)) + } + + func testSelectAllSelectsDefaultDeselectedItems() { + var selection = ItemSelectionState() + let item = CleanableItem(name: "movie.mov", path: "/Users/me/Desktop/movie.mov", size: 200_000_000, category: .largeFiles, isSelected: false, lastModified: nil) + + selection.selectAll([item]) + + XCTAssertTrue(selection.isSelected(item)) + } + + func testDeselectAllDeselectsDefaultSelectedItems() { + var selection = ItemSelectionState() + let item = CleanableItem(name: "cache.db", path: "/Users/me/Library/Caches/cache.db", size: 20_000, category: .userCache, isSelected: true, lastModified: nil) + + selection.deselectAll([item]) + + XCTAssertFalse(selection.isSelected(item)) + } +} +``` + +- [x] **Step 3: Write failing scheduled-clean safety tests** + +```swift +import XCTest +@testable import PureMac + +final class ScheduleSafetyTests: XCTestCase { + func testDefaultScheduledCategoriesExcludePersonalFileCategories() { + let categories = ScheduleConfig().categoriesToScan + + XCTAssertFalse(categories.contains(.largeFiles)) + XCTAssertFalse(categories.contains(.mailAttachments)) + XCTAssertFalse(categories.contains(.purgeableSpace)) + } + + func testAutomaticCleaningCategoriesAreOnlyCacheLikeTargets() { + XCTAssertEqual( + CleaningCategory.automaticCleaningCategories, + [.systemJunk, .userCache, .aiApps, .trashBins, .xcodeJunk, .brewCache] + ) + } +} +``` + +- [x] **Step 4: Run tests and verify red** + +Run: `xcodegen generate && xcodebuild test -project PureMac.xcodeproj -scheme PureMac -destination 'platform=macOS'` + +Expected: test build fails because `ItemSelectionState` and `CleaningCategory.automaticCleaningCategories` do not exist yet, or schedule assertions fail against the current all-category default. + +### Task 2: Fix Default Selection Semantics + +**Files:** +- Create: `PureMac/ViewModels/ItemSelectionState.swift` +- Modify: `PureMac/ViewModels/AppViewModel.swift` + +- [x] **Step 1: Implement `ItemSelectionState`** + +Create a value type that returns `item.isSelected` unless a user override exists. `toggle`, `selectAll`, and `deselectAll` should store overrides only when they differ from the item default. + +- [x] **Step 2: Replace `deselectedItems` usage in `AppViewModel`** + +Use `@Published private var itemSelection = ItemSelectionState()` and route `isItemSelected`, `toggleItem`, `selectAllInCategory`, `deselectAllInCategory`, `selectedSizeInCategory`, `selectedCountInCategory`, and `totalSelectedSize` through the new selection state. + +- [x] **Step 3: Clear selection overrides on every new scan** + +Call `itemSelection.clear()` where the old code called `deselectedItems.removeAll()`. + +- [x] **Step 4: Run selection tests and verify green** + +Run: `xcodebuild test -project PureMac.xcodeproj -scheme PureMac -destination 'platform=macOS' -only-testing:PureMacTests/ItemSelectionStateTests` + +Expected: all selection tests pass. + +### Task 3: Restrict Scheduled Auto-Clean + +**Files:** +- Modify: `PureMac/Models/Models.swift` +- Modify: `PureMac/ViewModels/AppViewModel.swift` + +- [x] **Step 1: Add automatic-clean category policy** + +Add `CleaningCategory.automaticCleaningCategories` returning `[.systemJunk, .userCache, .aiApps, .trashBins, .xcodeJunk, .brewCache]` and `var isAutomaticCleaningAllowed: Bool`. + +- [x] **Step 2: Change schedule defaults** + +Change `ScheduleConfig.categoriesToScan` from `CleaningCategory.scannable` to `CleaningCategory.automaticCleaningCategories`. + +- [x] **Step 3: Filter existing persisted schedules at runtime** + +In `runScheduledScan()`, filter `scheduler.config.categoriesToScan` with `isAutomaticCleaningAllowed` before scanning and auto-cleaning. This protects users who already have persisted configs containing `.largeFiles`, `.mailAttachments`, or `.purgeableSpace`. + +- [x] **Step 4: Run schedule safety tests and verify green** + +Run: `xcodebuild test -project PureMac.xcodeproj -scheme PureMac -destination 'platform=macOS' -only-testing:PureMacTests/ScheduleSafetyTests` + +Expected: all schedule safety tests pass. + +### Task 4: Add Manual Clean Confirmation + +**Files:** +- Modify: `PureMac/ViewModels/AppViewModel.swift` +- Modify: `PureMac/Views/ContentView.swift` +- Modify: `PureMac/Views/SmartScanView.swift` +- Modify: `PureMac/Views/CategoryDetailView.swift` + +- [x] **Step 1: Add pending clean state** + +Add a private pending action enum for all-items vs category cleaning. Add `requestCleanAll()`, `requestCleanCategory(_:)`, `cancelClean()`, `confirmClean()`, and a `cleanConfirmationMessage` string. + +- [x] **Step 2: Keep scheduled cleaning direct but safe** + +Move current cleaning implementation into private `performCleanAll()` and `performCleanCategory(_:)`; manual UI calls request methods, scheduled auto-clean calls `performCleanAll()` after filtering categories. + +- [x] **Step 3: Add a destructive confirmation alert** + +Attach an alert at the root `ContentView`. The destructive button text should clearly state permanent deletion. + +- [x] **Step 4: Route clean buttons through request methods** + +Change Smart Scan and Category Detail clean buttons to call `requestCleanAll()` and `requestCleanCategory(_:)`. + +- [x] **Step 5: Build the app** + +Run: `xcodebuild build -project PureMac.xcodeproj -scheme PureMac -destination 'platform=macOS'` + +Expected: build succeeds. + +### Task 5: Full Verification + +**Files:** +- Verify all modified files. + +- [x] **Step 1: Run all tests** + +Run: `xcodebuild test -project PureMac.xcodeproj -scheme PureMac -destination 'platform=macOS'` + +Expected: all tests pass. + +- [x] **Step 2: Run release build** + +Run: `xcodebuild build -project PureMac.xcodeproj -scheme PureMac -configuration Release -destination 'platform=macOS'` + +Expected: build succeeds. + +- [x] **Step 3: Review security-sensitive searches** + +Run: `rg -n "removeItem|Process\\(|launchctl|tmutil|diskutil|URLSession|analytics|telemetry|isSelected|automaticCleaningCategories" PureMac -g '*.swift'` + +Expected: destructive/process surfaces are limited to known cleaner paths, and no telemetry/network client appears. diff --git a/project.yml b/project.yml index 6f6fa42..6b8028b 100644 --- a/project.yml +++ b/project.yml @@ -36,3 +36,16 @@ targets: PRODUCT_NAME: PureMac SWIFT_EMIT_LOC_STRINGS: "YES" LD_RUNPATH_SEARCH_PATHS: "$(inherited) @executable_path/../Frameworks" + + PureMacTests: + type: bundle.unit-test + platform: macOS + sources: + - PureMacTests + dependencies: + - target: PureMac + settings: + base: + PRODUCT_NAME: PureMacTests + PRODUCT_BUNDLE_IDENTIFIER: com.puremac.app.tests + GENERATE_INFOPLIST_FILE: "YES"