commit 869b84f27c762916b106a72b47a58d62753c38a7 Author: wehub-resource-sync Date: Mon Jul 13 12:29:30 2026 +0800 chore: import upstream snapshot with attribution diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..1e46d37 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: momenbasel diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..cab2d3f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,27 @@ +--- +name: Bug Report +about: Report a bug or unexpected behavior +title: "[Bug] " +labels: bug +assignees: '' +--- + +**Describe the bug** +A clear description of what the bug is. + +**To reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '...' +3. See error + +**Expected behavior** +What you expected to happen. + +**Screenshots** +If applicable, add screenshots. + +**Environment** +- macOS version: +- PureMac version: +- Install method: (Homebrew / DMG / App Store / Source) diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..5fe0457 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,16 @@ +--- +name: Feature Request +about: Suggest a new feature or improvement +title: "[Feature] " +labels: enhancement +assignees: '' +--- + +**What would you like?** +A clear description of the feature. + +**Why is this useful?** +Describe the use case and who benefits. + +**Alternatives considered** +Any other approaches you've thought about. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..5adc74a --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,22 @@ +## What does this PR do? + + + +## Type of change + +- [ ] Bug fix +- [ ] New feature +- [ ] Performance improvement +- [ ] UI/UX enhancement +- [ ] Documentation +- [ ] Other (describe) + +## Testing + +- [ ] Tested on macOS Ventura (13.x) +- [ ] Tested on macOS Sonoma (14.x) +- [ ] Tested on macOS Sequoia (15.x) + +## Screenshots + + diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..e85a8ae --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,32 @@ +name: Build + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build: + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + + - name: Install XcodeGen + run: brew install xcodegen + + - name: Generate Xcode project + run: xcodegen generate + + - name: Build (Universal Binary) + run: | + xcodebuild -project PureMac.xcodeproj \ + -scheme PureMac \ + -configuration Release \ + -derivedDataPath build \ + build \ + ARCHS="arm64 x86_64" \ + ONLY_ACTIVE_ARCH=NO \ + CODE_SIGN_IDENTITY="" \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGNING_ALLOWED=NO diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..714811d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,333 @@ +name: Release + +on: + push: + tags: + - 'v*.*.*' + workflow_dispatch: + inputs: + version: + description: 'Version to release (e.g. 2.2.0). Must match project.yml MARKETING_VERSION.' + required: true + type: string + dry_run: + description: 'Build + sign + notarize but skip release upload + homebrew bump.' + required: false + default: false + type: boolean + +permissions: + contents: write + +env: + APP_NAME: PureMac + BUNDLE_ID: com.puremac.app + TEAM_ID: H3WXHVTP97 + SCHEME: PureMac + PROJECT: PureMac.xcodeproj + CONFIGURATION: Release + +jobs: + release: + name: Build, sign, notarize, release + runs-on: macos-15 + timeout-minutes: 60 + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Resolve version + id: ver + run: | + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + VERSION="${{ inputs.version }}" + else + VERSION="${GITHUB_REF_NAME#v}" + fi + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT" + echo "Releasing version: ${VERSION}" + + - name: Verify version matches project.yml + run: | + PROJ_VERSION=$(grep -E '^\s*MARKETING_VERSION:' project.yml | sed -E 's/.*"([^"]+)".*/\1/') + if [[ "${PROJ_VERSION}" != "${{ steps.ver.outputs.version }}" ]]; then + echo "::error::project.yml MARKETING_VERSION (${PROJ_VERSION}) != release version (${{ steps.ver.outputs.version }}). Bump project.yml first." + exit 1 + fi + + - name: Select Xcode + run: sudo xcode-select -s /Applications/Xcode_16.4.app + + - name: Install xcodegen + create-dmg + run: | + brew install xcodegen create-dmg + + - name: Generate Xcode project + run: xcodegen generate + + - name: Import Developer ID signing certificate + env: + BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }} + P12_PASSWORD: ${{ secrets.P12_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + run: | + set -euo pipefail + CERT_PATH="${RUNNER_TEMP}/certificate.p12" + KEYCHAIN_PATH="${RUNNER_TEMP}/app-signing.keychain-db" + echo -n "${BUILD_CERTIFICATE_BASE64}" | base64 --decode -o "${CERT_PATH}" + security create-keychain -p "${KEYCHAIN_PASSWORD}" "${KEYCHAIN_PATH}" + security set-keychain-settings -lut 21600 "${KEYCHAIN_PATH}" + security unlock-keychain -p "${KEYCHAIN_PASSWORD}" "${KEYCHAIN_PATH}" + security import "${CERT_PATH}" -P "${P12_PASSWORD}" -A -t cert -f pkcs12 -k "${KEYCHAIN_PATH}" + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "${KEYCHAIN_PASSWORD}" "${KEYCHAIN_PATH}" >/dev/null + security list-keychain -d user -s "${KEYCHAIN_PATH}" $(security list-keychain -d user | xargs) + # Verify identity loaded + security find-identity -v -p codesigning "${KEYCHAIN_PATH}" | grep "Developer ID Application: Moamen Basel" + + - name: Archive (universal arm64 + x86_64) + run: | + set -euo pipefail + xcodebuild \ + -project "${PROJECT}" \ + -scheme "${SCHEME}" \ + -configuration "${CONFIGURATION}" \ + -destination 'generic/platform=macOS' \ + -archivePath build/PureMac.xcarchive \ + ARCHS="arm64 x86_64" \ + ONLY_ACTIVE_ARCH=NO \ + CODE_SIGN_STYLE=Manual \ + CODE_SIGN_IDENTITY="Developer ID Application: Moamen Basel (H3WXHVTP97)" \ + DEVELOPMENT_TEAM="${TEAM_ID}" \ + OTHER_CODE_SIGN_FLAGS="--timestamp --options=runtime" \ + archive | xcbeautify + + - name: Export signed app + run: | + set -euo pipefail + cat > build/ExportOptions.plist <<'PLIST' + + + + + methoddeveloper-id + teamIDH3WXHVTP97 + signingStylemanual + signingCertificateDeveloper ID Application + + + PLIST + xcodebuild -exportArchive \ + -archivePath build/PureMac.xcarchive \ + -exportPath build/export \ + -exportOptionsPlist build/ExportOptions.plist | xcbeautify + + - name: Verify codesign + hardened runtime + run: | + set -euo pipefail + APP="build/export/PureMac.app" + codesign --verify --deep --strict --verbose=2 "${APP}" + codesign -dvv "${APP}" 2>&1 | tee /tmp/cs-info.txt + grep -q "flags=0x10000(runtime)" /tmp/cs-info.txt || { echo "::error::Hardened runtime missing"; exit 1; } + spctl --assess --type execute --verbose=4 "${APP}" || true + # Universal arch check + lipo -archs "${APP}/Contents/MacOS/PureMac" | grep -q "x86_64" && lipo -archs "${APP}/Contents/MacOS/PureMac" | grep -q "arm64" + + - name: Build DMG + env: + VERSION: ${{ steps.ver.outputs.version }} + run: | + set -euo pipefail + DMG="build/PureMac-${VERSION}.dmg" + create-dmg \ + --volname "PureMac ${VERSION}" \ + --window-size 540 360 \ + --icon-size 100 \ + --icon "PureMac.app" 140 180 \ + --hide-extension "PureMac.app" \ + --app-drop-link 400 180 \ + --no-internet-enable \ + "${DMG}" \ + build/export/PureMac.app + codesign --sign "Developer ID Application: Moamen Basel (H3WXHVTP97)" --timestamp "${DMG}" + codesign --verify --verbose=2 "${DMG}" + + - name: Write App Store Connect API key + env: + APP_STORE_CONNECT_PRIVATE_KEY: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }} + run: | + set -euo pipefail + KEY_PATH="${RUNNER_TEMP}/asc_api_key.p8" + printf '%s' "${APP_STORE_CONNECT_PRIVATE_KEY}" > "${KEY_PATH}" + chmod 600 "${KEY_PATH}" + echo "ASC_KEY_PATH=${KEY_PATH}" >> "$GITHUB_ENV" + + - name: Notarize DMG + env: + ASC_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} + ASC_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} + VERSION: ${{ steps.ver.outputs.version }} + run: | + set -euo pipefail + DMG="build/PureMac-${VERSION}.dmg" + xcrun notarytool submit "${DMG}" \ + --key "${ASC_KEY_PATH}" \ + --key-id "${ASC_KEY_ID}" \ + --issuer "${ASC_ISSUER_ID}" \ + --wait \ + --timeout 30m + xcrun stapler staple "${DMG}" + xcrun stapler validate "${DMG}" + spctl --assess --type install --verbose=4 "${DMG}" + + - name: Build notarized zip + env: + ASC_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} + ASC_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} + VERSION: ${{ steps.ver.outputs.version }} + run: | + set -euo pipefail + ZIP="build/PureMac-${VERSION}.zip" + # Notarize the .app inside a zip too (homebrew cask uses zip) + ditto -c -k --keepParent --sequesterRsrc build/export/PureMac.app build/PureMac-app.zip + xcrun notarytool submit build/PureMac-app.zip \ + --key "${ASC_KEY_PATH}" \ + --key-id "${ASC_KEY_ID}" \ + --issuer "${ASC_ISSUER_ID}" \ + --wait --timeout 30m + xcrun stapler staple build/export/PureMac.app + # Final shippable zip with stapled ticket + ditto -c -k --keepParent --sequesterRsrc build/export/PureMac.app "${ZIP}" + + - name: Compute SHA256 checksums + id: sha + env: + VERSION: ${{ steps.ver.outputs.version }} + run: | + set -euo pipefail + DMG_SHA=$(shasum -a 256 "build/PureMac-${VERSION}.dmg" | awk '{print $1}') + ZIP_SHA=$(shasum -a 256 "build/PureMac-${VERSION}.zip" | awk '{print $1}') + echo "dmg=${DMG_SHA}" >> "$GITHUB_OUTPUT" + echo "zip=${ZIP_SHA}" >> "$GITHUB_OUTPUT" + { + echo "## Checksums" + echo "" + echo "| Asset | SHA-256 |" + echo "|-------|---------|" + echo "| PureMac-${VERSION}.dmg | \`${DMG_SHA}\` |" + echo "| PureMac-${VERSION}.zip | \`${ZIP_SHA}\` |" + } > build/CHECKSUMS.md + + - name: Upload artifacts (always, for inspection) + uses: actions/upload-artifact@v4 + with: + name: puremac-${{ steps.ver.outputs.version }} + path: | + build/PureMac-${{ steps.ver.outputs.version }}.dmg + build/PureMac-${{ steps.ver.outputs.version }}.zip + build/CHECKSUMS.md + retention-days: 14 + + - name: Create or update GitHub release + if: ${{ github.event_name == 'push' || inputs.dry_run == false }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.ver.outputs.version }} + TAG: ${{ steps.ver.outputs.tag }} + run: | + set -euo pipefail + NOTES_FILE=build/release-notes.md + { + echo "## PureMac ${VERSION}" + echo "" + echo "Universal binary (Intel + Apple Silicon). Signed with Developer ID + notarized + stapled by Apple." + echo "" + cat build/CHECKSUMS.md + echo "" + echo "### Install" + echo "" + echo "\`\`\`" + echo "brew tap momenbasel/tap" + echo "brew install --cask puremac" + echo "\`\`\`" + } > "${NOTES_FILE}" + + if gh release view "${TAG}" --repo "${GITHUB_REPOSITORY}" >/dev/null 2>&1; then + gh release upload "${TAG}" \ + "build/PureMac-${VERSION}.dmg" \ + "build/PureMac-${VERSION}.zip" \ + --repo "${GITHUB_REPOSITORY}" --clobber + else + gh release create "${TAG}" \ + "build/PureMac-${VERSION}.dmg" \ + "build/PureMac-${VERSION}.zip" \ + --title "PureMac ${TAG}" \ + --notes-file "${NOTES_FILE}" \ + --repo "${GITHUB_REPOSITORY}" + fi + + - name: Bump in-repo homebrew formula + if: ${{ github.event_name == 'push' || inputs.dry_run == false }} + env: + VERSION: ${{ steps.ver.outputs.version }} + ZIP_SHA: ${{ steps.sha.outputs.zip }} + run: | + set -euo pipefail + FORMULA=homebrew/puremac.rb + /usr/bin/sed -i '' -E "s/^ version \".*\"/ version \"${VERSION}\"/" "${FORMULA}" + /usr/bin/sed -i '' -E "s/^ sha256 \".*\"/ sha256 \"${ZIP_SHA}\"/" "${FORMULA}" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add "${FORMULA}" + if git diff --cached --quiet; then + echo "No formula changes to commit." + else + git commit -m "homebrew: bump to ${VERSION} (sha256 ${ZIP_SHA:0:12})" + git push origin HEAD:main + fi + + - name: Bump tap formula (momenbasel/homebrew-tap) + # `env.HOMEBREW_TAP_TOKEN != ''` evaluated empty because env was + # step-scoped — referencing the secret directly works in step `if` + # per GitHub Actions docs. Also emits a loud warning when the secret + # is absent so future runs don't silently skip again. + if: ${{ github.event_name == 'push' || inputs.dry_run == false }} + env: + HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + VERSION: ${{ steps.ver.outputs.version }} + ZIP_SHA: ${{ steps.sha.outputs.zip }} + run: | + set -euo pipefail + if [[ -z "${HOMEBREW_TAP_TOKEN:-}" ]]; then + echo "::warning title=Tap bump skipped::HOMEBREW_TAP_TOKEN secret is not set. Tap will stay at its previous version. Set the secret to enable auto-bumps." + exit 0 + fi + TAP_DIR="${RUNNER_TEMP}/homebrew-tap" + git clone "https://x-access-token:${HOMEBREW_TAP_TOKEN}@github.com/momenbasel/homebrew-tap.git" "${TAP_DIR}" + cd "${TAP_DIR}" + # Cask path may be Casks/puremac.rb or puremac.rb depending on tap layout + FORMULA=$(find . -name 'puremac.rb' -not -path './.git/*' | head -1) + if [[ -z "${FORMULA}" ]]; then + echo "::error::puremac.rb not found in tap" + exit 1 + fi + /usr/bin/sed -i '' -E "s/^ version \".*\"/ version \"${VERSION}\"/" "${FORMULA}" + /usr/bin/sed -i '' -E "s/^ sha256 \".*\"/ sha256 \"${ZIP_SHA}\"/" "${FORMULA}" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add "${FORMULA}" + if git diff --cached --quiet; then + echo "No tap formula changes." + else + git commit -m "puremac: bump to ${VERSION} (sha256 ${ZIP_SHA:0:12})" + git push origin HEAD + fi + + - name: Cleanup keychain + ASC key + if: always() + run: | + security delete-keychain "${RUNNER_TEMP}/app-signing.keychain-db" || true + rm -f "${RUNNER_TEMP}/asc_api_key.p8" || true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..58d2cab --- /dev/null +++ b/.gitignore @@ -0,0 +1,54 @@ +# Xcode +build/ +build-ci/ +DerivedData/ +*.xcworkspace +!*.xcworkspace/contents.xcworkspacedata +*.xcuserdata +xcuserdata/ + +# macOS +.DS_Store +*.swp +*~ + +# Swift Package Manager +.build/ +Packages/ +Package.resolved + +# CocoaPods +Pods/ + +# Carthage +Carthage/Build/ + +# IDE +*.pbxuser +*.mode1v3 +*.mode2v3 +*.perspectivev3 +*.moved-aside + +# Fastlane (entire directory) +fastlane/ + +# Credentials +*.p8 +*.p12 +*.cer +*.mobileprovision +fastlane/README.md +asc_api.py +ExportOptions.plist + +# Build artifacts +*.dmg +*.zip +build/ + +# Work files (local only) +_work/ + +# Local handoff notes (never committed) +HANDOFF.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..c152388 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,52 @@ +# Contributing to PureMac + +Thanks for your interest in contributing to PureMac. + +## Getting Started + +1. Fork the repo +2. Clone your fork: `git clone https://github.com/YOUR_USERNAME/PureMac.git` +3. Install prerequisites: `brew install xcodegen` +4. Generate the Xcode project: `xcodegen generate` +5. Open in Xcode: `open PureMac.xcodeproj` + +## Development + +- PureMac is built with **SwiftUI** and targets **macOS 13.0+** +- Project structure is defined in `project.yml` (XcodeGen) +- Run the app from Xcode with Cmd+R + +## Pull Requests + +- Keep PRs focused on a single change +- Test your changes on at least macOS Ventura (13.0) +- Follow existing code style +- Update the README if your change affects user-facing behavior + +## What to Contribute + +- Bug fixes +- New cleaning categories +- Performance improvements +- UI/UX enhancements +- Localization (translations) +- Documentation improvements + +## Reporting Bugs + +Open an issue using the **Bug Report** template. Include: +- macOS version +- PureMac version +- Steps to reproduce +- Expected vs actual behavior + +## Feature Requests + +Open an issue using the **Feature Request** template. Describe: +- What you'd like to see +- Why it would be useful +- Any alternatives you've considered + +## Code of Conduct + +Be respectful and constructive. We're all here to make Mac maintenance easier for everyone. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c830c16 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 PureMac Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/PureMac.xcodeproj/project.pbxproj b/PureMac.xcodeproj/project.pbxproj new file mode 100644 index 0000000..51dca53 --- /dev/null +++ b/PureMac.xcodeproj/project.pbxproj @@ -0,0 +1,774 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 013EF7B96BF046D4DB38FBC5 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 241E0895B09C71AB423B2F9E /* Localizable.strings */; }; + 1B0D043102FDB245312A5147 /* AppFilesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 798B80977D14647A5691B0A0 /* AppFilesView.swift */; }; + 2253F11BDF561B617439C96B /* FullDiskAccessManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E866A1541D289C69144A5E62 /* FullDiskAccessManager.swift */; }; + 27F449EDD1B082FE11FEC9DF /* SchedulerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28181F034530331A550C6A3D /* SchedulerService.swift */; }; + 340E424F759ACCDE7372F99F /* Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5ADDC35F31E40780FB5D017 /* Theme.swift */; }; + 41B27CACD7C6C7471EFD03F9 /* UpdateService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 022D0EAEE2B6E54069FC2CBE /* UpdateService.swift */; }; + 47C5ECD49C4DD75F271DB6CE /* StringNormalization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D3AC5F17D7CA94FAB1E8D7A /* StringNormalization.swift */; }; + 48D1431A7C99C19EEBDB056B /* CleaningEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A8DE5D45BA19E2670B57DC5 /* CleaningEngine.swift */; }; + 4F754D89F4CE5142BE384062 /* AppConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = F31F91226CDCFBB8E303B7DA /* AppConstants.swift */; }; + 50304378D99F2E9E48B642AF /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 9CC8F66E27BE52A6639E904B /* Sparkle */; }; + 52F11962555C639F0EECADD6 /* FDADemoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231FF7CBB5F621B66F2AB8A5 /* FDADemoView.swift */; }; + 535B23C0108C06475215B8E8 /* AppTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AB003A7751F05727DBFD1A5 /* AppTheme.swift */; }; + 59B3096E2AE8ED0397B16798 /* AppLanguagePreferencesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 491771F923C93FD61A263893 /* AppLanguagePreferencesTests.swift */; }; + 61D285E96DED76FDCD54570F /* PermissionSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 069AAE87E3F9EDE5593AD8B3 /* PermissionSheet.swift */; }; + 6E72022A661CBB41331A442C /* PermissionCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9DCBD97CC88D77AE8DB01A4 /* PermissionCoordinator.swift */; }; + 75B5F0401D37F2872B1AD85A /* AppListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60E6BC61614B27C065BB18C9 /* AppListView.swift */; }; + 76B132F9C499225D33E0D075 /* EmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 424F7B28624C271620E13BBC /* EmptyStateView.swift */; }; + 826A750D2D7EC14C2AE306A3 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3667D46D8E2004EB4D73835A /* Models.swift */; }; + 8947F0CE448791BD50EECF46 /* Conditions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB94E06E145558123BB5BFB3 /* Conditions.swift */; }; + 8B541441926847072DC5B75B /* DashboardCharts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08C60E04A0CA3F227816DB63 /* DashboardCharts.swift */; }; + 91751CF033E1541BFD39B12C /* MenuBarMonitorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A27DB5A70FA829B6C3ED0AC3 /* MenuBarMonitorView.swift */; }; + 93743B036059418560D876E6 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A8D39C3C250F26302EC45AB /* SettingsView.swift */; }; + 95278ABDCF5F4D6AC60503B1 /* AppearancePill.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92259B3E9F4468865F15DEEC /* AppearancePill.swift */; }; + 9AA80E035DF7B33F6EE118DF /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = CED1B71D5F9510582E869CFD /* AppState.swift */; }; + 9BB5AAA574AFED6C27A3F8E2 /* AppPathFinder.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC0FEE7141871ED5F9E36121 /* AppPathFinder.swift */; }; + 9E3639F9EFCB2A22F6747894 /* MenuBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 913E3064AA9BD7BE94A315CF /* MenuBarController.swift */; }; + A2AE68CC75CB72D6B10CBDF5 /* DashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F661A0F64CF93E482CB1728F /* DashboardView.swift */; }; + A549D8A39A9E5E70D91B90A6 /* Haptics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40A3F22FCEF534DE4A2CAD0E /* Haptics.swift */; }; + A9C3A1F643C26930F442E729 /* OrphanSafetyPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AE790496C936424EF98320A /* OrphanSafetyPolicy.swift */; }; + ABDDD4F35102A39CC1A2C325 /* ConfettiView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0599AA604B0D6BA4F957537 /* ConfettiView.swift */; }; + B51E5A95BB49A6C0F5273E21 /* FileSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CE522B406791BCE905BC55B /* FileSize.swift */; }; + B52938BBD11842631314543D /* CategoryDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10B0AF194677EAC1D5568785 /* CategoryDetailView.swift */; }; + BC6C800216343438413349A3 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D4FD34378988D430A582ED0 /* OnboardingView.swift */; }; + CDCDFEBA39A3290101F86AC6 /* MainWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F510F232341EE18F11DC934 /* MainWindow.swift */; }; + CFA64F54CDBF8A4765E0068D /* LocalizationFilesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 155CE1B8CDCCCA6F9FD028C1 /* LocalizationFilesTests.swift */; }; + D50EB059E741011EB2523731 /* ScanError.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEF15CB1B8EFCA78EF491824 /* ScanError.swift */; }; + D9445C2641A8637B65DA5ACE /* ScanEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = A711CDF5285F68775D9B5513 /* ScanEngine.swift */; }; + DBFD73E3D9BDA74EC1CA56AB /* SystemMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB798781B99987F55D77E2E3 /* SystemMonitor.swift */; }; + DC32253D26D0E29762006DA0 /* AppBundleDragHandle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D3F9FEDC493A8E49E910F67 /* AppBundleDragHandle.swift */; }; + DDD6BA35DBF32E7A6B5F6F8B /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B2EA41E1096FA8E3B916AD13 /* Assets.xcassets */; }; + DDDA5879006CB97D5D7BDD87 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5785762276FB5E3209C6DE4D /* Logger.swift */; }; + E2403D82F00D749C9AD4A6D4 /* AppStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 752603285F7DBBA12BB3AA91 /* AppStateTests.swift */; }; + E60B0A2C5D0A6CAE35BF4DFB /* Locations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77D3D9A9BC52839E6D0A22BC /* Locations.swift */; }; + E726702EABBE56155861DBA9 /* AppLanguage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82BB0904726B38DEB141BECA /* AppLanguage.swift */; }; + EDEF28CAD23E936FBED1783B /* PureMacApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63581B70F9B10231964E3602 /* PureMacApp.swift */; }; + F1F506C146E17D6CEC4A12F3 /* AppInfoFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23486A54A82EE3865B784D1C /* AppInfoFetcher.swift */; }; + F2FA881A4B2209CC2A6342FB /* CLI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01B2C5F66B6D812572BD4F05 /* CLI.swift */; }; + FDA30FE6349CA01C8D859F04 /* OrphanListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B71E8F62DB76D85F41F9A2E9 /* OrphanListView.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 */ + 01B2C5F66B6D812572BD4F05 /* CLI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLI.swift; sourceTree = ""; }; + 022D0EAEE2B6E54069FC2CBE /* UpdateService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateService.swift; sourceTree = ""; }; + 02E502E2B5C6AECC76E5CFEF /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/Localizable.strings"; sourceTree = ""; }; + 069AAE87E3F9EDE5593AD8B3 /* PermissionSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionSheet.swift; sourceTree = ""; }; + 08C60E04A0CA3F227816DB63 /* DashboardCharts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardCharts.swift; sourceTree = ""; }; + 10B0AF194677EAC1D5568785 /* CategoryDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryDetailView.swift; sourceTree = ""; }; + 155CE1B8CDCCCA6F9FD028C1 /* LocalizationFilesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizationFilesTests.swift; sourceTree = ""; }; + 1AB003A7751F05727DBFD1A5 /* AppTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTheme.swift; sourceTree = ""; }; + 1D3AC5F17D7CA94FAB1E8D7A /* StringNormalization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringNormalization.swift; sourceTree = ""; }; + 231FF7CBB5F621B66F2AB8A5 /* FDADemoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FDADemoView.swift; sourceTree = ""; }; + 23486A54A82EE3865B784D1C /* AppInfoFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppInfoFetcher.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 = ""; }; + 2D3F9FEDC493A8E49E910F67 /* AppBundleDragHandle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppBundleDragHandle.swift; sourceTree = ""; }; + 311078221878708524283765 /* PureMac.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PureMac.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 340D303C60FF878B019056B6 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = ""; }; + 3667D46D8E2004EB4D73835A /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = ""; }; + 3A8D39C3C250F26302EC45AB /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + 3D4FD34378988D430A582ED0 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = ""; }; + 40A3F22FCEF534DE4A2CAD0E /* Haptics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Haptics.swift; sourceTree = ""; }; + 424F7B28624C271620E13BBC /* EmptyStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyStateView.swift; sourceTree = ""; }; + 46660271CFF167AB0FE7371D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + 491771F923C93FD61A263893 /* AppLanguagePreferencesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLanguagePreferencesTests.swift; sourceTree = ""; }; + 4AE790496C936424EF98320A /* OrphanSafetyPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrphanSafetyPolicy.swift; sourceTree = ""; }; + 5664D2BDAEAA9AE3A53DB364 /* PureMac.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = PureMac.entitlements; sourceTree = ""; }; + 5785762276FB5E3209C6DE4D /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = ""; }; + 5A5C80929EE4A430272674BC /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = ""; }; + 607B9A8C7B34D6A7DCF4FFE2 /* PureMacTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = PureMacTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 60E6BC61614B27C065BB18C9 /* AppListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppListView.swift; sourceTree = ""; }; + 63581B70F9B10231964E3602 /* PureMacApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PureMacApp.swift; sourceTree = ""; }; + 752603285F7DBBA12BB3AA91 /* AppStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateTests.swift; sourceTree = ""; }; + 77D3D9A9BC52839E6D0A22BC /* Locations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Locations.swift; sourceTree = ""; }; + 798B80977D14647A5691B0A0 /* AppFilesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppFilesView.swift; sourceTree = ""; }; + 82BB0904726B38DEB141BECA /* AppLanguage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLanguage.swift; sourceTree = ""; }; + 8CE522B406791BCE905BC55B /* FileSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileSize.swift; sourceTree = ""; }; + 8D5E63D733D1A3BDEDF4DCA5 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = ""; }; + 913E3064AA9BD7BE94A315CF /* MenuBarController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarController.swift; sourceTree = ""; }; + 92259B3E9F4468865F15DEEC /* AppearancePill.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearancePill.swift; sourceTree = ""; }; + 9F04B811BB0012F6D2F07F91 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; + 9F510F232341EE18F11DC934 /* MainWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainWindow.swift; sourceTree = ""; }; + A27DB5A70FA829B6C3ED0AC3 /* MenuBarMonitorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarMonitorView.swift; sourceTree = ""; }; + A711CDF5285F68775D9B5513 /* ScanEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanEngine.swift; sourceTree = ""; }; + AC0FEE7141871ED5F9E36121 /* AppPathFinder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppPathFinder.swift; sourceTree = ""; }; + B2CD0028599BE178B96F18A6 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Localizable.strings; sourceTree = ""; }; + B2EA41E1096FA8E3B916AD13 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + B71E8F62DB76D85F41F9A2E9 /* OrphanListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrphanListView.swift; sourceTree = ""; }; + C5ADDC35F31E40780FB5D017 /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = ""; }; + CB94E06E145558123BB5BFB3 /* Conditions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Conditions.swift; sourceTree = ""; }; + CED1B71D5F9510582E869CFD /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; + DB798781B99987F55D77E2E3 /* SystemMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemMonitor.swift; sourceTree = ""; }; + E866A1541D289C69144A5E62 /* FullDiskAccessManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullDiskAccessManager.swift; sourceTree = ""; }; + EEF15CB1B8EFCA78EF491824 /* ScanError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanError.swift; sourceTree = ""; }; + F0599AA604B0D6BA4F957537 /* ConfettiView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfettiView.swift; sourceTree = ""; }; + F31F91226CDCFBB8E303B7DA /* AppConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppConstants.swift; sourceTree = ""; }; + F661A0F64CF93E482CB1728F /* DashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardView.swift; sourceTree = ""; }; + F9DCBD97CC88D77AE8DB01A4 /* PermissionCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionCoordinator.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + F8C2F3E176B50B537CC3381D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 50304378D99F2E9E48B642AF /* Sparkle in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 13CF0676D0E93925F46C13AA = { + isa = PBXGroup; + children = ( + E383F69184F4552E5A41D010 /* PureMac */, + 261FB8D961FEC76F15EA523A /* PureMacTests */, + 4562CA9E5625FA4EEEFECB6D /* Products */, + ); + sourceTree = ""; + }; + 261FB8D961FEC76F15EA523A /* PureMacTests */ = { + isa = PBXGroup; + children = ( + 491771F923C93FD61A263893 /* AppLanguagePreferencesTests.swift */, + 752603285F7DBBA12BB3AA91 /* AppStateTests.swift */, + 155CE1B8CDCCCA6F9FD028C1 /* LocalizationFilesTests.swift */, + ); + path = PureMacTests; + sourceTree = ""; + }; + 3CF46713F75B81F0F86D1C6F /* Models */ = { + isa = PBXGroup; + children = ( + 82BB0904726B38DEB141BECA /* AppLanguage.swift */, + 3667D46D8E2004EB4D73835A /* Models.swift */, + EEF15CB1B8EFCA78EF491824 /* ScanError.swift */, + ); + path = Models; + sourceTree = ""; + }; + 4562CA9E5625FA4EEEFECB6D /* Products */ = { + isa = PBXGroup; + children = ( + 311078221878708524283765 /* PureMac.app */, + 607B9A8C7B34D6A7DCF4FFE2 /* PureMacTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 45B485C4ACB5B08C1B87917F /* Views */ = { + isa = PBXGroup; + children = ( + 10B0AF194677EAC1D5568785 /* CategoryDetailView.swift */, + F661A0F64CF93E482CB1728F /* DashboardView.swift */, + 9F510F232341EE18F11DC934 /* MainWindow.swift */, + 3D4FD34378988D430A582ED0 /* OnboardingView.swift */, + DAD2EC78EB98016ADA27D114 /* Apps */, + 8AE26715B5748307483A1A5E /* Components */, + 9D3C2DC382F5D4B77DFD218B /* Orphans */, + 7BC68AB045DA6E5299FD3CB6 /* Settings */, + ); + path = Views; + sourceTree = ""; + }; + 6184B2EC3D01E6E95633406E /* Services */ = { + isa = PBXGroup; + children = ( + 2A8DE5D45BA19E2670B57DC5 /* CleaningEngine.swift */, + E866A1541D289C69144A5E62 /* FullDiskAccessManager.swift */, + 40A3F22FCEF534DE4A2CAD0E /* Haptics.swift */, + 913E3064AA9BD7BE94A315CF /* MenuBarController.swift */, + F9DCBD97CC88D77AE8DB01A4 /* PermissionCoordinator.swift */, + A711CDF5285F68775D9B5513 /* ScanEngine.swift */, + 28181F034530331A550C6A3D /* SchedulerService.swift */, + DB798781B99987F55D77E2E3 /* SystemMonitor.swift */, + 022D0EAEE2B6E54069FC2CBE /* UpdateService.swift */, + ); + path = Services; + sourceTree = ""; + }; + 7BC68AB045DA6E5299FD3CB6 /* Settings */ = { + isa = PBXGroup; + children = ( + 3A8D39C3C250F26302EC45AB /* SettingsView.swift */, + ); + path = Settings; + sourceTree = ""; + }; + 7C1729F88C0E5563E1A3DB40 /* Extensions */ = { + isa = PBXGroup; + children = ( + C5ADDC35F31E40780FB5D017 /* Theme.swift */, + ); + path = Extensions; + sourceTree = ""; + }; + 8AE26715B5748307483A1A5E /* Components */ = { + isa = PBXGroup; + children = ( + 2D3F9FEDC493A8E49E910F67 /* AppBundleDragHandle.swift */, + 92259B3E9F4468865F15DEEC /* AppearancePill.swift */, + 1AB003A7751F05727DBFD1A5 /* AppTheme.swift */, + F0599AA604B0D6BA4F957537 /* ConfettiView.swift */, + 08C60E04A0CA3F227816DB63 /* DashboardCharts.swift */, + 424F7B28624C271620E13BBC /* EmptyStateView.swift */, + 231FF7CBB5F621B66F2AB8A5 /* FDADemoView.swift */, + A27DB5A70FA829B6C3ED0AC3 /* MenuBarMonitorView.swift */, + 069AAE87E3F9EDE5593AD8B3 /* PermissionSheet.swift */, + ); + path = Components; + sourceTree = ""; + }; + 9D3C2DC382F5D4B77DFD218B /* Orphans */ = { + isa = PBXGroup; + children = ( + B71E8F62DB76D85F41F9A2E9 /* OrphanListView.swift */, + ); + path = Orphans; + sourceTree = ""; + }; + A8F1C251567E5321E1CA2484 /* ViewModels */ = { + isa = PBXGroup; + children = ( + CED1B71D5F9510582E869CFD /* AppState.swift */, + ); + path = ViewModels; + sourceTree = ""; + }; + D4333B07691BD85CAE0E5B15 /* Core */ = { + isa = PBXGroup; + children = ( + F31F91226CDCFBB8E303B7DA /* AppConstants.swift */, + ); + path = Core; + sourceTree = ""; + }; + DAD2EC78EB98016ADA27D114 /* Apps */ = { + isa = PBXGroup; + children = ( + 798B80977D14647A5691B0A0 /* AppFilesView.swift */, + 60E6BC61614B27C065BB18C9 /* AppListView.swift */, + ); + path = Apps; + sourceTree = ""; + }; + E05E733EFBC4EFDA12BEC43B /* Utilities */ = { + isa = PBXGroup; + children = ( + 01B2C5F66B6D812572BD4F05 /* CLI.swift */, + 8CE522B406791BCE905BC55B /* FileSize.swift */, + 5785762276FB5E3209C6DE4D /* Logger.swift */, + 4AE790496C936424EF98320A /* OrphanSafetyPolicy.swift */, + ); + path = Utilities; + sourceTree = ""; + }; + E383F69184F4552E5A41D010 /* PureMac */ = { + isa = PBXGroup; + children = ( + B2EA41E1096FA8E3B916AD13 /* Assets.xcassets */, + 46660271CFF167AB0FE7371D /* Info.plist */, + 5664D2BDAEAA9AE3A53DB364 /* PureMac.entitlements */, + 63581B70F9B10231964E3602 /* PureMacApp.swift */, + D4333B07691BD85CAE0E5B15 /* Core */, + 7C1729F88C0E5563E1A3DB40 /* Extensions */, + 241E0895B09C71AB423B2F9E /* Localizable.strings */, + F283C00EB52AB140F61500A3 /* Logic */, + 3CF46713F75B81F0F86D1C6F /* Models */, + 6184B2EC3D01E6E95633406E /* Services */, + A8F1C251567E5321E1CA2484 /* ViewModels */, + 45B485C4ACB5B08C1B87917F /* Views */, + ); + path = PureMac; + sourceTree = ""; + }; + EC11280F62B0574E3699EFA6 /* Scanning */ = { + isa = PBXGroup; + children = ( + 23486A54A82EE3865B784D1C /* AppInfoFetcher.swift */, + AC0FEE7141871ED5F9E36121 /* AppPathFinder.swift */, + CB94E06E145558123BB5BFB3 /* Conditions.swift */, + 77D3D9A9BC52839E6D0A22BC /* Locations.swift */, + 1D3AC5F17D7CA94FAB1E8D7A /* StringNormalization.swift */, + ); + path = Scanning; + sourceTree = ""; + }; + F283C00EB52AB140F61500A3 /* Logic */ = { + isa = PBXGroup; + children = ( + EC11280F62B0574E3699EFA6 /* Scanning */, + E05E733EFBC4EFDA12BEC43B /* Utilities */, + ); + path = Logic; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + B19E38DEAA0144A77120F39C /* PureMac */ = { + isa = PBXNativeTarget; + buildConfigurationList = 2754925F163D15C85EDF494D /* Build configuration list for PBXNativeTarget "PureMac" */; + buildPhases = ( + 63C589367B714FC06B0519E5 /* Sources */, + 3975A867E04014FA565E9F46 /* Resources */, + F8C2F3E176B50B537CC3381D /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = PureMac; + packageProductDependencies = ( + 9CC8F66E27BE52A6639E904B /* Sparkle */, + ); + productName = PureMac; + 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 */ + 37BE87544E2EC9F6FF1CD280 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1640; + TargetAttributes = { + B19E38DEAA0144A77120F39C = { + DevelopmentTeam = H3WXHVTP97; + ProvisioningStyle = Automatic; + }; + FBA8B0DE95746207A043B802 = { + DevelopmentTeam = H3WXHVTP97; + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = 2ABAFAE07AA42044AE58F688 /* Build configuration list for PBXProject "PureMac" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + ar, + en, + es, + ja, + "pt-BR", + "zh-Hans", + "zh-Hant", + ); + mainGroup = 13CF0676D0E93925F46C13AA; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + 0AEFFB8005A58EEF008401F1 /* XCRemoteSwiftPackageReference "Sparkle" */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = 4562CA9E5625FA4EEEFECB6D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + B19E38DEAA0144A77120F39C /* PureMac */, + FBA8B0DE95746207A043B802 /* PureMacTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 3975A867E04014FA565E9F46 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + DDD6BA35DBF32E7A6B5F6F8B /* Assets.xcassets in Resources */, + 013EF7B96BF046D4DB38FBC5 /* Localizable.strings in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 63C589367B714FC06B0519E5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + DC32253D26D0E29762006DA0 /* AppBundleDragHandle.swift in Sources */, + 4F754D89F4CE5142BE384062 /* AppConstants.swift in Sources */, + 1B0D043102FDB245312A5147 /* AppFilesView.swift in Sources */, + F1F506C146E17D6CEC4A12F3 /* AppInfoFetcher.swift in Sources */, + E726702EABBE56155861DBA9 /* AppLanguage.swift in Sources */, + 75B5F0401D37F2872B1AD85A /* AppListView.swift in Sources */, + 9BB5AAA574AFED6C27A3F8E2 /* AppPathFinder.swift in Sources */, + 9AA80E035DF7B33F6EE118DF /* AppState.swift in Sources */, + 535B23C0108C06475215B8E8 /* AppTheme.swift in Sources */, + 95278ABDCF5F4D6AC60503B1 /* AppearancePill.swift in Sources */, + F2FA881A4B2209CC2A6342FB /* CLI.swift in Sources */, + B52938BBD11842631314543D /* CategoryDetailView.swift in Sources */, + 48D1431A7C99C19EEBDB056B /* CleaningEngine.swift in Sources */, + 8947F0CE448791BD50EECF46 /* Conditions.swift in Sources */, + ABDDD4F35102A39CC1A2C325 /* ConfettiView.swift in Sources */, + 8B541441926847072DC5B75B /* DashboardCharts.swift in Sources */, + A2AE68CC75CB72D6B10CBDF5 /* DashboardView.swift in Sources */, + 76B132F9C499225D33E0D075 /* EmptyStateView.swift in Sources */, + 52F11962555C639F0EECADD6 /* FDADemoView.swift in Sources */, + B51E5A95BB49A6C0F5273E21 /* FileSize.swift in Sources */, + 2253F11BDF561B617439C96B /* FullDiskAccessManager.swift in Sources */, + A549D8A39A9E5E70D91B90A6 /* Haptics.swift in Sources */, + E60B0A2C5D0A6CAE35BF4DFB /* Locations.swift in Sources */, + DDDA5879006CB97D5D7BDD87 /* Logger.swift in Sources */, + CDCDFEBA39A3290101F86AC6 /* MainWindow.swift in Sources */, + 9E3639F9EFCB2A22F6747894 /* MenuBarController.swift in Sources */, + 91751CF033E1541BFD39B12C /* MenuBarMonitorView.swift in Sources */, + 826A750D2D7EC14C2AE306A3 /* Models.swift in Sources */, + BC6C800216343438413349A3 /* OnboardingView.swift in Sources */, + FDA30FE6349CA01C8D859F04 /* OrphanListView.swift in Sources */, + A9C3A1F643C26930F442E729 /* OrphanSafetyPolicy.swift in Sources */, + 6E72022A661CBB41331A442C /* PermissionCoordinator.swift in Sources */, + 61D285E96DED76FDCD54570F /* PermissionSheet.swift in Sources */, + EDEF28CAD23E936FBED1783B /* PureMacApp.swift in Sources */, + D9445C2641A8637B65DA5ACE /* ScanEngine.swift in Sources */, + D50EB059E741011EB2523731 /* ScanError.swift in Sources */, + 27F449EDD1B082FE11FEC9DF /* SchedulerService.swift in Sources */, + 93743B036059418560D876E6 /* SettingsView.swift in Sources */, + 47C5ECD49C4DD75F271DB6CE /* StringNormalization.swift in Sources */, + DBFD73E3D9BDA74EC1CA56AB /* SystemMonitor.swift in Sources */, + 340E424F759ACCDE7372F99F /* Theme.swift in Sources */, + 41B27CACD7C6C7471EFD03F9 /* UpdateService.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A6847DD7D5552F4AA6B17BA8 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 59B3096E2AE8ED0397B16798 /* AppLanguagePreferencesTests.swift in Sources */, + E2403D82F00D749C9AD4A6D4 /* AppStateTests.swift in Sources */, + CFA64F54CDBF8A4765E0068D /* LocalizationFilesTests.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 */ + 241E0895B09C71AB423B2F9E /* Localizable.strings */ = { + isa = PBXVariantGroup; + children = ( + B2CD0028599BE178B96F18A6 /* ar */, + 9F04B811BB0012F6D2F07F91 /* en */, + 340D303C60FF878B019056B6 /* es */, + 5A5C80929EE4A430272674BC /* ja */, + 8D5E63D733D1A3BDEDF4DCA5 /* pt-BR */, + 2641C6376DD6F5889F35510E /* zh-Hans */, + 02E502E2B5C6AECC76E5CFEF /* zh-Hant */, + ); + name = Localizable.strings; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 0794DC5454CB748EC1374422 /* Debug */ = { + 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; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_ENTITLEMENTS = PureMac/PureMac.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 18; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = H3WXHVTP97; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = PureMac/Info.plist; + MACOSX_DEPLOYMENT_TARGET = 13.0; + MARKETING_VERSION = 2.8.3; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = NO; + PRODUCT_BUNDLE_IDENTIFIER = com.puremac.app; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.9; + }; + name = Debug; + }; + 62DEA95205CFC6624F5AE4DA /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGNING_ALLOWED = NO; + CODE_SIGNING_REQUIRED = NO; + 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)"; + CODE_SIGNING_ALLOWED = NO; + CODE_SIGNING_REQUIRED = NO; + COMBINE_HIDPI_IMAGES = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@loader_path/../Frameworks", + ); + SDKROOT = macosx; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/PureMac.app/Contents/MacOS/PureMac"; + }; + name = Debug; + }; + 8632219D8F7DC9932C9DCC9F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGNING_ALLOWED = NO; + CODE_SIGNING_REQUIRED = NO; + COMBINE_HIDPI_IMAGES = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@loader_path/../Frameworks", + ); + 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; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_ENTITLEMENTS = PureMac/PureMac.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 18; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = H3WXHVTP97; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = PureMac/Info.plist; + MACOSX_DEPLOYMENT_TARGET = 13.0; + MARKETING_VERSION = 2.8.3; + 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; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.9; + }; + name = Release; + }; + F079CEBA12A56241A5D0D3BC /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + 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 = Release; + }; +/* 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 = ( + 62DEA95205CFC6624F5AE4DA /* Debug */, + F079CEBA12A56241A5D0D3BC /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 2ABAFAE07AA42044AE58F688 /* Build configuration list for PBXProject "PureMac" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 0794DC5454CB748EC1374422 /* Debug */, + D1B9D379DD6CC0DA24E58BCF /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 0AEFFB8005A58EEF008401F1 /* XCRemoteSwiftPackageReference "Sparkle" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/sparkle-project/Sparkle"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.0.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 9CC8F66E27BE52A6639E904B /* Sparkle */ = { + isa = XCSwiftPackageProductDependency; + package = 0AEFFB8005A58EEF008401F1 /* XCRemoteSwiftPackageReference "Sparkle" */; + productName = Sparkle; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 37BE87544E2EC9F6FF1CD280 /* Project object */; +} diff --git a/PureMac.xcodeproj/xcshareddata/xcschemes/PureMac.xcscheme b/PureMac.xcodeproj/xcshareddata/xcschemes/PureMac.xcscheme new file mode 100644 index 0000000..564f560 --- /dev/null +++ b/PureMac.xcodeproj/xcshareddata/xcschemes/PureMac.xcscheme @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/PureMac/Assets.xcassets/AccentColor.colorset/Contents.json b/PureMac/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..8603fbe --- /dev/null +++ b/PureMac/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.945", + "green" : "0.400", + "red" : "0.388" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/PureMac/Assets.xcassets/AppIcon.appiconset/Contents.json b/PureMac/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..4ab5e2f --- /dev/null +++ b/PureMac/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "filename" : "icon_16.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" + }, + { + "filename" : "icon_16@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" + }, + { + "filename" : "icon_32.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" + }, + { + "filename" : "icon_32@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "filename" : "icon_128.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" + }, + { + "filename" : "icon_128@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" + }, + { + "filename" : "icon_256.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" + }, + { + "filename" : "icon_256@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "filename" : "icon_512.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "filename" : "icon_512@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/PureMac/Assets.xcassets/AppIcon.appiconset/icon_128.png b/PureMac/Assets.xcassets/AppIcon.appiconset/icon_128.png new file mode 100644 index 0000000..3198fb7 Binary files /dev/null and b/PureMac/Assets.xcassets/AppIcon.appiconset/icon_128.png differ diff --git a/PureMac/Assets.xcassets/AppIcon.appiconset/icon_128@2x.png b/PureMac/Assets.xcassets/AppIcon.appiconset/icon_128@2x.png new file mode 100644 index 0000000..9f9d3ea Binary files /dev/null and b/PureMac/Assets.xcassets/AppIcon.appiconset/icon_128@2x.png differ diff --git a/PureMac/Assets.xcassets/AppIcon.appiconset/icon_16.png b/PureMac/Assets.xcassets/AppIcon.appiconset/icon_16.png new file mode 100644 index 0000000..5385353 Binary files /dev/null and b/PureMac/Assets.xcassets/AppIcon.appiconset/icon_16.png differ diff --git a/PureMac/Assets.xcassets/AppIcon.appiconset/icon_16@2x.png b/PureMac/Assets.xcassets/AppIcon.appiconset/icon_16@2x.png new file mode 100644 index 0000000..a020ab0 Binary files /dev/null and b/PureMac/Assets.xcassets/AppIcon.appiconset/icon_16@2x.png differ diff --git a/PureMac/Assets.xcassets/AppIcon.appiconset/icon_256.png b/PureMac/Assets.xcassets/AppIcon.appiconset/icon_256.png new file mode 100644 index 0000000..9f9d3ea Binary files /dev/null and b/PureMac/Assets.xcassets/AppIcon.appiconset/icon_256.png differ diff --git a/PureMac/Assets.xcassets/AppIcon.appiconset/icon_256@2x.png b/PureMac/Assets.xcassets/AppIcon.appiconset/icon_256@2x.png new file mode 100644 index 0000000..ec9c9db Binary files /dev/null and b/PureMac/Assets.xcassets/AppIcon.appiconset/icon_256@2x.png differ diff --git a/PureMac/Assets.xcassets/AppIcon.appiconset/icon_32.png b/PureMac/Assets.xcassets/AppIcon.appiconset/icon_32.png new file mode 100644 index 0000000..a020ab0 Binary files /dev/null and b/PureMac/Assets.xcassets/AppIcon.appiconset/icon_32.png differ diff --git a/PureMac/Assets.xcassets/AppIcon.appiconset/icon_32@2x.png b/PureMac/Assets.xcassets/AppIcon.appiconset/icon_32@2x.png new file mode 100644 index 0000000..d8da71c Binary files /dev/null and b/PureMac/Assets.xcassets/AppIcon.appiconset/icon_32@2x.png differ diff --git a/PureMac/Assets.xcassets/AppIcon.appiconset/icon_512.png b/PureMac/Assets.xcassets/AppIcon.appiconset/icon_512.png new file mode 100644 index 0000000..ec9c9db Binary files /dev/null and b/PureMac/Assets.xcassets/AppIcon.appiconset/icon_512.png differ diff --git a/PureMac/Assets.xcassets/AppIcon.appiconset/icon_512@2x.png b/PureMac/Assets.xcassets/AppIcon.appiconset/icon_512@2x.png new file mode 100644 index 0000000..f99dce0 Binary files /dev/null and b/PureMac/Assets.xcassets/AppIcon.appiconset/icon_512@2x.png differ diff --git a/PureMac/Assets.xcassets/Contents.json b/PureMac/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/PureMac/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/PureMac/Core/AppConstants.swift b/PureMac/Core/AppConstants.swift new file mode 100644 index 0000000..85c26cb --- /dev/null +++ b/PureMac/Core/AppConstants.swift @@ -0,0 +1,14 @@ +// +// AppConstants.swift +// PureMac +// +// Created by Theo Sementa on 12/04/2026. +// + +import Foundation + +struct AppConstants { + + static let appVersion: String = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0" + +} diff --git a/PureMac/Extensions/Theme.swift b/PureMac/Extensions/Theme.swift new file mode 100644 index 0000000..c3525cf --- /dev/null +++ b/PureMac/Extensions/Theme.swift @@ -0,0 +1,12 @@ +import SwiftUI + +// PureMac uses system colors exclusively. +// The app respects the user's system appearance (light/dark/accent color). + +extension View { + func cardBackground() -> some View { + self + .padding() + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 8)) + } +} diff --git a/PureMac/Info.plist b/PureMac/Info.plist new file mode 100644 index 0000000..79ca3a0 --- /dev/null +++ b/PureMac/Info.plist @@ -0,0 +1,60 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleName + PureMac + CFBundleDisplayName + PureMac + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + LSApplicationCategoryType + public.app-category.utilities + NSHumanReadableCopyright + Copyright 2026. MIT License. + NSDesktopFolderUsageDescription + PureMac needs access to your Desktop to find junk and orphaned files. + NSDocumentsFolderUsageDescription + PureMac needs access to your Documents to find junk and orphaned files. + NSDownloadsFolderUsageDescription + PureMac needs access to your Downloads to find junk and orphaned files. + NSRemovableVolumesUsageDescription + PureMac needs access to removable volumes to scan for junk files. + NSSystemAdministrationUsageDescription + PureMac needs system administration access to clean caches and uninstall apps completely. + NSAppleEventsUsageDescription + PureMac uses Apple Events to interact with Finder when revealing files. + NSServices + + + NSMenuItem + + default + Uninstall with PureMac + + NSMessage + uninstallApp + NSPortName + PureMac + NSRequiredContext + + NSSendFileTypes + + com.apple.application-bundle + + + + + diff --git a/PureMac/Logic/Scanning/AppInfoFetcher.swift b/PureMac/Logic/Scanning/AppInfoFetcher.swift new file mode 100644 index 0000000..56f98db --- /dev/null +++ b/PureMac/Logic/Scanning/AppInfoFetcher.swift @@ -0,0 +1,138 @@ +import Foundation +import AppKit + +struct InstalledApp: Identifiable, Hashable { + let id: UUID + let appName: String + let bundleIdentifier: String + let path: URL + let icon: NSImage + let size: Int64 + + var formattedSize: String { + ByteCountFormatter.string(fromByteCount: size, countStyle: .file) + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + static func == (lhs: InstalledApp, rhs: InstalledApp) -> Bool { + lhs.id == rhs.id + } +} + +final class AppInfoFetcher { + static let shared = AppInfoFetcher() + private let fileManager = FileManager.default + + private static let protectedBundleIDs: Set = [ + "com.apple.Safari", "com.apple.finder", "com.apple.AppStore", + "com.apple.systempreferences", "com.apple.Terminal", + "com.apple.ActivityMonitor", "com.apple.dt.Xcode", + "com.apple.mail", "com.apple.iCal", "com.apple.AddressBook", + "com.apple.Preview", "com.apple.TextEdit", "com.apple.calculator", + "com.apple.MobileSMS", "com.apple.FaceTime", "com.apple.Music", + "com.apple.TV", "com.apple.Podcasts", "com.apple.News", + "com.apple.Maps", "com.apple.Photos", "com.apple.Notes", + "com.apple.reminders", "com.apple.Stocks", "com.apple.Home", + "com.apple.weather", "com.apple.clock", "com.apple.Passwords", + ] + + private init() {} + + func fetchInstalledApps() -> [InstalledApp] { + var apps: [InstalledApp] = [] + var seenBundleIDs: Set = [] + + // `/Users/Shared` is where game launchers (Riot Client, some Blizzard + // and Epic helpers) drop their `.app` bundles instead of /Applications, + // so it has to be scanned for those to show up in the uninstaller + // (issue #123). It can also hold multi-gigabyte game data trees, so it + // is depth-bounded — the launcher bundles live within the first few + // levels (e.g. /Users/Shared/Riot Games/Riot Client.app at depth 2); + // the bound is kept generous (6) so a vendor that nests one or two + // directories deeper is still found, while the multi-gigabyte asset + // trees below that are not walked. + let searchRoots: [(path: String, maxDepth: Int)] = [ + ("/Applications", 8), + ("\(home)/Applications", 8), + ("/System/Applications", 8), + ("/Users/Shared", 6), + ] + + for (searchPath, maxDepth) in searchRoots { + guard let enumerator = fileManager.enumerator( + at: URL(fileURLWithPath: searchPath), + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles, .skipsPackageDescendants] + ) else { continue } + + for case let url as URL in enumerator { + guard url.pathExtension == "app" else { + // Stop descending once past the depth bound so a deep data + // tree (e.g. a game's assets) doesn't get fully walked. + if enumerator.level >= maxDepth { + enumerator.skipDescendants() + } + continue + } + + // Skip subdirectories inside .app bundles + enumerator.skipDescendants() + + // Skip system/protected apps + if url.path.hasPrefix("/System") { continue } + + guard let app = loadAppInfo(from: url), + !seenBundleIDs.contains(app.bundleIdentifier), + !Self.protectedBundleIDs.contains(app.bundleIdentifier) else { continue } + + seenBundleIDs.insert(app.bundleIdentifier) + apps.append(app) + } + } + + return apps.sorted { $0.appName.localizedCaseInsensitiveCompare($1.appName) == .orderedAscending } + } + + /// Build an `InstalledApp` from a single bundle URL. Used by the Finder + /// Services handler ("Uninstall with PureMac") to resolve a right-clicked + /// .app into the uninstaller without re-scanning every app. Enforces the + /// same protections as the full scan: no /System apps, and no protected + /// Apple bundle IDs (Safari, Mail, Xcode, App Store, …) — so a right-click + /// can never route a system app into the uninstaller. + func fetchApp(at url: URL) -> InstalledApp? { + guard url.pathExtension == "app", !url.path.hasPrefix("/System") else { return nil } + guard let app = loadAppInfo(from: url), + !Self.protectedBundleIDs.contains(app.bundleIdentifier) else { return nil } + return app + } + + private func loadAppInfo(from url: URL) -> InstalledApp? { + guard let bundle = Bundle(url: url) else { return nil } + + let bundleID = bundle.bundleIdentifier ?? url.deletingPathExtension().lastPathComponent + let appName = (bundle.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String) + ?? (bundle.object(forInfoDictionaryKey: "CFBundleName") as? String) + ?? url.deletingPathExtension().lastPathComponent + + let icon = NSWorkspace.shared.icon(forFile: url.path) + icon.size = NSSize(width: 32, height: 32) + + let size = appSize(at: url) + + return InstalledApp( + id: UUID(), + appName: appName, + bundleIdentifier: bundleID, + path: url, + icon: icon, + size: size + ) + } + + private func appSize(at url: URL) -> Int64 { + FileSizeCalculator.size(of: url) ?? 0 + } +} diff --git a/PureMac/Logic/Scanning/AppPathFinder.swift b/PureMac/Logic/Scanning/AppPathFinder.swift new file mode 100644 index 0000000..b058074 --- /dev/null +++ b/PureMac/Logic/Scanning/AppPathFinder.swift @@ -0,0 +1,393 @@ +// +// AppPathFinder.swift +// PureMac +// +// Heuristic file discovery engine that locates all filesystem artifacts +// belonging to a given macOS application. Uses multi-level matching +// against bundle identifiers, app names, entitlements, and team IDs +// with configurable sensitivity. +// + +import Foundation +import AppKit + +class AppPathFinder { + + // MARK: - Types + + struct AppInfo { + let appName: String + let bundleIdentifier: String + let path: URL + let entitlements: [String]? + let teamIdentifier: String? + } + + enum Sensitivity { + case strict // Exact bundle ID + exact name match only + case enhanced // + partial matching, bundle components, stripped version + case deep // + company name, entitlements, team ID + } + + // MARK: - Properties + + private let appInfo: AppInfo + private let locations: Locations + private let sensitivity: Sensitivity + private var collectionSet: Set = [] + private let collectionQueue = DispatchQueue(label: "com.puremac.pathfinder.collection") + + // Pre-computed cached identifiers (computed once in init, used in hot loop) + private let normalizedBundleID: String + private let bundleLastTwo: String + private let normalizedAppName: String + private let appNameLettersOnly: String + private let pathComponentName: String + private let companyName: String? + private let baseBundleID: String? + private let strippedAppName: String? + private let normalizedEntitlements: [String] + private let normalizedTeamID: String? + + // MARK: - Initialization + + init(appInfo: AppInfo, locations: Locations, sensitivity: Sensitivity = .enhanced) { + self.appInfo = appInfo + self.locations = locations + self.sensitivity = sensitivity + + self.normalizedBundleID = appInfo.bundleIdentifier.normalizedForMatching() + self.bundleLastTwo = appInfo.bundleIdentifier.bundleLastTwoComponents + self.normalizedAppName = appInfo.appName.normalizedForMatching() + self.appNameLettersOnly = appInfo.appName.lettersOnly + self.pathComponentName = appInfo.path.lastPathComponent + .replacingOccurrences(of: ".app", with: "") + .normalizedForMatching() + self.companyName = appInfo.bundleIdentifier.bundleCompanyName + self.baseBundleID = appInfo.bundleIdentifier.baseBundleIdentifier?.normalizedForMatching() + + let stripped = appInfo.appName.strippingTrailingVersion().normalizedForMatching() + self.strippedAppName = (stripped != normalizedAppName && !stripped.isEmpty) ? stripped : nil + + self.normalizedEntitlements = appInfo.entitlements?.compactMap { e in + let n = e.normalizedForMatching() + return n.isEmpty ? nil : n + } ?? [] + + self.normalizedTeamID = appInfo.teamIdentifier?.normalizedForMatching() + } + + // MARK: - Public API + + /// Find all files related to this app synchronously. + func findPaths() -> Set { + collectionSet.insert(appInfo.path) + + for location in locations.appSearch.paths { + let isLibRoot = isLibraryDirectory(location) + let maxDepth = isLibRoot ? 2 : 1 + processLocation(location, currentDepth: 0, maxDepth: maxDepth, isLibraryRootSearch: isLibRoot) + } + + let containers = discoverContainers() + collectionSet.formUnion(containers) + + applyConditions() + + return filterSubpaths(collectionSet) + } + + /// Find all files related to this app with parallel location processing. + func findPathsAsync(completion: @escaping (Set) -> Void) { + collectionSet.insert(appInfo.path) + + let group = DispatchGroup() + for location in locations.appSearch.paths { + group.enter() + DispatchQueue.global(qos: .userInitiated).async { + let isLibRoot = self.isLibraryDirectory(location) + let maxDepth = isLibRoot ? 2 : 1 + self.processLocation(location, currentDepth: 0, maxDepth: maxDepth, isLibraryRootSearch: isLibRoot) + group.leave() + } + } + + group.notify(queue: .global(qos: .userInitiated)) { + let containers = self.discoverContainers() + self.collectionQueue.sync { + self.collectionSet.formUnion(containers) + } + self.applyConditions() + let result = self.filterSubpaths(self.collectionSet) + DispatchQueue.main.async { + completion(result) + } + } + } + + // MARK: - Location Processing + + private func processLocation(_ location: String, currentDepth: Int, maxDepth: Int, isLibraryRootSearch: Bool) { + guard let contents = try? FileManager.default.contentsOfDirectory(atPath: location) else { + return + } + + var localResults: [URL] = [] + var subdirs: [URL] = [] + + for item in contents { + let itemURL = URL(fileURLWithPath: location).appendingPathComponent(item) + let normalizedName: String + + if itemURL.hasDirectoryPath || itemURL.pathExtension.isEmpty { + normalizedName = item.normalizedForMatching() + } else { + normalizedName = (item as NSString).deletingPathExtension.normalizedForMatching() + } + + var isDir: ObjCBool = false + guard FileManager.default.fileExists(atPath: itemURL.path, isDirectory: &isDir) else { continue } + + if shouldSkipItem(normalizedName, at: itemURL) { continue } + + if matchesApp(normalizedName: normalizedName, itemURL: itemURL) { + let itemToAdd: URL + + // For depth-2 matches in Library root, add the parent directory when + // it is a vendor folder (not a standard Library subdirectory). + if isLibraryRootSearch && currentDepth == 2 { + let parent = itemURL.deletingLastPathComponent() + let parentName = parent.lastPathComponent + if !Locations.standardLibrarySubdirectories.contains(parentName) { + itemToAdd = parent + } else { + itemToAdd = itemURL + } + } else { + itemToAdd = itemURL + } + + localResults.append(itemToAdd) + } + + // Recurse into subdirectories up to maxDepth + if isDir.boolValue && currentDepth < maxDepth { + if isLibraryRootSearch && currentDepth == 0 { + if !skipDeepSearch.contains(itemURL.lastPathComponent) { + subdirs.append(itemURL) + } + } else { + subdirs.append(itemURL) + } + } + } + + collectionQueue.sync { + collectionSet.formUnion(localResults) + } + + for subdir in subdirs { + processLocation(subdir.path, currentDepth: currentDepth + 1, maxDepth: maxDepth, isLibraryRootSearch: isLibraryRootSearch) + } + } + + // MARK: - Matching Engine + + // Minimum token length required for a name-based match to pass. Prevents + // a malicious app named "s-s-h" (normalized "ssh", 3 chars) from + // short-name-bombing home dotfiles like ~/.ssh into the uninstall list. + private static let minMatchTokenLength = 5 + + /// Anchored check for whether `self.normalizedBundleID` belongs to the + /// family identified by `conditionBundleID`. Accepts exact equality, + /// ".child" extension, or "parent." suffix - rejects a bundle ID that + /// merely contains the condition string as a substring. This prevents + /// `com.evil.jetbrainsapp` from hijacking the `jetbrains` rule. + private func bundleIDMatchesCondition(_ conditionBundleID: String) -> Bool { + guard !conditionBundleID.isEmpty else { return false } + if normalizedBundleID == conditionBundleID { return true } + if normalizedBundleID.hasPrefix(conditionBundleID + ".") { return true } + if normalizedBundleID.hasSuffix("." + conditionBundleID) { return true } + return false + } + + /// Multi-level heuristic matcher. Returns true if the normalized filename + /// belongs to the target app at the current sensitivity level. + private func matchesApp(normalizedName: String, itemURL: URL) -> Bool { + // Per-app condition overrides take priority. Anchor the bundle ID + // check (see bundleIDMatchesCondition above). + for condition in appConditions { + guard bundleIDMatchesCondition(condition.bundleID) else { continue } + if condition.excludeTerms.contains(where: { normalizedName.contains($0) }) { + return false + } + if condition.includeTerms.contains(where: { normalizedName.contains($0) }) { + return true + } + } + + // Entitlement-based matching + for entitlement in normalizedEntitlements { + let match = sensitivity == .strict + ? normalizedName == entitlement + : normalizedName.contains(entitlement) + if match { return true } + } + + // Level 1: Full bundle identifier + let fullBundleMatch = normalizedName.contains(normalizedBundleID) + + // Level 2: App name + let isStrict = sensitivity == .strict + let minLen = AppPathFinder.minMatchTokenLength + let appNameMatch = normalizedAppName.count >= minLen && (isStrict + ? normalizedName == normalizedAppName + : normalizedName.contains(normalizedAppName)) + + // Level 3: Path component name (the .app directory name without extension) + let pathMatch = pathComponentName.count >= minLen && (isStrict + ? normalizedName == pathComponentName + : normalizedName.contains(pathComponentName)) + + // Level 4: Letters-only app name + let lettersMatch = appNameLettersOnly.count >= minLen && (isStrict + ? normalizedName == appNameLettersOnly + : normalizedName.contains(appNameLettersOnly)) + + if (normalizedBundleID.count >= 5 && fullBundleMatch) || appNameMatch || pathMatch || lettersMatch { + return true + } + + // Enhanced mode: additional partial matching strategies + if sensitivity != .strict { + // Level 5: Last two bundle ID components + if bundleLastTwo.count >= minLen, normalizedName.contains(bundleLastTwo) { return true } + + // Level 6: Base bundle ID (strips .helper/.agent/.daemon suffixes) + if let base = baseBundleID, base.count >= minLen, normalizedName.contains(base) { return true } + + // Level 7: Version-stripped app name + if let stripped = strippedAppName, stripped.count >= minLen, normalizedName.contains(stripped) { return true } + } + + // Deep mode: broadest matching heuristics + if sensitivity == .deep { + // Level 8: Company name extracted from bundle ID + if let company = companyName, company.count >= minLen, normalizedName.contains(company) { return true } + + // Level 9: Team identifier from code signature + if let teamID = normalizedTeamID, teamID.count >= minLen, normalizedName.contains(teamID) { return true } + } + + return false + } + + // MARK: - Skip Logic + + private func shouldSkipItem(_ normalizedName: String, at url: URL) -> Bool { + if collectionQueue.sync(execute: { collectionSet.contains(url) }) { return true } + + for skip in skipConditions { + for path in skip.skipPaths { + if url.path.hasPrefix(path) { return true } + } + if skip.skipPrefixes.contains(where: { normalizedName.hasPrefix($0) }) { + if !skip.allowPrefixes.contains(where: { normalizedName.hasPrefix($0) }) { + return true + } + } + } + return false + } + + // MARK: - Container Discovery + + /// Discovers sandboxed app containers that belong to this app by checking + /// both UUID-named containers (via metadata plist) and name-matched containers. + private func discoverContainers() -> [URL] { + var containers: [URL] = [] + + guard let containersPath = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask) + .first?.appendingPathComponent("Containers") else { return containers } + + guard let dirs = try? FileManager.default.contentsOfDirectory( + at: containersPath, includingPropertiesForKeys: nil, options: .skipsHiddenFiles + ) else { return containers } + + for dir in dirs { + let dirName = dir.lastPathComponent + + // UUID-named containers: read the metadata plist for the owning bundle ID + if dirName.count == 36 && dirName.contains("-") { + let metaPlist = dir.appendingPathComponent(".com.apple.containermanagerd.metadata.plist") + if let meta = NSDictionary(contentsOf: metaPlist), + let bundleID = meta["MCMMetadataIdentifier"] as? String, + bundleID == appInfo.bundleIdentifier { + containers.append(dir) + } + } + + // Named containers matching the bundle ID directly. Require the + // bundle ID itself to be at least 5 chars to avoid picking up a + // container owned by an app with a degenerate bundle identifier. + if normalizedBundleID.count >= 5, + dirName.normalizedForMatching() == normalizedBundleID { + containers.append(dir) + } + } + + return containers + } + + // MARK: - Condition Application + + /// Applies per-app force-include and force-exclude path overrides after + /// the main scan has completed. + private func applyConditions() { + for condition in appConditions { + guard bundleIDMatchesCondition(condition.bundleID) else { continue } + if let paths = condition.forceIncludePaths { + for path in paths { + if FileManager.default.fileExists(atPath: path.path) { + collectionSet.insert(path) + } + } + } + if let paths = condition.forceExcludePaths { + for path in paths { + collectionSet.remove(path) + } + } + } + } + + // MARK: - Helpers + + private func isLibraryDirectory(_ location: String) -> Bool { + location == "\(home)/Library" || location == "/Library" + } + + /// Removes child paths when a parent is already in the set, and discards + /// results that consist solely of a Trash item. + private func filterSubpaths(_ urls: Set) -> Set { + let sorted = urls.map { $0.standardizedFileURL }.sorted { $0.path < $1.path } + var filtered: [URL] = [] + + for url in sorted { + // Remove any existing entries that are children of this URL + filtered.removeAll { $0.path.hasPrefix(url.path + "/") } + + // Only add if this URL is not a child of an existing entry + if !filtered.contains(where: { url.path.hasPrefix($0.path + "/") }) { + filtered.append(url) + } + } + + // A single result pointing into the Trash is not meaningful + if filtered.count == 1, let first = filtered.first, first.path.contains(".Trash") { + return [] + } + + return Set(filtered) + } +} diff --git a/PureMac/Logic/Scanning/Conditions.swift b/PureMac/Logic/Scanning/Conditions.swift new file mode 100644 index 0000000..d952bed --- /dev/null +++ b/PureMac/Logic/Scanning/Conditions.swift @@ -0,0 +1,504 @@ +// +// Conditions.swift +// PureMac +// +// Per-app matching rules and system-level skip conditions for the heuristic scan engine. +// These define edge cases where bundle ID and app name matching alone is insufficient. +// + +import Foundation + +// MARK: - AppCondition + +/// Defines per-app overrides for the heuristic file matcher. +/// +/// Some apps use naming conventions that collide with other apps (e.g. "Xcode" vs "Xcodes"), +/// or scatter files under unexpected names. This struct lets the scanner include additional +/// search terms, exclude false positives, and force-include/exclude specific filesystem paths. +struct AppCondition: Codable { + let bundleID: String + let includeTerms: [String] + let excludeTerms: [String] + let forceIncludePaths: [URL]? + let forceExcludePaths: [URL]? + + init( + bundleID: String, + includeTerms: [String], + excludeTerms: [String], + forceIncludePaths: [String]? = nil, + forceExcludePaths: [String]? = nil + ) { + self.bundleID = bundleID.normalizedForMatching() + self.includeTerms = includeTerms.map { $0.normalizedForMatching() } + self.excludeTerms = excludeTerms.map { $0.normalizedForMatching() } + self.forceIncludePaths = forceIncludePaths?.compactMap { path in + let url = URL(fileURLWithPath: path) + return FileManager.default.fileExists(atPath: url.path) ? url : nil + } + self.forceExcludePaths = forceExcludePaths?.compactMap { path in + let url = URL(fileURLWithPath: path) + return FileManager.default.fileExists(atPath: url.path) ? url : nil + } + } +} + +// MARK: - SkipCondition + +/// Defines prefixes and paths that should be skipped during scanning. +/// +/// `skipPrefixes` - Normalized filename prefixes that are always skipped (e.g. system plists). +/// `allowPrefixes` - Exceptions to skipPrefixes for Apple apps we DO want to scan. +/// `skipPaths` - Absolute paths that should never appear in scan results. +struct SkipCondition { + let skipPrefixes: [String] + let allowPrefixes: [String] + let skipPaths: [String] +} + +// MARK: - App Conditions Database + +/// Per-app matching overrides. +/// Each entry handles a specific app whose files cannot be reliably found by bundle ID alone. +let appConditions: [AppCondition] = [ + + // --------------------------------------------------------------- + // Apple Developer Tools + // --------------------------------------------------------------- + + // Xcode: includes simulator containers, DerivedData markers, and Apple DT prefixes. + // Must exclude Xcodes.app (third-party Xcode version manager) and Xcode cleaner utilities. + AppCondition( + bundleID: "com.apple.dt.xcode", + includeTerms: ["com.apple.dt", "xcode", "simulator"], + excludeTerms: [ + "com.robotsandpencils.xcodesapp", + "com.xcodesorg.xcodesapp", + "com.oneminutegames.xcodecleaner", + "io.hyperapp.xcodecleaner", + "available-xcodes", + "xcodes", + "cleaner for xcode" + ], + forceIncludePaths: [ + "\(home)/Library/Containers/com.apple.iphonesimulator.ShareExtension" + ] + ), + + // Xcodes.app (robotsandpencils variant) + AppCondition( + bundleID: "com.robotsandpencils.xcodesapp", + includeTerms: [], + excludeTerms: [ + "com.apple.dt.xcode", + "com.oneminutegames.xcodecleaner", + "io.hyperapp.xcodecleaner" + ] + ), + + // Xcodes.app (xcodesorg variant) + AppCondition( + bundleID: "com.xcodesorg.xcodesapp", + includeTerms: [], + excludeTerms: [ + "com.apple.dt.xcode", + "com.oneminutegames.xcodecleaner", + "io.hyperapp.xcodecleaner" + ] + ), + + // Xcode Cleaner (hyperapp) + AppCondition( + bundleID: "io.hyperapp.xcodecleaner", + includeTerms: [], + excludeTerms: [ + "com.robotsandpencils.xcodesapp", + "com.oneminutegames.xcodecleaner", + "com.apple.dt.xcode", + "xcodes.json" + ] + ), + + // --------------------------------------------------------------- + // Communication & Video Conferencing + // --------------------------------------------------------------- + + // Zoom: uses "us.zoom.xos" bundle but files are often named just "zoom". + AppCondition( + bundleID: "us.zoom.xos", + includeTerms: ["zoom"], + excludeTerms: [] + ), + + // Microsoft Teams: exclude general Office shared frameworks. + AppCondition( + bundleID: "com.microsoft.teams2", + includeTerms: [], + excludeTerms: ["office"] + ), + + // --------------------------------------------------------------- + // Web Browsers + // --------------------------------------------------------------- + + // Brave Browser + AppCondition( + bundleID: "com.brave.browser", + includeTerms: ["brave"], + excludeTerms: [] + ), + + // Google Chrome: include "google" and "chrome" but exclude iTerm's chromefeaturestate + // and unrelated "monochrome" matches. + AppCondition( + bundleID: "com.google.chrome", + includeTerms: ["google", "chrome"], + excludeTerms: ["iterm", "chromefeaturestate", "monochrome"] + ), + + // Microsoft Edge: exclude other Microsoft products that share the "com.microsoft" prefix. + AppCondition( + bundleID: "com.microsoft.edgemac", + includeTerms: [], + excludeTerms: ["vscode", "rdc", "appcenter", "office", "oneauth"] + ), + + // Mozilla Firefox (release) + AppCondition( + bundleID: "org.mozilla.firefox", + includeTerms: ["firefox"], + excludeTerms: ["thunderbird"] + ), + + // Mozilla Firefox Nightly + AppCondition( + bundleID: "org.mozilla.firefox.nightly", + includeTerms: ["mozilla", "firefox"], + excludeTerms: ["thunderbird"] + ), + + // Mozilla Thunderbird + AppCondition( + bundleID: "org.mozilla.thunderbird", + includeTerms: [], + excludeTerms: ["firefox"] + ), + + // Arc Browser: uses Firestore for sync; force-include its App Support and Caches folders. + AppCondition( + bundleID: "company.thebrowser.Browser", + includeTerms: ["firestore"], + excludeTerms: [], + forceIncludePaths: [ + "\(home)/Library/Application Support/Arc/", + "\(home)/Library/Caches/Arc/" + ] + ), + + // --------------------------------------------------------------- + // Developer Tools & IDEs + // --------------------------------------------------------------- + + // VS Code: force-include the "Code" support directory. + // Must exclude VS Code Insiders to prevent cross-contamination. + AppCondition( + bundleID: "com.microsoft.VSCode", + includeTerms: ["vscode"], + excludeTerms: ["vscodeinsiders", "insiders"], + forceIncludePaths: [ + "\(home)/Library/Application Support/Code/" + ] + ), + + // VS Code Insiders: separate from stable VS Code. + AppCondition( + bundleID: "com.microsoft.VSCodeInsiders", + includeTerms: ["vscodeinsiders", "insiders"], + excludeTerms: [], + forceIncludePaths: [ + "\(home)/Library/Application Support/Code - Insiders/" + ] + ), + + // GitHub Desktop: uses "comgithubelectron" in some legacy cache paths. + AppCondition( + bundleID: "com.github.githubclient", + includeTerms: ["comgithubelectron"], + excludeTerms: [] + ), + + // JetBrains IDEs (IntelliJ, PyCharm, WebStorm, etc.): share common directories. + // The bundle ID prefix "jetbrains" matches all JetBrains products. + AppCondition( + bundleID: "jetbrains", + includeTerms: ["jcef"], + excludeTerms: [], + forceIncludePaths: [ + "\(home)/Library/Application Support/JetBrains/", + "\(home)/Library/Caches/JetBrains/", + "\(home)/Library/Logs/JetBrains/" + ] + ), + + // Native Instruments (Native Access, Kontakt, etc.) + AppCondition( + bundleID: "com.native-instruments.nativeaccess", + includeTerms: ["comnative", "nativeinstruments"], + excludeTerms: [] + ), + + // --------------------------------------------------------------- + // Productivity & Utilities + // --------------------------------------------------------------- + + // Logi Options+: "logi" prefix collides with "login" and "logic". + AppCondition( + bundleID: "com.logi.optionsplus", + includeTerms: ["logi", "logipluginservice"], + excludeTerms: ["login", "logic"] + ), + + // 1Password: uses shared Chromium-based browser engine paths. + AppCondition( + bundleID: "com.1password.1password", + includeTerms: ["waveboxapp", "sidekick"], + excludeTerms: [] + ), + + // Stats (system monitor): exclude "video" to avoid matching video-stats plists. + AppCondition( + bundleID: "eu.exelban.stats", + includeTerms: [], + excludeTerms: ["video"] + ), + + // BatteryToolkit: plist prefix uses concatenated form "memhaeuser". + AppCondition( + bundleID: "me.mhaeuser.BatteryToolkit", + includeTerms: ["memhaeuser"], + excludeTerms: [] + ), + + // Okta Verify + AppCondition( + bundleID: "com.okta.mobile", + includeTerms: ["okta"], + excludeTerms: [] + ), + + // --------------------------------------------------------------- + // Virtualization & Remote Access + // --------------------------------------------------------------- + + // BlueStacks: uses interprocess communication files with non-obvious names. + AppCondition( + bundleID: "com.now.gg.BlueStacks", + includeTerms: ["bst_boost_interprocess"], + excludeTerms: [] + ), + + // StrongDM: Electron app with "sdm" bundle but files named "strongdm". + AppCondition( + bundleID: "com.electron.sdm", + includeTerms: ["strongdm"], + excludeTerms: [] + ), + + // --------------------------------------------------------------- + // Social & Messaging + // --------------------------------------------------------------- + + // Facebook Archon (Workplace): includes login helper with different naming. + AppCondition( + bundleID: "com.facebook.archon.developerid", + includeTerms: ["archon.loginhelper"], + excludeTerms: [] + ), +] + +// MARK: - Skip Conditions + +/// System-level skip rules applied globally during scanning. +/// Prevents the scanner from matching macOS system files, the Trash, and known false positives. +let skipConditions: [SkipCondition] = [ + SkipCondition( + skipPrefixes: [ + "mobiledocuments", + "reminders", + "dsstore", + "comapplepasswordmanager" + ], + allowPrefixes: [ + "comappleconfigurator", + "comappledt", + "comappleiwork", + "comapplesfsymbols", + "comappletestflight", + "comapplesharedfilelist", + "comapplelssharedfilelist" + ], + skipPaths: [ + "\(home)/.Trash", + "/Library/SystemExtensions", + "/System/Volumes/Preboot/Cryptexes/App/System/Library/CoreServices/PasswordManagerBrowserExtensionHelper.app/Contents/MacOS/PasswordManagerBrowserExtensionHelper", + "\(home)/Library/Application Support/Chromium/NativeMessagingHosts/com.apple.passwordmanager.json", + "\(home)/Library/Application Support/Google/Chrome/NativeMessagingHosts/com.apple.passwordmanager.json" + ] + highRiskHomeDotPaths + ) +] + +/// Home-directory dotdirs that must never be matched as app artifacts no +/// matter how short or coincidental the app name is. Adding an entry here is +/// the correct fix for "removing webapp X also deleted my CLI tool X config" +/// (see issues #50 / #51). +let highRiskHomeDotPaths: [String] = [ + "\(home)/.claude", + "\(home)/.ssh", + "\(home)/.aws", + "\(home)/.gnupg", + "\(home)/.gpg", + "\(home)/.kube", + "\(home)/.docker", + "\(home)/.config", + "\(home)/.git", + "\(home)/.gitconfig", + "\(home)/.git-credentials", + "\(home)/.netrc", + "\(home)/.npmrc", + "\(home)/.yarnrc", + "\(home)/.pnpmrc", + "\(home)/.pip", + "\(home)/.pypirc", + "\(home)/.rbenv", + "\(home)/.pyenv", + "\(home)/.nvm", + "\(home)/.cargo", + "\(home)/.rustup", + "\(home)/.gem", + "\(home)/.local", + "\(home)/.password-store", + "\(home)/.mozilla", + "\(home)/.wine", + "\(home)/.vscode", + "\(home)/.vim", + "\(home)/.viminfo", + "\(home)/.zshrc", + "\(home)/.zsh_history", + "\(home)/.bash_history", + "\(home)/.bashrc", + "\(home)/.bash_profile", + "\(home)/.profile", +] + +// MARK: - Deep Search Exclusions + +/// Library subdirectories excluded from depth=2 (deep) search. +/// These are macOS system directories that never contain third-party app files. +/// Searching them wastes time and produces false positives. +let skipDeepSearch: Set = [ + + // Core System + "Apple", "Audio", "Bluetooth", "ColorSync", "Components", "CoreAnalytics", + "CoreMediaIO", "DirectoryServices", "Filesystems", "GPUBundles", "Graphics", + "KernelCollections", "OSAnalytics", "OpenDirectory", "Sandbox", "Security", + "SystemExtensions", "SystemMigration", "SystemProfiler", "StagedDriverExtensions", + "StagedExtensions", "StartupItems", + + // User Data & System Services + "Accessibility", "Accounts", "AppleMediaServices", "Assistant", "Assistants", + "Autosave Information", "Biome", "Calendars", "CallServices", "CloudStorage", + "Contacts", "Cookies", "DataAccess", "DataDeliveryServices", "DoNotDisturb", + "DuetExpertCenter", "Finance", "FinanceBackup", "FrontBoard", "GameKit", + "GroupContainersAlias", "HomeKit", "IdentityServices", "IntelligencePlatform", + "Intents", "KeyboardServices", "LanguageModeling", "LockdownMode", "Mail", + "MediaAnalysis", "Messages", "Metadata", "Mobile Documents", "MobileDevice", + "News", "Passes", "PersonalizationPortrait", "Photos", "PrivateCloudCompute", + "Reminders", "ResponseKit", "Safari", "SafariSafeBrowsing", "SafariSandboxBroker", + "ScreenRecordings", "StatusKit", "Suggestions", "SyncedPreferences", "Translation", + "UnifiedAssetFramework", "Weather", "homeenergyd", "studentd", + + // Development & System Tools + "Developer", "Perl", "Ruby", "Java", "Python", "Catacomb", "InstallerSandboxes", + "Trial", "Updates", "Staging", "ContainerManager", "Daemon Containers", + + // Additional System Directories + "ColorPickers", "Colors", "Compositions", "Contextual Menu Items", "Documentation", + "DriverExtensions", "Favorites", "FontCollections", "Fonts", "Image Capture", + "Input Methods", "Jupyter", "Keyboard", "Keyboard Layouts", "Keychains", + "Managed Preferences", "PDF Services", "Printers", "QuickLook", "Receipts", + "Screen Savers", "ScriptingAdditions", "Scripts", "Sharing", "Shortcuts", + "Sounds", "Speech", "Spelling", "Spotlight", "User Pictures", "User Template", + "Video", "WebServer", "Workflows", + + // Apple Service Bundles (com.apple.*) + "com.apple.AppleMediaServices", "com.apple.WatchListKit", "com.apple.aiml.instrumentation", + "com.apple.appleaccountd", "com.apple.bluetooth.services.cloud", "com.apple.bluetoothuser", + "com.apple.familycircled", "com.apple.iTunesCloud", "com.apple.internal.ck", + + // iCloud & Sync Infrastructure + "com.apple.cloudpaird", "com.apple.iCloudHelper", "com.apple.nsurlsessiond", + "com.apple.sbd", "com.apple.touristd", + + // System Agents & Daemons + "com.apple.AMPLibraryAgent", "com.apple.bird", "com.apple.coreduetd", + "com.apple.homed", "com.apple.photoanalysisd", "com.apple.routined", + "com.apple.siriactionsd", "com.apple.suggestd", + + // Frameworks & Runtime (never user-facing) + "com.apple.AppStoreComponents", "com.apple.ScreenTimeUI", + "com.apple.TelephonyUtilities", "com.apple.WebInspector" +] + +// MARK: - Reverse Search Exclusions + +/// Normalized prefixes of files and folders to skip during orphan (reverse) search. +/// These are macOS system items, Apple daemons, and infrastructure that should never +/// be flagged as orphaned third-party app files. +let skipReverse: [String] = [ + // Apple & System + "apple", "temporary", "btserver", "proapps", "scripteditor", "ilife", + "livefsd", "siritoday", "addressbook", "animoji", "appstore", + "askpermission", "callhistory", "clouddocs", "diskimages", "dock", + "facetime", "fileprovider", "instruments", "knowledge", "mobilesync", + "syncservices", "homeenergyd", "icloud", "icdd", "networkserviceproxy", + "familycircle", "geoservices", "installation", "passkit", + "sharedimagecache", "desktop", "mbuseragent", "swiftpm", "baseband", + "coresimulator", "photoslegacyupgrade", "photosupgrade", "siritts", + "ipod", "globalpreferences", + + // Analytics & Telemetry + "apmanalytics", "apmexperiment", "avatarcache", "byhost", + "contextstoreagent", "mobilemeaccounts", "mobiledocuments", "mobile", + "intentbuilderc", "loginwindow", "momc", "replayd", "sharedfilelistd", + + // Build Tools & Compilers + "clang", "audiocomponent", "csexattrcryptoservice", + "livetranscriptionagent", "sandboxhelper", "statuskitagent", + + // System Daemons + "betaenrollmentd", "contentlinkingd", "diagnosticextensionsd", "gamed", + "heard", "homed", "itunescloudd", "lldb", "mds", "mediaanalysisd", + "metrickitd", "mobiletimerd", "proactived", "ptpcamerad", "studentd", + "talagent", "watchlistd", "apptranslocation", "xcrun", + + // Generic Infrastructure + "ds_store", "caches", "crashreporter", "trash", + + // PureMac itself (never flag our own files) + "puremac", + + // Common SDKs and Shared Components + "amsdatamigratortool", "arfilecache", "assistant", "chromium", + "cloudkit", "webkit", "databases", "diagnostic", "cache", "gamekit", + "homebrew", "logi", "microsoft", "mozilla", "sync", "google", + "sentinel", "hexnode", "sentry", "tvappservices", "reminders", "pbs", + "notarytool", "differentialprivacy", "storeassetd", "webpush", + "storedownloadd", "fsck", "crash", "python", "discrecording", + "photossearch", "pylint", "jamf", "scopedbookmarkagent", "anonymous", + "identifier", "isolated", "nobackup", "privacypreservingmeasurement", + "symbols", "stickersd", "privatecloudcomputed", "tipsd", + "controlcenter", "contactsd", "staticcheck", "index", "segment", + "sparkle", "summaryevents", "launchdarkly", "identityservicesd", + "embeddedbinaryvalidationutility", "aaprofilepicture", "minilauncher", + "jna", "automator", "locationaccessstored", "spotlight", "cef" +] diff --git a/PureMac/Logic/Scanning/Locations.swift b/PureMac/Logic/Scanning/Locations.swift new file mode 100644 index 0000000..ea7c44b --- /dev/null +++ b/PureMac/Logic/Scanning/Locations.swift @@ -0,0 +1,166 @@ +// +// Locations.swift +// PureMac +// +// Defines all macOS filesystem locations where applications leave files. +// Used by the heuristic scan engine to discover app-related artifacts. +// + +import Foundation + +let home = FileManager.default.homeDirectoryForCurrentUser.path + +class Locations: ObservableObject { + + struct SearchCategory { + let name: String + var paths: [String] + } + + // Standard macOS Library subdirectories. + // Used to determine if depth=2 matches should add the parent directory (vendor folders) + // or the matched item itself (standard system folders). + static let standardLibrarySubdirectories: Set = [ + "Application Scripts", "Application Support", "Caches", + "Containers", "Group Containers", "HTTPStorages", + "Internet Plug-Ins", "LaunchAgents", "LaunchDaemons", + "Logs", "Preferences", "PreferencePanes", + "PrivilegedHelperTools", "Saved Application State", + "Services", "WebKit", "Extensions", "Frameworks" + ] + + let cacheDir: String + let tempDir: String + var appSearch: SearchCategory + var reverseSearch: SearchCategory + + init() { + let (cacheDir, tempDir) = Locations.darwinCT() + self.cacheDir = cacheDir + self.tempDir = tempDir + + self.appSearch = SearchCategory(name: "Apps", paths: [ + // User home - bare "\(home)" is intentionally NOT scanned. + // Scanning bare $HOME matches top-level dotfiles like .claude, + // .ssh, .aws, .kube by normalized app-name ("claude" matching + // ".claude") and invites data loss when uninstalling unrelated + // webapps. Scoped subdirs below are still scanned. + "\(home)/.config", + "\(home)/Documents", + "\(home)/Desktop", + "\(home)/Applications", + // User Library + "\(home)/Library", + "\(home)/Library/Application Scripts", + "\(home)/Library/Application Support", + "\(home)/Library/Application Support/CrashReporter", + "\(home)/Library/Application Support/com.apple.sharedfilelist/com.apple.LSSharedFileList.ApplicationRecentDocuments", + "\(home)/Library/Containers", + "\(home)/Library/Caches", + "\(home)/Library/Caches/com.apple.helpd/Generated", + "\(home)/Library/Caches/com.crashlytics", + "\(home)/Library/Caches/com.google.SoftwareUpdate", + "\(home)/Library/Caches/com.google.Keystone", + "\(home)/Library/Caches/org.sparkle-project.Sparkle", + "\(home)/Library/Caches/com.segment.analytics", + "\(home)/Library/Caches/SentryCrash", + "\(home)/Library/Caches/Rollbar", + "\(home)/Library/Caches/Amplitude", + "\(home)/Library/Caches/Realm", + "\(home)/Library/Caches/Parse", + "\(home)/Library/Group Containers", + "\(home)/Library/HTTPStorages", + "\(home)/Library/Internet Plug-Ins", + "\(home)/Library/LaunchAgents", + "\(home)/Library/Logs", + "\(home)/Library/Logs/DiagnosticReports", + "\(home)/Library/Preferences", + "\(home)/Library/PreferencePanes", + "\(home)/Library/Preferences/ByHost", + "\(home)/Library/Saved Application State", + "\(home)/Library/Services", + "\(home)/Library/WebKit", + // System-wide + "/Applications", + "/Users/Shared", + "/Users/Shared/Library/Application Support", + "/Library", + "/Library/Application Support", + "/Library/Application Support/CrashReporter", + "/Library/Caches", + "/Library/Extensions", + "/Library/Internet Plug-Ins", + "/Library/LaunchAgents", + "/Library/LaunchDaemons", + "/Library/Logs", + "/Library/Logs/DiagnosticReports", + "/Library/Preferences", + "/Library/PrivilegedHelperTools", + "/private/var/db/receipts", + "/private/tmp", + "/usr/local/bin", + "/usr/local/etc", + "/usr/local/opt", + "/usr/local/sbin", + "/usr/local/share", + "/usr/local/var", + cacheDir, + tempDir + ]) + + // Dynamically append Application Support subfolders for deeper search + let subfolders = Locations.listAppSupportDirectories() + for folder in subfolders { + self.appSearch.paths.append("\(home)/Library/Application Support/\(folder)") + } + + self.reverseSearch = SearchCategory(name: "Reverse", paths: [ + "\(home)/Library/Application Scripts", + "\(home)/Library/Application Support", + "\(home)/Library/Application Support/Caches", + "\(home)/Library/Application Support/com.apple.sharedfilelist/com.apple.LSSharedFileList.ApplicationRecentDocuments", + "\(home)/Library/Containers", + "\(home)/Library/Caches", + "\(home)/Library/HTTPStorages", + "\(home)/Library/Internet Plug-Ins", + "\(home)/Library/LaunchAgents", + "\(home)/Library/Logs", + "\(home)/Library/Preferences", + "\(home)/Library/PreferencePanes", + "\(home)/Library/Preferences/ByHost", + "\(home)/Library/Saved Application State", + "\(home)/Library/WebKit", + "/Users/Shared/Library/Application Support", + "/Library/Application Support", + "/Library/Application Support/CrashReporter", + "/Library/Internet Plug-Ins", + "/Library/LaunchAgents", + "/Library/LaunchDaemons", + "/Library/PrivilegedHelperTools", + ]) + } + + static func darwinCT() -> (String, String) { + var cacheDir = "" + var tempDir = "" + if let cfCacheDir = NSSearchPathForDirectoriesInDomains(.cachesDirectory, .userDomainMask, true).first { + cacheDir = cfCacheDir + } + if let cfTempDir = ProcessInfo.processInfo.environment["TMPDIR"] { + tempDir = cfTempDir + } + return (cacheDir, tempDir) + } + + static func listAppSupportDirectories() -> [String] { + let appSupportPath = "\(home)/Library/Application Support" + guard let contents = try? FileManager.default.contentsOfDirectory(atPath: appSupportPath) else { + return [] + } + return contents.filter { item in + var isDir: ObjCBool = false + let fullPath = (appSupportPath as NSString).appendingPathComponent(item) + return FileManager.default.fileExists(atPath: fullPath, isDirectory: &isDir) && isDir.boolValue + } + } +} diff --git a/PureMac/Logic/Scanning/StringNormalization.swift b/PureMac/Logic/Scanning/StringNormalization.swift new file mode 100644 index 0000000..df84029 --- /dev/null +++ b/PureMac/Logic/Scanning/StringNormalization.swift @@ -0,0 +1,50 @@ +import Foundation + +extension String { + + func normalizedForMatching() -> String { + self.lowercased() + .replacingOccurrences(of: " ", with: "") + .replacingOccurrences(of: "-", with: "") + .replacingOccurrences(of: "_", with: "") + .replacingOccurrences(of: ".", with: "") + } + + func strippingTrailingVersion() -> String { + self.replacingOccurrences( + of: #"\s+\d+(\.\d+)*\s*$"#, + with: "", + options: .regularExpression + ).trimmingCharacters(in: .whitespaces) + } + + var lettersOnly: String { + self.filter { $0.isLetter }.lowercased() + } + + var bundleCompanyName: String? { + let components = self.components(separatedBy: ".") + guard components.count == 3 else { return nil } + let company = components[1] + return company.isEmpty ? nil : company.normalizedForMatching() + } + + var bundleLastTwoComponents: String { + let components = self.components(separatedBy: ".") + .compactMap { $0 != "-" ? $0.lowercased() : nil } + return components.suffix(2).joined() + } + + var baseBundleIdentifier: String? { + let components = self.components(separatedBy: ".") + let suffixes: Set = [ + "helper", "agent", "daemon", "service", "xpc", + "launcher", "updater", "installer", "uninstaller", + "login", "extension", "plugin" + ] + guard components.count >= 4, + let last = components.last?.lowercased(), + suffixes.contains(last) else { return nil } + return components.dropLast().joined(separator: ".") + } +} diff --git a/PureMac/Logic/Utilities/CLI.swift b/PureMac/Logic/Utilities/CLI.swift new file mode 100644 index 0000000..640acea --- /dev/null +++ b/PureMac/Logic/Utilities/CLI.swift @@ -0,0 +1,177 @@ +import Foundation + +struct CLI { + private static let knownCommands: Set = [ + "scan", "disk-info", "list", + "help", "--help", "-h", + "version", "--version", "-v", + ] + + static func isKnownCommand(_ arg: String) -> Bool { + knownCommands.contains(arg) + } + + static func run() -> Never { + let args = Array(CommandLine.arguments.dropFirst()) + + guard let command = args.first else { + printUsage() + exit(0) + } + + switch command { + case "scan": + handleScan(args: Array(args.dropFirst())) + case "disk-info": + handleDiskInfo() + case "list": + handleList() + case "help", "--help", "-h": + printUsage() + case "version", "--version", "-v": + printVersion() + default: + printError("Unknown command: \(command)") + printUsage() + exit(1) + } + exit(0) + } + + // MARK: - Commands + + private static func handleScan(args: [String]) { + let json = args.contains("--json") + let categoryFilter = extractValue(for: "--category", in: args) + + let engine = ScanEngine() + let categories: [CleaningCategory] + + if let filter = categoryFilter { + guard let cat = CleaningCategory.scannable.first(where: { + $0.rawValue.lowercased().replacingOccurrences(of: " ", with: "") == + filter.lowercased().replacingOccurrences(of: " ", with: "") + }) else { + printError("Unknown category: \(filter)") + print("Available: \(CleaningCategory.scannable.map(\.rawValue).joined(separator: ", "))") + exit(1) + } + categories = [cat] + } else { + categories = CleaningCategory.scannable + } + + var allResults: [(String, Int, Int64)] = [] + + let group = DispatchGroup() + let queue = DispatchQueue(label: "cli.scan") + + for category in categories { + group.enter() + Task { + let result = await engine.scanCategory(category) + queue.sync { + allResults.append((category.rawValue, result.itemCount, result.totalSize)) + } + group.leave() + } + } + group.wait() + + if json { + printJSON(allResults) + } else { + printTable(allResults) + } + } + + private static func handleDiskInfo() { + let engine = ScanEngine() + var info = DiskInfo() + let group = DispatchGroup() + group.enter() + Task { + info = await engine.getDiskInfo() + group.leave() + } + group.wait() + + print("Disk Usage:") + print(" Total: \(info.formattedTotal)") + print(" Used: \(info.formattedUsed)") + print(" Free: \(info.formattedFree)") + if info.purgeableSpace > 0 { + print(" Purgeable: \(info.formattedPurgeable)") + } + } + + private static func handleList() { + let apps = AppInfoFetcher.shared.fetchInstalledApps() + print("Installed Apps (\(apps.count)):") + for app in apps { + let size = ByteCountFormatter.string(fromByteCount: app.size, countStyle: .file) + print(" \(app.appName.padding(toLength: 35, withPad: " ", startingAt: 0)) \(size.padding(toLength: 12, withPad: " ", startingAt: 0)) \(app.bundleIdentifier)") + } + } + + // MARK: - Output + + private static func printTable(_ results: [(String, Int, Int64)]) { + var totalSize: Int64 = 0 + var totalItems = 0 + + print("Category Items Size") + print("---------------------- ----- --------") + for (name, count, size) in results { + let sizeStr = ByteCountFormatter.string(fromByteCount: size, countStyle: .file) + print("\(name.padding(toLength: 22, withPad: " ", startingAt: 0)) \(String(count).padding(toLength: 5, withPad: " ", startingAt: 0)) \(sizeStr)") + totalSize += size + totalItems += count + } + print("---------------------- ----- --------") + let totalStr = ByteCountFormatter.string(fromByteCount: totalSize, countStyle: .file) + print("Total \(String(totalItems).padding(toLength: 5, withPad: " ", startingAt: 0)) \(totalStr)") + } + + private static func printJSON(_ results: [(String, Int, Int64)]) { + var entries: [String] = [] + for (name, count, size) in results { + entries.append(" {\"category\": \"\(name)\", \"items\": \(count), \"bytes\": \(size)}") + } + print("[\n\(entries.joined(separator: ",\n"))\n]") + } + + private static func printUsage() { + print(""" + PureMac CLI + + Usage: puremac [options] + + Commands: + scan Scan all categories + scan --category Scan a specific category + scan --json Output as JSON + disk-info Show disk usage + list List installed apps + version Show version + help Show this help + + Categories: + \(CleaningCategory.scannable.map(\.rawValue).joined(separator: ", ")) + """) + } + + private static func printVersion() { + let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "2.0.0" + print("PureMac \(version)") + } + + private static func printError(_ message: String) { + FileHandle.standardError.write(Data("Error: \(message)\n".utf8)) + } + + private static func extractValue(for flag: String, in args: [String]) -> String? { + guard let index = args.firstIndex(of: flag), index + 1 < args.count else { return nil } + return args[index + 1] + } +} diff --git a/PureMac/Logic/Utilities/FileSize.swift b/PureMac/Logic/Utilities/FileSize.swift new file mode 100644 index 0000000..1a258cf --- /dev/null +++ b/PureMac/Logic/Utilities/FileSize.swift @@ -0,0 +1,66 @@ +import Foundation + +/// Allocated-size calculation that works for both files and directories. +/// +/// `URLResourceValues.totalFileAllocatedSize` does **not** recurse: on a +/// directory URL it returns only the directory inode's own allocation +/// (~96 bytes to a few KB on APFS), not the sum of the bundle's contents. +/// Reading it directly on an `.app` bundle or a support folder is what made +/// items display as a handful of bytes. For directories we enumerate and sum +/// the regular files instead. +enum FileSizeCalculator { + private static let fileManager = FileManager.default + + /// On-disk allocated size of `url`. Recurses into directories. + /// Returns `nil` if the item can't be read at all. + static func size(of url: URL) -> Int64? { + let values = try? url.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) + // Treat a symlink as a file (size of the link itself), never recursing + // into its target. `.isDirectoryKey` resolves symlinks, so without this + // guard a top-level symlink-to-directory would be walked as the target's + // full tree — inflating the size, escaping the item's real footprint, + // and mismatching deletion (removeItem deletes only the link). Check + // isSymbolicLink first so the directory branch only sees real dirs. + if values?.isSymbolicLink != true, values?.isDirectory == true { + return directorySize(of: url) + } + return fileSize(of: url) + } + + private static func fileSize(of url: URL) -> Int64? { + if let values = try? url.resourceValues(forKeys: [.totalFileAllocatedSizeKey]), + let size = values.totalFileAllocatedSize { + return Int64(size) + } + if let values = try? url.resourceValues(forKeys: [.fileAllocatedSizeKey]), + let size = values.fileAllocatedSize { + return Int64(size) + } + guard let attrs = try? fileManager.attributesOfItem(atPath: url.path), + let size = (attrs[.size] as? NSNumber)?.int64Value else { return nil } + return size + } + + private static func directorySize(of url: URL) -> Int64 { + guard let enumerator = fileManager.enumerator( + at: url, + includingPropertiesForKeys: [.totalFileAllocatedSizeKey, .fileAllocatedSizeKey, .isRegularFileKey, .isSymbolicLinkKey], + options: [.skipsHiddenFiles] + ) else { return 0 } + + var total: Int64 = 0 + for case let fileURL as URL in enumerator { + guard let values = try? fileURL.resourceValues(forKeys: [.totalFileAllocatedSizeKey, .fileAllocatedSizeKey, .isRegularFileKey, .isSymbolicLinkKey]) else { continue } + // Skip symlinks so we don't double-count or follow links that + // escape the directory. Only sum regular-file payload. + if values.isSymbolicLink == true { continue } + guard values.isRegularFile == true else { continue } + if let allocated = values.totalFileAllocatedSize { + total += Int64(allocated) + } else if let allocated = values.fileAllocatedSize { + total += Int64(allocated) + } + } + return total + } +} diff --git a/PureMac/Logic/Utilities/Logger.swift b/PureMac/Logic/Utilities/Logger.swift new file mode 100644 index 0000000..594584c --- /dev/null +++ b/PureMac/Logic/Utilities/Logger.swift @@ -0,0 +1,59 @@ +import Foundation +import os + +struct LogEntry: Identifiable, Sendable { + let id = UUID() + let timestamp = Date() + let message: String + let level: LogLevel + let source: String +} + +enum LogLevel: String, Sendable, CaseIterable { + case debug + case info + case warning + case error + + fileprivate var osLogType: OSLogType { + switch self { + case .debug: return .debug + case .info: return .info + case .warning: return .default + case .error: return .error + } + } +} + +@MainActor +final class Logger: ObservableObject { + + static let shared = Logger() + + @Published private(set) var entries: [LogEntry] = [] + + private static let maxEntries = 1000 + + private let osLogger: os.Logger + + private init() { + let subsystem = Bundle.main.bundleIdentifier ?? "com.puremac.app" + self.osLogger = os.Logger(subsystem: subsystem, category: "general") + } + + nonisolated func log(_ message: String, level: LogLevel = .info, source: String = #function) { + osLogger.log(level: level.osLogType, "\(message, privacy: .public)") + + let entry = LogEntry(message: message, level: level, source: source) + Task { @MainActor [weak self] in + self?.append(entry) + } + } + + private func append(_ entry: LogEntry) { + entries.append(entry) + if entries.count > Self.maxEntries { + entries.removeFirst(entries.count - Self.maxEntries) + } + } +} diff --git a/PureMac/Logic/Utilities/OrphanSafetyPolicy.swift b/PureMac/Logic/Utilities/OrphanSafetyPolicy.swift new file mode 100644 index 0000000..2b79f49 --- /dev/null +++ b/PureMac/Logic/Utilities/OrphanSafetyPolicy.swift @@ -0,0 +1,76 @@ +import Foundation + +enum OrphanSafetyPolicy { + private static let home = FileManager.default.homeDirectoryForCurrentUser.path + + // Conservative allowlist: only volatile data directories. + static let allowedRoots: [String] = [ + "\(home)/Library/Caches", + "\(home)/Library/Logs", + "\(home)/Library/Saved Application State", + "\(home)/Library/HTTPStorages", + "\(home)/Library/WebKit", + "\(home)/Library/Application Support/CrashReporter", + "/Library/Caches", + "/Library/Logs", + ] + + private static let blockedFragments: [String] = [ + "/Library/Preferences", + "/Library/PreferencePanes", + "/Library/Containers", + "/Library/Group Containers", + "/Library/Application Scripts", + "/Library/LaunchAgents", + "/Library/LaunchDaemons", + "/Library/PrivilegedHelperTools", + "/Library/Keychains", + "/Library/Mail", + "/Library/Safari", + "/Library/Messages", + "/Library/Calendars", + "/Library/Accounts", + "/Library/Mobile Documents", + "/Library/CloudStorage", + ] + + static func isSafeCandidate(_ url: URL) -> Bool { + let path = normalizedPath(url) + let lowerPath = path.lowercased() + + // Belt-and-suspenders: reject any high-risk home dotpath (defined in + // Conditions.swift). The allowedRoots filter below would catch most + // of these, but this early block keeps the rule obvious. + for root in highRiskHomeDotPaths { + if path == root || path.hasPrefix(root + "/") { + return false + } + } + + // Require the match to land STRICTLY inside the allowed root, not at a + // sibling like /tmpfoo. Trailing "/" prevents hasPrefix from matching + // sibling directories whose names merely start with the root name. + guard allowedRoots.contains(where: { root in + let rootWithSlash = root.lowercased() + "/" + return lowerPath.hasPrefix(rootWithSlash) + }) else { + return false + } + + if blockedFragments.contains(where: { lowerPath.contains($0.lowercased()) }) { + return false + } + + let name = url.lastPathComponent.lowercased() + if name.hasPrefix("com.apple.") || name == ".globalpreferences.plist" { + return false + } + + return true + } + + private static func normalizedPath(_ url: URL) -> String { + let standardized = url.standardizedFileURL + return standardized.resolvingSymlinksInPath().path + } +} diff --git a/PureMac/Models/AppLanguage.swift b/PureMac/Models/AppLanguage.swift new file mode 100644 index 0000000..62ca935 --- /dev/null +++ b/PureMac/Models/AppLanguage.swift @@ -0,0 +1,56 @@ +import Foundation + +enum AppLanguage: String, CaseIterable, Identifiable { + case system = "system" + case english = "en" + case spanish = "es" + case japanese = "ja" + case arabic = "ar" + case portugueseBrazil = "pt-BR" + case simplifiedChinese = "zh-Hans" + case traditionalChinese = "zh-Hant" + + static let preferenceKey = "settings.general.appLanguage" + + var id: String { rawValue } + + var displayName: String { + switch self { + case .system: return "System Default" + case .english: return "English" + case .spanish: return "Spanish" + case .japanese: return "Japanese" + case .arabic: return "Arabic" + case .portugueseBrazil: return "Portuguese (Brazil)" + case .simplifiedChinese: return "Chinese (Simplified)" + case .traditionalChinese: return "Chinese (Traditional)" + } + } + + static var current: AppLanguage { + if let selectedLanguage = UserDefaults.standard.string(forKey: preferenceKey), + let language = AppLanguage(rawValue: selectedLanguage) { + return language + } + + guard let bundleIdentifier = Bundle.main.bundleIdentifier, + let appDefaults = UserDefaults.standard.persistentDomain(forName: bundleIdentifier), + let preferredLanguages = appDefaults["AppleLanguages"] as? [String], + let preferredLanguage = preferredLanguages.first else { + return .system + } + + let normalized = preferredLanguage.replacingOccurrences(of: "_", with: "-") + return allCases.first { $0.rawValue == normalized } ?? .system + } +} + +enum AppLanguagePreferences { + static func apply(_ language: AppLanguage, defaults: UserDefaults = .standard) { + if language == .system { + defaults.removeObject(forKey: "AppleLanguages") + } else { + defaults.set([language.rawValue], forKey: "AppleLanguages") + } + } +} diff --git a/PureMac/Models/Models.swift b/PureMac/Models/Models.swift new file mode 100644 index 0000000..44bfd43 --- /dev/null +++ b/PureMac/Models/Models.swift @@ -0,0 +1,228 @@ +import SwiftUI + +// MARK: - Cleaning Category + +enum CleaningCategory: String, CaseIterable, Identifiable, Codable { + case smartScan = "Smart Scan" + case systemJunk = "System Junk" + case userCache = "User Cache" + case aiApps = "AI Apps" + case mailAttachments = "Mail Files" + case trashBins = "Trash Bins" + case largeFiles = "Large & Old Files" + case purgeableSpace = "Purgeable Space" + case xcodeJunk = "Xcode Junk" + case brewCache = "Brew Cache" + case nodeCache = "Node Cache" + case dockerCache = "Docker Cache" + + var id: String { rawValue } + + var icon: String { + switch self { + case .smartScan: return "sparkles" + case .systemJunk: return "gearshape.fill" + case .userCache: return "internaldrive.fill" + case .aiApps: return "cpu.fill" + case .mailAttachments: return "envelope.fill" + case .trashBins: return "trash.fill" + case .largeFiles: return "doc.fill" + case .purgeableSpace: return "arrow.3.trianglepath" + case .xcodeJunk: return "hammer.fill" + case .brewCache: return "mug.fill" + case .nodeCache: return "leaf.fill" + case .dockerCache: return "shippingbox.fill" + } + } + + var description: String { + switch self { + case .smartScan: return "Scan everything at once" + case .systemJunk: return "System caches, logs, and temporary files" + case .userCache: return "Application caches and browser data" + case .aiApps: return "Local AI app logs, caches, and optional history" + case .mailAttachments: return "Downloaded mail attachments" + case .trashBins: return "Files in your Trash" + case .largeFiles: return "Files over 100 MB or older than 1 year" + case .purgeableSpace: return "Reserved by macOS - freed automatically when space is needed" + case .xcodeJunk: return "Derived data, archives, and simulators" + case .brewCache: return "Homebrew download cache" + case .nodeCache: return "npm, yarn, and pnpm download caches" + case .dockerCache: return "Docker images, containers, and build cache" + } + } + + var color: Color { + switch self { + case .smartScan: return .accentColor + case .systemJunk: return .purple + case .userCache: return .blue + case .aiApps: return .teal + case .mailAttachments: return .orange + case .trashBins: return .red + case .largeFiles: return .yellow + case .purgeableSpace: return .green + case .xcodeJunk: return .cyan + case .brewCache: return .mint + case .nodeCache: return .pink + case .dockerCache: return .indigo + } + } + + // Categories to scan in Smart Scan mode. + // + // `purgeableSpace` is deliberately excluded: APFS purgeable space is + // reserved and reclaimed by macOS itself — no third-party app can reliably + // enumerate or free it, and the Finder's own purgeable figure is known to + // be inaccurate. We still SHOW it in the storage breakdown for + // transparency, but we never present it as junk PureMac can delete. + // Honest > impressive. + static var scannable: [CleaningCategory] { + allCases.filter { $0 != .smartScan && $0 != .purgeableSpace } + } +} + +// MARK: - Scan State + +enum ScanState: Equatable { + case idle + case scanning(progress: Double, currentCategory: String) + case completed + case cleaning(progress: Double) + case cleaned + + var isActive: Bool { + switch self { + case .scanning, .cleaning: return true + default: return false + } + } +} + +// MARK: - Cleanable Item + +struct CleanableItem: Identifiable, Hashable { + let id = UUID() + let name: String + let path: String + let size: Int64 + let category: CleaningCategory + var isSelected: Bool + let lastModified: Date? + + var formattedSize: String { + ByteCountFormatter.string(fromByteCount: size, countStyle: .file) + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + static func == (lhs: CleanableItem, rhs: CleanableItem) -> Bool { + lhs.id == rhs.id + } +} + +// MARK: - Category Result + +struct CategoryResult: Identifiable { + let id = UUID() + let category: CleaningCategory + var items: [CleanableItem] + var totalSize: Int64 + + var formattedSize: String { + ByteCountFormatter.string(fromByteCount: totalSize, countStyle: .file) + } + + var itemCount: Int { items.count } +} + +// MARK: - Schedule Settings + +enum ScheduleInterval: String, CaseIterable, Identifiable, Codable { + case hours1 = "Every Hour" + case hours3 = "Every 3 Hours" + case hours6 = "Every 6 Hours" + case hours12 = "Every 12 Hours" + case daily = "Daily" + case weekly = "Weekly" + case biweekly = "Every 2 Weeks" + case monthly = "Monthly" + + var id: String { rawValue } + + var seconds: TimeInterval { + switch self { + case .hours1: return 3600 + case .hours3: return 10800 + case .hours6: return 21600 + case .hours12: return 43200 + case .daily: return 86400 + case .weekly: return 604800 + case .biweekly: return 1209600 + case .monthly: return 2592000 + } + } +} + +struct ScheduleConfig: Codable { + var isEnabled: Bool = false + var interval: ScheduleInterval = .daily + var autoClean: Bool = false + var autoPurge: Bool = false + var categoriesToScan: [CleaningCategory] = CleaningCategory.scannable + var lastRunDate: Date? + var nextRunDate: Date? + var notifyOnCompletion: Bool = true + var minimumCleanSize: Int64 = 100 * 1024 * 1024 // 100 MB + + var formattedLastRun: String { + guard let date = lastRunDate else { return String(localized: "Never") } + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + return formatter.localizedString(for: date, relativeTo: Date()) + } + + var formattedNextRun: String { + guard let date = nextRunDate else { return String(localized: "Not scheduled") } + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + return formatter.localizedString(for: date, relativeTo: Date()) + } +} + +// MARK: - Disk Info + +struct DiskInfo { + var totalSpace: Int64 = 0 + var freeSpace: Int64 = 0 + var usedSpace: Int64 = 0 + var purgeableSpace: Int64 = 0 + + var usedPercentage: Double { + guard totalSpace > 0 else { return 0 } + return Double(usedSpace) / Double(totalSpace) + } + + var freePercentage: Double { + guard totalSpace > 0 else { return 0 } + return Double(freeSpace) / Double(totalSpace) + } + + var formattedTotal: String { + ByteCountFormatter.string(fromByteCount: totalSpace, countStyle: .file) + } + + var formattedFree: String { + ByteCountFormatter.string(fromByteCount: freeSpace, countStyle: .file) + } + + var formattedUsed: String { + ByteCountFormatter.string(fromByteCount: usedSpace, countStyle: .file) + } + + var formattedPurgeable: String { + ByteCountFormatter.string(fromByteCount: purgeableSpace, countStyle: .file) + } +} diff --git a/PureMac/Models/ScanError.swift b/PureMac/Models/ScanError.swift new file mode 100644 index 0000000..2dc567f --- /dev/null +++ b/PureMac/Models/ScanError.swift @@ -0,0 +1,27 @@ +import Foundation + +enum ScanError: LocalizedError { + case permissionDenied(path: String) + case directoryEnumerationFailed(path: String, underlying: Error) + case processExecutionFailed(tool: String, underlying: Error) + case invalidData(context: String) + case helperToolUnavailable + case operationCancelled + + var errorDescription: String? { + switch self { + case .permissionDenied(let path): + return "Permission denied when accessing '\(path)'. Grant Full Disk Access in System Settings." + case .directoryEnumerationFailed(let path, let underlying): + return "Failed to enumerate directory '\(path)': \(underlying.localizedDescription)" + case .processExecutionFailed(let tool, let underlying): + return "Failed to execute '\(tool)': \(underlying.localizedDescription)" + case .invalidData(let context): + return "Invalid data encountered: \(context)" + case .helperToolUnavailable: + return "The privileged helper tool is not installed or unavailable." + case .operationCancelled: + return "The operation was cancelled." + } + } +} diff --git a/PureMac/PureMac.entitlements b/PureMac/PureMac.entitlements new file mode 100644 index 0000000..e89b7f3 --- /dev/null +++ b/PureMac/PureMac.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/PureMac/PureMacApp.swift b/PureMac/PureMacApp.swift new file mode 100644 index 0000000..4078a74 --- /dev/null +++ b/PureMac/PureMacApp.swift @@ -0,0 +1,142 @@ +import AppKit +import SwiftUI + +@MainActor +class AppDelegate: NSObject, NSApplicationDelegate { + /// Owns the optional menu-bar status item. Nil until the monitor is enabled. + private var menuBarController: MenuBarController? + + /// Normally PureMac quits when its window closes. When the menu-bar system + /// monitor is enabled the app stays resident so the meters keep updating in + /// the menu bar; "Open PureMac" in that menu reopens the window. + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + !UserDefaults.standard.bool(forKey: "settings.general.menuBarMonitor") + } + + func applicationDidFinishLaunching(_ notification: Notification) { + NSWindow.allowsAutomaticWindowTabbing = false + + // Install the menu-bar monitor if the user has it enabled. Never under + // XCTest — the status-item machinery would stall the test-host run loop. + if NSClassFromString("XCTestCase") == nil { + syncMenuBarMonitor() + NotificationCenter.default.addObserver( + self, selector: #selector(syncMenuBarMonitor), + name: .pureMacMenuBarMonitorChanged, object: nil + ) + } + // Touch TCC-protected paths so macOS registers PureMac in the + // Full Disk Access pane on first launch (fixes issue #75). + FullDiskAccessManager.shared.triggerRegistration() + // Register the Finder Services provider so "Uninstall with PureMac" + // appears when an .app bundle is right-clicked (issue #109). + NSApp.servicesProvider = self + NSUpdateDynamicServices() + } + + /// Finder Services entry point. Declared in Info.plist as NSMessage + /// `uninstallApp`; receives the right-clicked .app via the pasteboard and + /// hands it to AppState through a notification. Brings PureMac forward so + /// the user lands on the uninstall scan. + @objc func uninstallApp(_ pboard: NSPasteboard, + userData: String?, + error: AutoreleasingUnsafeMutablePointer?) { + let urls = (pboard.readObjects(forClasses: [NSURL.self], + options: [.urlReadingFileURLsOnly: true]) as? [URL]) ?? [] + guard let appURL = urls.first(where: { $0.pathExtension == "app" }) else { + error?.pointee = "Select an application (.app) to uninstall." as NSString + return + } + NSApp.activate(ignoringOtherApps: true) + // Buffer the path for the cold-launch case (AppState may not exist yet, + // and NotificationCenter does not replay); AppState drains it in init. + ExternalUninstallBuffer.pendingPath = appURL.path + NotificationCenter.default.post( + name: .pureMacExternalUninstall, + object: nil, + userInfo: ["path": appURL.path] + ) + } + + /// Create or tear down the menu-bar status item to match the current + /// Settings toggle. Posted to whenever the toggle flips so it takes effect + /// without a relaunch. + @objc func syncMenuBarMonitor() { + let enabled = UserDefaults.standard.bool(forKey: "settings.general.menuBarMonitor") + if enabled, menuBarController == nil { + menuBarController = MenuBarController() + } else if !enabled, let controller = menuBarController { + controller.teardown() + menuBarController = nil + } + } +} + +extension Notification.Name { + /// Posted when the "Show system monitor in menu bar" Settings toggle flips, + /// so AppDelegate can add/remove the status item live. + static let pureMacMenuBarMonitorChanged = Notification.Name("PureMac.MenuBarMonitorChanged") +} + +@main +struct PureMacApp: App { + @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + @StateObject private var appState = AppState() + @StateObject private var theme = ThemeManager.shared + @AppStorage("PureMac.OnboardingComplete") private var onboardingComplete = false + + init() { + // Enter CLI mode only when the first arg is a known command. Xcode and + // LaunchServices inject args like -NSDocumentRevisionsDebugMode and + // -psn_ that must not be interpreted as CLI commands. + if let first = CommandLine.arguments.dropFirst().first, + CLI.isKnownCommand(first) { + CLI.run() + } + } + + var body: some Scene { + WindowGroup(id: "main") { + Group { + if onboardingComplete { + MainWindow() + .environmentObject(appState) + .frame(minWidth: 900, minHeight: 600) + } else { + OnboardingView(isComplete: $onboardingComplete) + } + } + .environmentObject(theme) + .preferredColorScheme(theme.appearance.colorScheme) + // Record the openWindow action so the menu-bar popover can reopen + // this window after it's been closed (the popover lives outside the + // scene graph and can't use openWindow itself). + .background(WindowOpenerCapture()) + } + .windowStyle(.automatic) + .windowToolbarStyle(.unified) + .windowResizability(.contentMinSize) + .defaultSize(width: 1000, height: 680) + .commands { + CommandGroup(replacing: .newItem) {} + CommandMenu("Updates") { + Button("Check for Updates") { + UpdateService.shared.checkForUpdates() + } + .keyboardShortcut("u", modifiers: [.command, .shift]) + } + } + + Settings { + SettingsView() + .environmentObject(appState) + } + + // The opt-in menu-bar system monitor is an AppKit NSStatusItem managed + // by AppDelegate/MenuBarController rather than a SwiftUI MenuBarExtra: + // a conditional `.window`-style MenuBarExtra fails to type-check, and an + // unconditional one sets up status-item machinery that hangs the XCTest + // host. The AppKit controller is only created when enabled and never + // under tests, sidestepping both problems. + } +} diff --git a/PureMac/Services/CleaningEngine.swift b/PureMac/Services/CleaningEngine.swift new file mode 100644 index 0000000..5dc3c1b --- /dev/null +++ b/PureMac/Services/CleaningEngine.swift @@ -0,0 +1,374 @@ +import Foundation + +actor CleaningEngine { + private let fileManager = FileManager.default + + struct CleaningResult { + var freedSpace: Int64 = 0 + var itemsCleaned: Int = 0 + var errors: [String] = [] + var cleanedPaths: Set = [] + // Items that user-level FileManager.removeItem refused with EACCES / + // EPERM. These are root-owned and need an admin-privileged second + // pass via cleanWithAdminPrivileges(items:). + var requiresAdmin: [CleanableItem] = [] + } + + // MARK: - Public API + + func cleanItems(_ items: [CleanableItem], progressHandler: @Sendable (Double) -> Void) async -> CleaningResult { + var result = CleaningResult() + let total = items.count + + for (index, item) in items.enumerated() { + let progress = Double(index + 1) / Double(total) + progressHandler(progress) + + if item.category == .purgeableSpace { + let purged = await purgePurgeableSpace() + result.freedSpace += purged + if purged > 0 { result.itemsCleaned += 1 } + // Purgeable space is a one-shot reclaim action, not a file + // unlink. Mark it handled so it isn't later mistaken for an + // item that "couldn't be removed" (the purge ran regardless of + // how much APFS chose to release). See issue #112. + result.cleanedPaths.insert(item.path) + continue + } + + do { + let itemURL = URL(fileURLWithPath: item.path) + guard fileManager.fileExists(atPath: item.path) else { continue } + + // Security: resolve symlinks, validate the real path, delete + // through the resolved URL. Deleting through the unresolved + // path lets an attacker-at-same-UID swap a component to a + // symlink after the check and have us follow it. + let resolvedURL = itemURL.resolvingSymlinksInPath() + let resolved = resolvedURL.path + + // Large files surfaced by scanLargeFiles are per-file items + // under Downloads/Documents/Desktop; those get a narrower check + // instead of the whole-subtree allow-list. + let pathAccepted: Bool = { + if item.category == .largeFiles { + return isExplicitSingleFileDeletable(resolvedPath: resolved) + } + return isSafeToDelete(resolvedPath: resolved) + }() + guard pathAccepted else { + let msg = "Skipped symlink or unsafe path: \(item.path) -> \(resolved)" + Logger.shared.log(msg, level: .warning) + result.errors.append(msg) + continue + } + + // Narrow the TOCTOU window: re-resolve right before the delete + // and require the resolved path to still match. Any concurrent + // swap between check and delete aborts the operation. + let reResolved = URL(fileURLWithPath: item.path).resolvingSymlinksInPath().path + guard reResolved == resolved else { + let msg = "Aborting delete: path resolution changed between check and unlink for \(item.path)" + Logger.shared.log(msg, level: .warning) + result.errors.append(msg) + continue + } + + try fileManager.removeItem(at: resolvedURL) + result.freedSpace += item.size + result.itemsCleaned += 1 + result.cleanedPaths.insert(item.path) + } catch { + let nsError = error as NSError + let isPermissionDenied = + (nsError.domain == NSCocoaErrorDomain && + (nsError.code == NSFileWriteNoPermissionError || + nsError.code == NSFileReadNoPermissionError)) || + (nsError.domain == NSPOSIXErrorDomain && + (nsError.code == Int(EACCES) || nsError.code == Int(EPERM))) + if isPermissionDenied { + // Defer to the admin pass — these are typically root-owned + // system caches that the user-level process can't unlink. + result.requiresAdmin.append(item) + Logger.shared.log("Deferring to admin pass: \(item.path)", level: .info) + } else { + let detail = "\(item.name) at \(item.path): \(error.localizedDescription)" + result.errors.append(detail) + Logger.shared.log("Clean failed: \(detail)", level: .error) + } + } + } + + return result + } + + func cleanCategory(_ result: CategoryResult, progressHandler: @Sendable (Double) -> Void) async -> CleaningResult { + let selectedItems = result.items.filter { $0.isSelected } + return await cleanItems(selectedItems, progressHandler: progressHandler) + } + + /// Re-runs the deletion of the supplied items as root via NSAppleScript's + /// "with administrator privileges" clause. Triggers exactly one auth + /// prompt for the whole batch (macOS caches the credential for ~5 min). + /// + /// Every path is re-validated against the same allow-list as the user- + /// level pass (isSafeToDelete / isExplicitSingleFileDeletable) before it + /// gets handed off to /bin/rm. Paths are passed via a NUL-separated + /// temp file consumed by xargs -0, so no shell-quoting pitfalls. + func cleanWithAdminPrivileges(items: [CleanableItem]) async -> CleaningResult { + var result = CleaningResult() + + Logger.shared.log("Admin pass starting with \(items.count) item(s)", level: .info) + + // Re-validate. Don't trust the caller — anything not on the allow-list + // refuses to escalate. + let validated: [(item: CleanableItem, resolved: String)] = items.compactMap { item in + let resolved = URL(fileURLWithPath: item.path).resolvingSymlinksInPath().path + let accepted: Bool = { + if item.category == .largeFiles { + return isExplicitSingleFileDeletable(resolvedPath: resolved) + } + return isSafeToDelete(resolvedPath: resolved) || isSafeUninstallEscalationPath(resolved) + }() + if !accepted { + Logger.shared.log("Refusing admin escalation for unsafe path: \(item.path)", level: .warning) + } + return accepted ? (item, resolved) : nil + } + guard !validated.isEmpty else { + Logger.shared.log("Admin pass: no items survived validation", level: .warning) + return result + } + + // Stage paths NUL-separated so newlines/spaces in paths don't matter. + let staged = validated.map(\.resolved).joined(separator: "\u{0}") + guard let payload = staged.data(using: .utf8) else { return result } + + let tempFile = FileManager.default.temporaryDirectory + .appendingPathComponent("puremac-rm-\(UUID().uuidString)") + do { + try payload.write(to: tempFile, options: [.atomic]) + } catch { + Logger.shared.log("Couldn't stage admin path list: \(error.localizedDescription)", level: .error) + return result + } + defer { try? FileManager.default.removeItem(at: tempFile) } + + let quotedTempPath = shellSingleQuoted(tempFile.path) + let script = """ + do shell script "/usr/bin/xargs -0 /bin/rm -rf -- < \(quotedTempPath)" with administrator privileges + """ + + let runResult: (success: Bool, error: String?) = await withCheckedContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + let appleScript = NSAppleScript(source: script) + var errorInfo: NSDictionary? + appleScript?.executeAndReturnError(&errorInfo) + if let errorInfo { + continuation.resume(returning: (false, "\(errorInfo)")) + } else { + continuation.resume(returning: (true, nil)) + } + } + } + + guard runResult.success else { + // -128 is "user cancelled" — log quietly, no need for an error row. + if let err = runResult.error, !err.contains("-128") { + Logger.shared.log("Admin clean failed: \(err)", level: .error) + result.errors.append("Administrator authorization failed") + } + return result + } + + // Verify which items actually disappeared. xargs may have reported a + // partial failure even when the AppleScript exited cleanly, so we + // re-stat every path rather than trust the script's exit status. + for (item, resolved) in validated { + if !FileManager.default.fileExists(atPath: resolved) { + result.cleanedPaths.insert(item.path) + result.itemsCleaned += 1 + result.freedSpace += item.size + } else { + let detail = "\(item.name) at \(item.path) survived admin removal" + result.errors.append(detail) + Logger.shared.log("Admin pass survivor: \(detail)", level: .error) + } + } + Logger.shared.log("Admin pass complete: \(result.itemsCleaned) deleted, \(result.errors.count) survived", level: .info) + return result + } + + // MARK: - Purgeable Space + + func purgePurgeableSpace() async -> Int64 { + // Get current purgeable space first + let beforeFree = getCurrentFreeSpace() + + let task = Process() + task.executableURL = URL(fileURLWithPath: "/usr/sbin/diskutil") + task.arguments = ["apfs", "purgePurgeable", "/"] + let pipe = Pipe() + task.standardOutput = pipe + task.standardError = Pipe() + + do { + try task.run() + task.waitUntilExit() + + let afterFree = getCurrentFreeSpace() + let freedSpace = afterFree - beforeFree + return max(0, freedSpace) + } catch { + Logger.shared.log("diskutil purge failed: \(error.localizedDescription)", level: .error) + return 0 + } + } + + // MARK: - Trash + + func emptyTrash() async -> Int64 { + let home = fileManager.homeDirectoryForCurrentUser.path + let trashPath = "\(home)/.Trash" + var totalFreed: Int64 = 0 + + do { + let contents = try fileManager.contentsOfDirectory(atPath: trashPath) + for item in contents { + let fullPath = (trashPath as NSString).appendingPathComponent(item) + if let attrs = try? fileManager.attributesOfItem(atPath: fullPath) { + totalFreed += (attrs[.size] as? Int64) ?? 0 + } + try fileManager.removeItem(atPath: fullPath) + } + } catch { + Logger.shared.log("Trash cleanup incomplete: \(error.localizedDescription)", level: .warning) + } + + return totalFreed + } + + // MARK: - Helpers + + /// Validates that a resolved path is safe to delete. + /// Prevents symlink attacks where a link in ~/Library/Caches points to ~/.ssh. + /// Downloads, Documents, and Desktop are intentionally NOT whole-subtree + /// allow-listed - scanLargeFiles emits per-file items instead, so those + /// deletions can still happen through the explicit per-item flow. + private func isSafeToDelete(resolvedPath: String) -> Bool { + let home = fileManager.homeDirectoryForCurrentUser.path + let allowedRoots = [ + "\(home)/Library/Caches", + "\(home)/Library/Logs", + "\(home)/Library/Saved Application State", + "\(home)/Library/HTTPStorages", + "\(home)/Library/WebKit", + "\(home)/Library/Containers", + "\(home)/Library/Group Containers", + "\(home)/Library/Application Support", + "\(home)/Library/Preferences", + "\(home)/Library/LaunchAgents", + "\(home)/Library/Mail Downloads", + "\(home)/Library/Developer/Xcode/DerivedData", + "\(home)/Library/Developer/Xcode/Archives", + "\(home)/Library/Developer/CoreSimulator/Caches", + "\(home)/.Trash", + "\(home)/.npm", + "\(home)/.cache", + "\(home)/Library/Containers/com.docker.docker", + "/Library/Caches", + "/Library/Logs", + "/private/var/log", + "/private/var/tmp", + // /var is a symlink to /private/var, and resolvingSymlinksInPath + // gives the /var form. Both spellings must be allow-listed or + // every system log/tmp deletion silently fails the safety check. + "/var/log", + "/var/tmp", + "/tmp", + ] + // Either the path equals an allow-listed root (whole-subtree wipe by + // the scanner that emits the root itself, e.g. DerivedData) or it + // sits strictly inside one. The trailing "/" on the prefix match + // prevents siblings like "/tmpfoo" from sneaking past "/tmp". + let normalized = (resolvedPath as NSString).standardizingPath + return allowedRoots.contains { root in + if normalized == root { return true } + let rootWithSeparator = root.hasSuffix("/") ? root : root + "/" + return normalized.hasPrefix(rootWithSeparator) + } + } + + /// Allows the app uninstaller to escalate only the protected roots it owns: + /// app bundles, package receipts, and launch plists. This intentionally + /// stays narrower than the normal cleaner allow-list. + private func isSafeUninstallEscalationPath(_ path: String) -> Bool { + let normalized = (path as NSString).standardizingPath + let home = fileManager.homeDirectoryForCurrentUser.path + + return isAppBundlePath(normalized, rootedAt: "/Applications") + || isAppBundlePath(normalized, rootedAt: "\(home)/Applications") + || isReceiptPath(normalized, rootedAt: "/private/var/db/receipts") + || isReceiptPath(normalized, rootedAt: "/var/db/receipts") + || isPlistUnder(normalized, root: "/Library/LaunchDaemons") + || isPlistUnder(normalized, root: "/Library/LaunchAgents") + } + + private func isAppBundlePath(_ path: String, rootedAt root: String) -> Bool { + guard isInside(path, root: root) else { return false } + let normalizedRoot = (root as NSString).standardizingPath + guard path != normalizedRoot else { return false } + let rootWithSeparator = normalizedRoot.hasSuffix("/") ? normalizedRoot : normalizedRoot + "/" + let relative = String(path.dropFirst(rootWithSeparator.count)) + return relative.split(separator: "/").contains { component in + component.lowercased().hasSuffix(".app") + } + } + + private func isReceiptPath(_ path: String, rootedAt root: String) -> Bool { + let parent = ((path as NSString).deletingLastPathComponent as NSString).standardizingPath + guard parent == (root as NSString).standardizingPath else { return false } + let ext = (path as NSString).pathExtension.lowercased() + return ext == "plist" || ext == "bom" + } + + private func isPlistUnder(_ path: String, root: String) -> Bool { + let parent = ((path as NSString).deletingLastPathComponent as NSString).standardizingPath + return parent == (root as NSString).standardizingPath && (path as NSString).pathExtension.lowercased() == "plist" + } + + private func isInside(_ path: String, root: String) -> Bool { + let normalizedRoot = (root as NSString).standardizingPath + if path == normalizedRoot { return true } + let rootWithSeparator = normalizedRoot.hasSuffix("/") ? normalizedRoot : normalizedRoot + "/" + return path.hasPrefix(rootWithSeparator) + } + + private func shellSingleQuoted(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" + } + + /// Allow a single-file delete under Downloads/Documents/Desktop when it + /// was explicitly surfaced by a scanner (e.g. scanLargeFiles). Whole-subtree + /// deletion of those roots remains blocked. + func isExplicitSingleFileDeletable(resolvedPath: String) -> Bool { + let home = fileManager.homeDirectoryForCurrentUser.path + let perFileRoots = [ + "\(home)/Downloads/", + "\(home)/Documents/", + "\(home)/Desktop/", + ] + let normalized = (resolvedPath as NSString).standardizingPath + return perFileRoots.contains { normalized.hasPrefix($0) } + } + + private func getCurrentFreeSpace() -> Int64 { + do { + let attrs = try fileManager.attributesOfFileSystem(forPath: "/") + return (attrs[.systemFreeSize] as? Int64) ?? 0 + } catch { + Logger.shared.log("Cannot read filesystem attributes: \(error.localizedDescription)", level: .warning) + return 0 + } + } +} diff --git a/PureMac/Services/FullDiskAccessManager.swift b/PureMac/Services/FullDiskAccessManager.swift new file mode 100644 index 0000000..d33ba36 --- /dev/null +++ b/PureMac/Services/FullDiskAccessManager.swift @@ -0,0 +1,122 @@ +import AppKit +import Foundation + +/// Detects whether Full Disk Access (FDA) has been granted to PureMac. +/// Without FDA, macOS TCC blocks access to ~/Library/Mail, ~/Library/Safari, +/// /Library/Application Support/com.apple.TCC, and other protected locations. +final class FullDiskAccessManager { + static let shared = FullDiskAccessManager() + + private init() {} + + /// Check if Full Disk Access is granted by attempting a real read of a + /// TCC-protected file. We use FileHandle/Data — not isReadableFile or + /// fileExists — because the metadata APIs short-circuit before TCC fires + /// and so don't register the calling app in the FDA list. + var hasFullDiskAccess: Bool { + let probes = [ + "/Library/Application Support/com.apple.TCC/TCC.db", + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Safari/CloudTabs.db").path, + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Mail").path, + ] + + for path in probes { + if canActuallyRead(path: path) { return true } + } + return false + } + + /// Real-read probe. Performs the syscall TCC actually evaluates, so + /// the OS records PureMac as the requester and adds it to the FDA pane. + /// Directories use contentsOfDirectory; files use FileHandle. + @discardableResult + private func canActuallyRead(path: String) -> Bool { + var isDir: ObjCBool = false + guard FileManager.default.fileExists(atPath: path, isDirectory: &isDir) else { + return false + } + if isDir.boolValue { + do { + _ = try FileManager.default.contentsOfDirectory(atPath: path) + return true + } catch { + return false + } + } else { + guard let handle = try? FileHandle(forReadingFrom: URL(fileURLWithPath: path)) else { + return false + } + defer { try? handle.close() } + return (try? handle.read(upToCount: 1)) != nil + } + } + + /// Force PureMac to appear in the Full Disk Access list. + /// + /// The OS only registers an app in the FDA pane after that app itself + /// makes a TCC-gated syscall. Metadata lookups (fileExists, isReadableFile) + /// don't qualify, and delegating to Finder via AppleScript registers + /// *Finder* — not PureMac. So at launch we touch a small set of paths + /// gated specifically by the SystemPolicyAllFiles (FDA) service. The reads + /// will fail until the user grants access — that's fine, the failed + /// attempts are what register us. + /// + /// We intentionally do NOT probe Messages, Contacts, Calendars, or other + /// paths gated by separate TCC services. Touching those would register + /// PureMac in unrelated permission lists ("Messages", "Contacts", etc.) + /// even though we never use them — confusing and a privacy regression. + func triggerRegistration() { + DispatchQueue.global(qos: .utility).async { + let home = FileManager.default.homeDirectoryForCurrentUser.path + // FDA-only probes. Each path here is read-gated by + // SystemPolicyAllFiles, nothing else. + let probePaths = [ + "/Library/Application Support/com.apple.TCC/TCC.db", + "\(home)/Library/Mail", + "\(home)/Library/Safari/CloudTabs.db", + "\(home)/Library/Safari/Bookmarks.plist", + "\(home)/Library/Cookies", + "\(home)/Library/HTTPStorages", + "\(home)/Library/Application Support/com.apple.TCC", + ] + for path in probePaths { + _ = self.canActuallyRead(path: path) + } + } + } + + /// Opens System Settings to the Full Disk Access pane. + func openFullDiskAccessSettings() { + let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles")! + NSWorkspace.shared.open(url) + } + + /// Reveal the running PureMac.app bundle in Finder so the user can drag + /// it into the Full Disk Access list when the OS hasn't auto-registered + /// it (common with Homebrew installs that strip the quarantine attribute). + func revealAppInFinder() { + let bundleURL = Bundle.main.bundleURL + NSWorkspace.shared.activateFileViewerSelecting([bundleURL]) + } + + /// Reset PureMac's TCC entries so the OS can re-register the bundle. + /// Useful when the bundle was replaced (Homebrew upgrade, manual move) + /// and the existing TCC row points at a stale code-signing identity. + /// Returns true if the reset command exited cleanly. + @discardableResult + func resetFullDiskAccess() -> Bool { + let bundleID = Bundle.main.bundleIdentifier ?? "com.puremac.app" + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/tccutil") + process.arguments = ["reset", "SystemPolicyAllFiles", bundleID] + do { + try process.run() + process.waitUntilExit() + return process.terminationStatus == 0 + } catch { + return false + } + } +} diff --git a/PureMac/Services/Haptics.swift b/PureMac/Services/Haptics.swift new file mode 100644 index 0000000..7a5c3f2 --- /dev/null +++ b/PureMac/Services/Haptics.swift @@ -0,0 +1,57 @@ +import AppKit + +/// Thin wrapper around `NSHapticFeedbackManager` so call sites don't have to +/// reach into AppKit. Only Force Touch trackpads produce a click; on other +/// hardware the calls are no-ops, which is the documented behavior. +/// +/// We deliberately use the system performer rather than spinning up a custom +/// haptic engine — the standard patterns (alignment / levelChange / generic) +/// already match what users expect from Apple's own apps. +enum Haptics { + /// Light tick — use for transient UI feedback like advancing a page or + /// flipping a toggle. Cheapest of the three. + static func tap() { + perform(.alignment) + } + + /// Stronger affirmation — use when a user-visible state change crosses a + /// boundary (Full Disk Access granted, scan completed, clean finished). + static func success() { + perform(.levelChange) + } + + /// Generic feedback — fallback when nothing else fits. + static func generic() { + perform(.generic) + } + + /// Defaults key for the user-facing "Play sound effects" toggle + /// (Settings → General). Defaults to on. + static let soundEffectsKey = "PureMac.SoundEffects" + + private static var soundEnabled: Bool { + UserDefaults.standard.object(forKey: soundEffectsKey) as? Bool ?? true + } + + /// Completion beat: haptic + the system "Glass" chime in one call so the + /// two always land together. The sound intentionally still plays under + /// Reduce Motion — there it stands in for the suppressed confetti. + static func successWithSound() { + success() + if soundEnabled { + NSSound(named: "Glass")?.play() + } + } + + /// Failure beat: haptic + the system "Basso" thud. + static func errorWithSound() { + generic() + if soundEnabled { + NSSound(named: "Basso")?.play() + } + } + + private static func perform(_ pattern: NSHapticFeedbackManager.FeedbackPattern) { + NSHapticFeedbackManager.defaultPerformer.perform(pattern, performanceTime: .now) + } +} diff --git a/PureMac/Services/MenuBarController.swift b/PureMac/Services/MenuBarController.swift new file mode 100644 index 0000000..e5afcbb --- /dev/null +++ b/PureMac/Services/MenuBarController.swift @@ -0,0 +1,88 @@ +import AppKit +import SwiftUI +import Combine + +/// Captures SwiftUI's `openWindow` action so AppKit surfaces (the menu-bar +/// popover, which lives outside the scene graph and has no working `openWindow` +/// environment) can reopen the main window after it has been closed. The main +/// window records the action on appear; the closure stays valid for the app's +/// lifetime even once the window is gone. +@MainActor +final class WindowOpener { + static let shared = WindowOpener() + var open: ((String) -> Void)? + private init() {} +} + +/// AppKit-backed menu-bar system monitor. A SwiftUI `MenuBarExtra` was avoided +/// here: a conditional `.window`-style `MenuBarExtra` fails to type-check, and +/// an unconditional one stalls the XCTest host's run loop. An `NSStatusItem` +/// driving an `NSPopover` (which hosts the existing SwiftUI `MenuBarMonitorView`) +/// gives the same UI with full create/destroy control and no test-host impact. +@MainActor +final class MenuBarController: NSObject, NSPopoverDelegate { + private let statusItem: NSStatusItem + private let popover = NSPopover() + private let monitor = SystemMonitor.shared + private var cancellable: AnyCancellable? + + override init() { + statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + super.init() + + // Persist the user's show/hide choice and ensure the item is requested + // visible (it defaults hidden when restored from a prior autosave state). + statusItem.autosaveName = "PureMacSystemMonitor" + statusItem.isVisible = true + + monitor.start() + + if let button = statusItem.button { + button.image = NSImage( + systemSymbolName: "gauge.with.dots.needle.67percent", + accessibilityDescription: "System Monitor" + ) + button.imagePosition = .imageLeading + button.target = self + button.action = #selector(togglePopover) + updateTitle() + } + + popover.behavior = .transient + popover.contentSize = NSSize(width: 248, height: 230) + popover.contentViewController = NSHostingController(rootView: MenuBarMonitorView()) + popover.delegate = self + + // Refresh the menu-bar CPU readout each time the monitor samples. + cancellable = monitor.$cpuUsage + .receive(on: RunLoop.main) + .sink { [weak self] _ in self?.updateTitle() } + } + + /// Remove the status item and release the monitor observer. Called by + /// AppDelegate before dropping the controller so teardown runs on the main + /// actor (a `@MainActor` deinit cannot touch isolated state safely). + func teardown() { + cancellable?.cancel() + cancellable = nil + if popover.isShown { popover.performClose(nil) } + NSStatusBar.system.removeStatusItem(statusItem) + monitor.stop() + } + + private func updateTitle() { + guard let button = statusItem.button else { return } + button.title = " \(Int((monitor.cpuUsage * 100).rounded()))%" + } + + @objc private func togglePopover() { + guard let button = statusItem.button else { return } + if popover.isShown { + popover.performClose(nil) + } else { + NSApp.activate(ignoringOtherApps: true) + popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) + popover.contentViewController?.view.window?.makeKey() + } + } +} diff --git a/PureMac/Services/PermissionCoordinator.swift b/PureMac/Services/PermissionCoordinator.swift new file mode 100644 index 0000000..bec4c66 --- /dev/null +++ b/PureMac/Services/PermissionCoordinator.swift @@ -0,0 +1,211 @@ +import AppKit +import Combine +import Foundation + +/// Centralized coordinator for Full Disk Access (FDA) prompts and retry flow. +/// +/// Replaces the bare "Open System Settings" alert with a sheet-driven flow that: +/// - Auto-opens the Settings pane and reveals PureMac.app in Finder so users can +/// drag the bundle into the FDA list when macOS hasn't auto-registered it +/// (common with Homebrew installs that strip the quarantine attribute). +/// - Polls `FullDiskAccessManager.hasFullDiskAccess` once per second while the +/// sheet is on-screen and auto-dismisses + invokes the retry callback the +/// moment the user toggles permission on. +/// - Carries the failed-item batch across the prompt so the caller can re-run +/// the clean without the user having to re-select anything. +@MainActor +final class PermissionCoordinator: ObservableObject { + static let shared = PermissionCoordinator() + + @Published private(set) var isRequesting: Bool = false + @Published private(set) var hasFullDiskAccess: Bool = false + @Published private(set) var failedItemPaths: [String] = [] + @Published private(set) var context: PromptContext = .general + + enum PromptContext { + case general + case cleanup(failedCount: Int) + case uninstall(appName: String, failedCount: Int) + + var headline: String { + switch self { + case .general: + return String(localized: "Grant Full Disk Access") + case .cleanup(let n): + return String( + format: String(localized: "%lld item(s) need Full Disk Access"), + Int64(n) + ) + case .uninstall(let app, let n): + return String( + format: String(localized: "Uninstalling %@: %lld file(s) need Full Disk Access"), + app, + Int64(n) + ) + } + } + } + + private var pollTimer: Timer? + private var onGrantCallback: (() -> Void)? + private var pendingGrantWork: DispatchWorkItem? + + private init() { + hasFullDiskAccess = FullDiskAccessManager.shared.hasFullDiskAccess + } + + /// Begin the request flow. `onGranted` fires exactly once when permission + /// is detected, regardless of whether the sheet was open or already closed. + /// + /// Re-entrant: if a request is already in flight, the new context and + /// callback replace the previous ones (last writer wins) but the polling + /// timer is not duplicated. Prevents a rapid double-tap from spinning up + /// two Timers or leaking the first callback's captured state. + func requestAccess( + context: PromptContext = .general, + failedPaths: [String] = [], + onGranted: @escaping () -> Void + ) { + // Drop the previous callback AND cancel any pending grant work + // before installing the new one. Without the cancel, a second + // requestAccess() during the 1-second post-grant delay would fire + // both callbacks — the queued one for the prior batch plus the + // new one we're installing here. + onGrantCallback = nil + pendingGrantWork?.cancel() + pendingGrantWork = nil + + self.context = context + self.failedItemPaths = failedPaths + self.onGrantCallback = onGranted + self.hasFullDiskAccess = FullDiskAccessManager.shared.hasFullDiskAccess + + if hasFullDiskAccess { + // Already granted. Fire immediately and skip the sheet — useful for + // retry-button paths where the user may have granted access in + // another window between the original failure and the retry tap. + onGranted() + onGrantCallback = nil + return + } + + isRequesting = true + // startPolling stops any existing timer first, so re-entry is safe. + startPolling() + } + + /// Open System Settings AND reveal PureMac.app in Finder so the user can + /// drag the bundle directly into the FDA list. Side-by-side windows are + /// the fastest path when macOS hasn't auto-registered the app. + func openSettingsAndReveal() { + FullDiskAccessManager.shared.openFullDiskAccessSettings() + // Delay the reveal slightly so Settings is the frontmost window first. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { + FullDiskAccessManager.shared.revealAppInFinder() + } + } + + /// Reset PureMac's TCC entry and re-prime registration. Use when the user + /// reports PureMac doesn't appear in the FDA list (typical after Homebrew + /// reinstall replaces the bundle with a different code signature). + func resetAndReprime() { + _ = FullDiskAccessManager.shared.resetFullDiskAccess() + FullDiskAccessManager.shared.triggerRegistration() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { + FullDiskAccessManager.shared.openFullDiskAccessSettings() + } + } + + func dismiss(callRetry: Bool = false) { + stopPolling() + // Cancel any pending 1-second post-grant retry — without this, a user + // who skips the sheet during the success-display delay would still + // see the retry execute, which contradicts their explicit dismiss. + pendingGrantWork?.cancel() + pendingGrantWork = nil + let callback = onGrantCallback + onGrantCallback = nil + isRequesting = false + failedItemPaths = [] + context = .general + if callRetry { callback?() } + } + + /// Refresh permission status without armed polling. Cheap to call on + /// app-becomes-active. + /// + /// If a request is in flight and we just observed the grant, route + /// through `handleGrant` so the sheet dismisses and the callback fires. + /// Without this, refreshStatus would flip `hasFullDiskAccess` to true + /// and the next poll tick's `granted && !hasFullDiskAccess` guard + /// would skip `handleGrant` — the sheet would sit open in a granted + /// state and never fire the retry. + func refreshStatus() { + Task.detached(priority: .userInitiated) { + let granted = FullDiskAccessManager.shared.hasFullDiskAccess + await MainActor.run { [weak self] in + guard let self else { return } + let wasGranted = self.hasFullDiskAccess + self.hasFullDiskAccess = granted + if granted && !wasGranted && self.isRequesting { + self.handleGrant() + } + } + } + } + + // MARK: - Polling + + private func startPolling() { + stopPolling() + pollTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + Task { @MainActor [weak self] in + guard let self else { return } + let granted = FullDiskAccessManager.shared.hasFullDiskAccess + if granted && !self.hasFullDiskAccess { + self.hasFullDiskAccess = true + self.handleGrant() + } else { + self.hasFullDiskAccess = granted + } + } + } + // Run once immediately so a user who granted before opening the sheet + // doesn't wait a full poll cycle. + Task { @MainActor [weak self] in + guard let self else { return } + if FullDiskAccessManager.shared.hasFullDiskAccess { + self.hasFullDiskAccess = true + self.handleGrant() + } + } + } + + private func stopPolling() { + pollTimer?.invalidate() + pollTimer = nil + } + + private func handleGrant() { + let callback = onGrantCallback + onGrantCallback = nil + stopPolling() + Haptics.success() + // Cancel any prior pending grant work — guarantees the callback fires + // at most once even if grant is detected twice in rapid succession + // (e.g. immediate-read + first poll tick both fire). + pendingGrantWork?.cancel() + let work = DispatchWorkItem { [weak self] in + guard let self else { return } + self.isRequesting = false + self.failedItemPaths = [] + self.context = .general + self.pendingGrantWork = nil + callback?() + } + pendingGrantWork = work + // Give the success state ~1s on screen so the user sees the + // confirmation tick before the sheet snaps closed. + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0, execute: work) + } +} diff --git a/PureMac/Services/ScanEngine.swift b/PureMac/Services/ScanEngine.swift new file mode 100644 index 0000000..882cbd6 --- /dev/null +++ b/PureMac/Services/ScanEngine.swift @@ -0,0 +1,1027 @@ +import Foundation + +actor ScanEngine { + private let fileManager = FileManager.default + private let home = FileManager.default.homeDirectoryForCurrentUser.path + + /// Live path reporter for the dashboard's scanning ticker. Throttled so + /// a directory with thousands of entries doesn't flood the main actor. + private var onPath: (@Sendable (String) -> Void)? + private var lastReport = Date.distantPast + + /// `path` is an autoclosure so the String is only materialized after the + /// throttle gate passes — a deep home-directory walk enumerates hundreds + /// of thousands of entries and only ~12/sec are ever displayed. + private func report(_ path: @autoclosure () -> String) { + guard let onPath else { return } + let now = Date() + guard now.timeIntervalSince(lastReport) > 0.1 else { return } + lastReport = now + onPath(path()) + } + + private struct CleanupTarget { + let name: String + let path: String + let isSelected: Bool + let minimumSize: Int64 + + init(name: String, path: String, isSelected: Bool = true, minimumSize: Int64 = 1024) { + self.name = name + self.path = path + self.isSelected = isSelected + self.minimumSize = minimumSize + } + } + + // MARK: - Public API + + func scanCategory( + _ category: CleaningCategory, + onPath: (@Sendable (String) -> Void)? = nil + ) async -> CategoryResult { + self.onPath = onPath + defer { self.onPath = nil } + switch category { + case .smartScan: + return CategoryResult(category: category, items: [], totalSize: 0) + case .systemJunk: + return scanSystemJunk() + case .userCache: + return scanUserCache() + case .aiApps: + return scanAIApps() + case .mailAttachments: + return scanMailAttachments() + case .trashBins: + return scanTrash() + case .largeFiles: + return scanLargeFiles() + case .purgeableSpace: + return scanPurgeableSpace() + case .xcodeJunk: + return scanXcodeJunk() + case .brewCache: + return scanBrewCache() + case .nodeCache: + return scanNodeCache() + case .dockerCache: + return scanDockerCache() + } + } + + func getDiskInfo() -> DiskInfo { + var info = DiskInfo() + do { + let attrs = try fileManager.attributesOfFileSystem(forPath: "/") + if let total = attrs[.systemSize] as? Int64 { + info.totalSpace = total + } + if let free = attrs[.systemFreeSize] as? Int64 { + info.freeSpace = free + } + info.usedSpace = info.totalSpace - info.freeSpace + + // Use URLResourceValues for accurate purgeable space detection + let rootURL = URL(fileURLWithPath: "/") + let values = try rootURL.resourceValues(forKeys: [ + .volumeAvailableCapacityForImportantUsageKey, + .volumeAvailableCapacityKey + ]) + if let importantCapacity = values.volumeAvailableCapacityForImportantUsage, + let freeCapacity = values.volumeAvailableCapacity { + // Purgeable = important capacity (free + purgeable) minus actual free + let purgeable = importantCapacity - Int64(freeCapacity) + if purgeable > 10 * 1024 * 1024 { // Only report if > 10 MB + info.purgeableSpace = purgeable + } + } + } catch { + Logger.shared.log("Disk info unavailable: \(error.localizedDescription)", level: .warning) + } + return info + } + + // MARK: - Scanners + + private func scanSystemJunk() -> CategoryResult { + var items: [CleanableItem] = [] + var totalSize: Int64 = 0 + + let systemPaths = [ + "/Library/Caches", + "/Library/Logs", + "/private/var/log", + "\(home)/Library/Logs", + "/tmp", + "/private/var/tmp", + ] + + for path in systemPaths { + let scanned = scanDirectory(path: path, category: .systemJunk, recursive: true, maxDepth: 3) + items.append(contentsOf: scanned) + } + + totalSize = items.reduce(0) { $0 + $1.size } + return CategoryResult(category: .systemJunk, items: items, totalSize: totalSize) + } + + private func scanUserCache() -> CategoryResult { + var items: [CleanableItem] = [] + // Exclude cache roots claimed by dedicated categories to avoid double-counting. + let excludedRootPaths = Set([ + "\(home)/Library/Caches/Homebrew", + "\(home)/Library/Caches/com.electron.ollama", + "\(home)/Library/Caches/ollama", + ].map(normalizePath)) + + // Dynamically enumerate ~/Library/Caches/ so every subdirectory is visible + let cachePath = "\(home)/Library/Caches" + let scanned = scanDirectory( + path: cachePath, + category: .userCache, + recursive: false, + maxDepth: 1, + excluding: excludedRootPaths + ) + items.append(contentsOf: scanned) + + // Also scan for npm/pip/yarn caches + let devCaches = [ + "\(home)/.npm/_cacache", + "\(home)/.cache/pip", + "\(home)/.cache/yarn", + "\(home)/.cache/pnpm", + "\(home)/Library/Caches/pip", + ] + + for path in devCaches { + if let item = makeCleanupItem( + name: URL(fileURLWithPath: path).lastPathComponent, + path: path, + category: .userCache + ) { + items.append(item) + } + } + + let uniqueItems = deduplicatedItems(items) + let totalSize = uniqueItems.reduce(0) { $0 + $1.size } + return CategoryResult(category: .userCache, items: uniqueItems, totalSize: totalSize) + } + + private func scanAIApps() -> CategoryResult { + let targets = [ + CleanupTarget( + name: "Ollama Logs", + path: "\(home)/.ollama/logs" + ), + CleanupTarget( + name: "Ollama Cache", + path: "\(home)/Library/Caches/ollama" + ), + CleanupTarget( + name: "Ollama Electron Cache", + path: "\(home)/Library/Caches/com.electron.ollama" + ), + CleanupTarget( + name: "Ollama WebKit Data", + path: "\(home)/Library/WebKit/com.electron.ollama" + ), + CleanupTarget( + name: "Ollama Saved State", + path: "\(home)/Library/Saved Application State/com.electron.ollama.savedState" + ), + CleanupTarget( + name: "Ollama CLI Prompt History (Optional)", + path: "\(home)/.ollama/history", + isSelected: false, + minimumSize: 0 + ), + CleanupTarget( + name: "LM Studio Server Logs", + path: "\(home)/.lmstudio/server-logs" + ), + CleanupTarget( + name: "LM Studio Conversations (Optional)", + path: "\(home)/.lmstudio/conversations", + isSelected: false, + minimumSize: 0 + ), + ] + + let items = deduplicatedItems(targets.compactMap { target in + makeCleanupItem( + name: target.name, + path: target.path, + category: .aiApps, + isSelected: target.isSelected, + minimumSize: target.minimumSize + ) + }) + let totalSize = items.reduce(0) { $0 + $1.size } + return CategoryResult(category: .aiApps, items: items.sorted { $0.size > $1.size }, totalSize: totalSize) + } + + private func scanMailAttachments() -> CategoryResult { + var items: [CleanableItem] = [] + + let mailPaths = [ + "\(home)/Library/Mail Downloads", + "\(home)/Library/Containers/com.apple.mail/Data/Library/Mail Downloads", + ] + + for path in mailPaths { + let scanned = scanDirectory(path: path, category: .mailAttachments, recursive: true, maxDepth: 3) + items.append(contentsOf: scanned) + } + + let totalSize = items.reduce(0) { $0 + $1.size } + return CategoryResult(category: .mailAttachments, items: items, totalSize: totalSize) + } + + private func scanTrash() -> CategoryResult { + var items: [CleanableItem] = [] + + let trashPath = "\(home)/.Trash" + let scanned = scanDirectory(path: trashPath, category: .trashBins, recursive: false, maxDepth: 1) + items.append(contentsOf: scanned) + + let totalSize = items.reduce(0) { $0 + $1.size } + return CategoryResult(category: .trashBins, items: items, totalSize: totalSize) + } + + private func scanLargeFiles() -> CategoryResult { + var items: [CleanableItem] = [] + + // Honor the thresholds the user set in Settings → Cleaning. These keys + // were previously surfaced in the UI but never read here, so the sliders + // did nothing; defaults match the old hardcoded 100 MB / 12-month values. + let defaults = UserDefaults.standard + let thresholdMB = defaults.object(forKey: "settings.cleaning.largeFileThreshold") as? Int ?? 100 + let oldFileMonths = defaults.object(forKey: "settings.cleaning.oldFileMonths") as? Int ?? 12 + let minSize = Int64(max(1, thresholdMB)) * 1024 * 1024 + let oldCutoff = Calendar.current.date(byAdding: .month, value: -max(1, oldFileMonths), to: Date()) + ?? Date.distantPast + + // Folders the user excluded from the large-file scan (issue #121) — + // e.g. VM images, media libraries, project assets they never want + // surfaced. Files anywhere inside an excluded folder are skipped, and + // the directory subtree is pruned so we don't even walk it. + let excludedFolders = (defaults.stringArray(forKey: "settings.cleaning.largeFileExcludedFolders") ?? []) + .map(normalizePath) + .filter { !$0.isEmpty } + + // Honor the "Skip hidden files during scan" toggle, which was a dead + // control until now (no scanner read it). Default true preserves the + // previous always-skip behavior. + let skipHidden = defaults.object(forKey: "settings.cleaning.skipHiddenFiles") as? Bool ?? true + var enumerationOptions: FileManager.DirectoryEnumerationOptions = [.skipsPackageDescendants] + if skipHidden { enumerationOptions.insert(.skipsHiddenFiles) } + + func isExcluded(_ path: String) -> Bool { + let normalized = normalizePath(path) + return excludedFolders.contains { normalized == $0 || normalized.hasPrefix($0 + "/") } + } + + let searchPaths = [ + "\(home)/Downloads", + "\(home)/Documents", + "\(home)/Desktop", + ] + + for basePath in searchPaths { + guard let enumerator = fileManager.enumerator( + at: URL(fileURLWithPath: basePath), + includingPropertiesForKeys: [.fileSizeKey, .contentModificationDateKey, .isRegularFileKey], + options: enumerationOptions + ) else { continue } + + // No entry cap. A 5k cap was here previously and meant any user + // with hundreds of thousands of small files (e.g. node_modules + // checkouts) would never see anything past entry 5k — the + // scattered 100+ MB files were always past that bound. + for case let fileURL as URL in enumerator { + // Prune excluded subtrees: hitting the excluded directory itself + // skips its whole contents; for files the call is a harmless no-op. + // Skip the path normalization entirely when nothing is excluded. + if !excludedFolders.isEmpty, isExcluded(fileURL.path) { + enumerator.skipDescendants() + continue + } + report(fileURL.path) + guard let resourceValues = try? fileURL.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey, .isRegularFileKey]), + let isFile = resourceValues.isRegularFile, isFile, + let fileSize = resourceValues.fileSize + else { continue } + + let size = Int64(fileSize) + let modDate = resourceValues.contentModificationDate + + if size > minSize || (modDate != nil && modDate! < oldCutoff && size > 10 * 1024 * 1024) { + items.append(CleanableItem( + name: fileURL.lastPathComponent, + path: fileURL.path, + size: size, + category: .largeFiles, + isSelected: false, // Don't auto-select large files + lastModified: modDate + )) + } + } + } + + items.sort { $0.size > $1.size } + let totalSize = items.reduce(0) { $0 + $1.size } + return CategoryResult(category: .largeFiles, items: items, totalSize: totalSize) + } + + private func scanPurgeableSpace() -> CategoryResult { + var items: [CleanableItem] = [] + var totalSize: Int64 = 0 + + // Detect APFS purgeable space via URLResourceValues (no admin needed) + let diskInfo = getDiskInfo() + if diskInfo.purgeableSpace > 0 { + // Purgeable space is reclaimed via `diskutil apfs purgePurgeable /` + // (see CleaningEngine.purgePurgeableSpace), it is NOT a file that + // gets unlinked. Using "/" as the path made the UI render the root + // directory as the deletion target and triggered a bogus + // "couldn't remove /" error after cleaning. Use an empty path so + // the row shows no misleading filesystem location and the reveal- + // in-Finder action stays disabled for this entry. (See issue #112.) + items.append(CleanableItem( + name: "APFS Purgeable Space", + path: "", + size: diskInfo.purgeableSpace, + category: .purgeableSpace, + isSelected: true, + lastModified: nil + )) + totalSize = diskInfo.purgeableSpace + } + + // Also list Time Machine local snapshots if any exist + let snapshots = getLocalSnapshots() + for snapshot in snapshots { + let snapshotSize = snapshot.size > 0 ? snapshot.size : 0 + if snapshotSize > 0 { + items.append(CleanableItem( + name: "TM Snapshot: \(snapshot.name)", + path: snapshot.name, + size: snapshotSize, + category: .purgeableSpace, + isSelected: false, + lastModified: snapshot.date + )) + } + } + + return CategoryResult(category: .purgeableSpace, items: items, totalSize: totalSize) + } + + private func scanXcodeJunk() -> CategoryResult { + var items: [CleanableItem] = [] + + let xcodePaths = [ + "\(home)/Library/Developer/Xcode/DerivedData", + "\(home)/Library/Developer/Xcode/Archives", + "\(home)/Library/Developer/CoreSimulator/Caches", + "\(home)/Library/Caches/com.apple.dt.Xcode", + ] + + for path in xcodePaths { + if fileManager.fileExists(atPath: path) { + let size = directorySize(path: path) + if size > 0 { + items.append(CleanableItem( + name: URL(fileURLWithPath: path).lastPathComponent, + path: path, + size: size, + category: .xcodeJunk, + isSelected: true, + lastModified: nil + )) + } + } + } + + let totalSize = items.reduce(0) { $0 + $1.size } + return CategoryResult(category: .xcodeJunk, items: items, totalSize: totalSize) + } + + private func scanBrewCache() -> CategoryResult { + var items: [CleanableItem] = [] + + // Default Homebrew download cache + var brewCachePaths = [ + "\(home)/Library/Caches/Homebrew", + ] + + // Known-good Homebrew cache roots. Any path returned by `brew --cache` + // that is NOT inside one of these is refused - prevents an attacker + // setting HOMEBREW_CACHE=$HOME/Documents from steering our cleanup. + let knownBrewRoots = [ + "\(home)/Library/Caches/Homebrew", + "/opt/homebrew/Library/Caches", + "/usr/local/Homebrew/Library/Caches", + "/Library/Caches/Homebrew", + ] + + // Detect custom HOMEBREW_CACHE via `brew --cache`. Strip HOMEBREW_* + // from the child env so an attacker can't steer the output via + // launchctl setenv, then validate the output against knownBrewRoots. + let brewBinPaths = ["/opt/homebrew/bin/brew", "/usr/local/bin/brew"] + var detectedCustomCache = false + for brewBin in brewBinPaths { + guard fileManager.fileExists(atPath: brewBin) else { continue } + let task = Process() + task.executableURL = URL(fileURLWithPath: brewBin) + task.arguments = ["--cache"] + var sanitizedEnv = ProcessInfo.processInfo.environment + for key in Array(sanitizedEnv.keys) where key.hasPrefix("HOMEBREW_") { + sanitizedEnv.removeValue(forKey: key) + } + task.environment = sanitizedEnv + let pipe = Pipe() + task.standardOutput = pipe + task.standardError = Pipe() + do { + try task.run() + task.waitUntilExit() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + if let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines), + !output.isEmpty { + let normalized = normalizePath(output) + let isKnown = knownBrewRoots.contains { root in + normalized == root || normalized.hasPrefix(root + "/") + } + guard isKnown else { + Logger.shared.log("Refusing suspicious brew cache path: \(output)", level: .warning) + break + } + if !brewCachePaths.map(normalizePath).contains(normalized) { + brewCachePaths.append(output) + } + detectedCustomCache = true + } + } catch { + Logger.shared.log("Failed to run \(brewBin) --cache: \(error.localizedDescription)", level: .warning) + } + break // Only need the first available brew binary + } + + if !detectedCustomCache { + Logger.shared.log("Homebrew not found at standard paths; scanning default cache location only", level: .info) + } + + for path in brewCachePaths { + if fileManager.fileExists(atPath: path) { + let size = directorySize(path: path) + if size > 0 { + items.append(CleanableItem( + name: URL(fileURLWithPath: path).lastPathComponent, + path: path, + size: size, + category: .brewCache, + isSelected: true, + lastModified: nil + )) + } + } + } + + let totalSize = items.reduce(0) { $0 + $1.size } + return CategoryResult(category: .brewCache, items: items, totalSize: totalSize) + } + + private func scanNodeCache() -> CategoryResult { + // Each entry is `(displayName, defaultPath, optional CLI for cache-dir + // detection)`. The CLI invocation overrides `defaultPath` if the user + // has set a custom location (e.g. via `npm config set cache`). + struct ManagerCache { + let name: String + let defaultPath: String + let detectionCommand: (cli: String, args: [String])? + } + + let managers: [ManagerCache] = [ + ManagerCache( + name: "npm cache", + defaultPath: "\(home)/.npm", + detectionCommand: (cli: "npm", args: ["config", "get", "cache"]) + ), + ManagerCache( + name: "yarn classic cache", + defaultPath: "\(home)/Library/Caches/Yarn", + detectionCommand: (cli: "yarn", args: ["cache", "dir"]) + ), + // Yarn Berry / v2+ uses a per-project .yarn/cache. We don't try to + // chase those — they're inside user projects and shouldn't be + // touched by a system cleaner. The classic cache above remains the + // global, safe-to-clean location. + ManagerCache( + name: "pnpm content-addressable store", + defaultPath: "\(home)/Library/pnpm/store", + detectionCommand: (cli: "pnpm", args: ["store", "path"]) + ), + ] + + var items: [CleanableItem] = [] + + // Common $PATH locations on macOS where these CLIs land. + let cliSearchPaths = [ + "/opt/homebrew/bin", + "/usr/local/bin", + "\(home)/.local/bin", + "\(home)/.volta/bin", + "\(home)/.nvm/versions/node", + ] + + for manager in managers { + var paths: [String] = [] + paths.append(manager.defaultPath) + + if let cmd = manager.detectionCommand, + let cliPath = locateExecutable(named: cmd.cli, searchPaths: cliSearchPaths), + let detected = runCommandReadingStdout(executable: cliPath, args: cmd.args) { + let normalized = detected.trimmingCharacters(in: .whitespacesAndNewlines) + if !normalized.isEmpty, + !paths.map(normalizePath).contains(normalizePath(normalized)) { + paths.append(normalized) + } + } + + for path in paths { + guard fileManager.fileExists(atPath: path) else { continue } + let size = directorySize(path: path) + guard size > 0 else { continue } + items.append(CleanableItem( + name: manager.name, + path: path, + size: size, + category: .nodeCache, + isSelected: true, + lastModified: nil + )) + } + } + + let totalSize = items.reduce(0) { $0 + $1.size } + return CategoryResult(category: .nodeCache, items: items, totalSize: totalSize) + } + + // -- Process helpers (used by scanNodeCache) -- + + private func locateExecutable(named name: String, searchPaths: [String]) -> String? { + for dir in searchPaths { + let candidate = (dir as NSString).appendingPathComponent(name) + if fileManager.isExecutableFile(atPath: candidate) { + return candidate + } + // For nvm: ~/.nvm/versions/node//bin/ + if dir.hasSuffix("/.nvm/versions/node"), + let versions = try? fileManager.contentsOfDirectory(atPath: dir) { + for v in versions { + let nested = (dir as NSString).appendingPathComponent("\(v)/bin/\(name)") + if fileManager.isExecutableFile(atPath: nested) { + return nested + } + } + } + } + return nil + } + + private func runCommandReadingStdout(executable: String, args: [String]) -> String? { + let task = Process() + task.executableURL = URL(fileURLWithPath: executable) + task.arguments = args + let pipe = Pipe() + task.standardOutput = pipe + task.standardError = Pipe() + do { + try task.run() + task.waitUntilExit() + } catch { + Logger.shared.log("\(executable) \(args.joined(separator: " ")) failed: \(error.localizedDescription)", level: .warning) + return nil + } + guard task.terminationStatus == 0 else { return nil } + let data = pipe.fileHandleForReading.readDataToEndOfFile() + return String(data: data, encoding: .utf8) + } + + private func scanDockerCache() -> CategoryResult { + var items: [CleanableItem] = [] + + // Docker Desktop on macOS keeps its VM disk + caches under + // ~/Library/Containers/com.docker.docker/Data. The caches we + // surface here are *recoverable* — they will be regenerated by + // Docker on next pull/build, and `docker system prune` is the + // CLI equivalent of cleaning them. + let dockerDataDirs = [ + // Build cache (BuildKit), per-user + "\(home)/Library/Containers/com.docker.docker/Data/cache", + // Vmnetd / vpnkit log + telemetry caches + "\(home)/Library/Containers/com.docker.docker/Data/log", + "\(home)/Library/Containers/com.docker.docker/Data/tmp", + // Group containers caches (Docker Desktop helper apps) + "\(home)/Library/Group Containers/group.com.docker/Caches", + // CLI plugin download cache + "\(home)/.docker/cli-plugins/.cache", + // Buildx / containerd inline cache + "\(home)/.docker/buildx/cache", + ] + + for path in dockerDataDirs { + guard fileManager.fileExists(atPath: path) else { continue } + let size = directorySize(path: path) + guard size > 0 else { continue } + items.append(CleanableItem( + name: URL(fileURLWithPath: path).lastPathComponent, + path: path, + size: size, + category: .dockerCache, + isSelected: true, + lastModified: nil + )) + } + + // If the `docker` CLI is available, surface reclaimable space + // reported by `docker system df` as a single virtual entry. + // We don't try to delete it directly — the user runs + // `docker system prune` themselves, which is the safe path. + // We just show how much they can recover. + let dockerBinPaths = ["/usr/local/bin/docker", "/opt/homebrew/bin/docker"] + for dockerBin in dockerBinPaths where fileManager.fileExists(atPath: dockerBin) { + if let reclaimable = reclaimableDockerSpace(dockerBin: dockerBin), reclaimable > 0 { + items.append(CleanableItem( + name: "Reclaimable (run `docker system prune -af`)", + path: dockerBin, + size: reclaimable, + category: .dockerCache, + isSelected: false, + lastModified: nil + )) + } + break + } + + let totalSize = items.reduce(0) { $0 + $1.size } + return CategoryResult(category: .dockerCache, items: items, totalSize: totalSize) + } + + /// Sum the reclaimable bytes reported by `docker system df --format json`. + /// Returns nil when Docker isn't running or the command fails — callers + /// should treat that as "no reclaimable info available", not as an error. + private func reclaimableDockerSpace(dockerBin: String) -> Int64? { + let task = Process() + task.executableURL = URL(fileURLWithPath: dockerBin) + task.arguments = ["system", "df", "--format", "{{.Reclaimable}}"] + let stdoutPipe = Pipe() + task.standardOutput = stdoutPipe + task.standardError = Pipe() + do { + try task.run() + task.waitUntilExit() + } catch { + Logger.shared.log("docker system df failed: \(error.localizedDescription)", level: .warning) + return nil + } + guard task.terminationStatus == 0 else { return nil } + let data = stdoutPipe.fileHandleForReading.readDataToEndOfFile() + guard let output = String(data: data, encoding: .utf8) else { return nil } + // Each line looks like e.g. "1.234GB (45%)" — parse the leading number. + var total: Int64 = 0 + for line in output.split(separator: "\n") { + let raw = line.split(separator: " ").first.map(String.init) ?? "" + if let bytes = parseHumanBytes(raw) { + total += bytes + } + } + return total + } + + /// Parse Docker's compact size format ("1.23GB", "456MB", "789kB") into bytes. + private func parseHumanBytes(_ s: String) -> Int64? { + let trimmed = s.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { return nil } + let units: [(String, Double)] = [ + ("TB", 1_000_000_000_000), + ("GB", 1_000_000_000), + ("MB", 1_000_000), + ("kB", 1_000), + ("KB", 1_000), + ("B", 1), + ] + for (suffix, multiplier) in units { + if trimmed.hasSuffix(suffix) { + let numberPart = String(trimmed.dropLast(suffix.count)) + if let value = Double(numberPart) { + return Int64(value * multiplier) + } + } + } + return nil + } + // MARK: - Helpers + + private func scanDirectory( + path: String, + category: CleaningCategory, + recursive: Bool, + maxDepth: Int, + excluding excludedPaths: Set = [] + ) -> [CleanableItem] { + var items: [CleanableItem] = [] + + guard fileManager.fileExists(atPath: path), + fileManager.isReadableFile(atPath: path) else { return [] } + + do { + let contents = try fileManager.contentsOfDirectory(atPath: path) + for item in contents { + let fullPath = (path as NSString).appendingPathComponent(item) + report(fullPath) + if excludedPaths.contains(normalizePath(fullPath)) { + continue + } + + var isDir: ObjCBool = false + guard fileManager.fileExists(atPath: fullPath, isDirectory: &isDir) else { continue } + + // Security: skip symlinks to prevent symlink-following attacks + if let attrs = try? fileManager.attributesOfItem(atPath: fullPath), + let fileType = attrs[.type] as? FileAttributeType, + fileType == .typeSymbolicLink { + continue + } + + if isDir.boolValue { + let size = directorySize(path: fullPath) + if size > 1024 { // Skip tiny entries + items.append(CleanableItem( + name: item, + path: fullPath, + size: size, + category: category, + isSelected: true, + lastModified: fileModDate(path: fullPath) + )) + } + } else { + if let attrs = try? fileManager.attributesOfItem(atPath: fullPath), + let size = attrs[.size] as? Int64, size > 1024 { + items.append(CleanableItem( + name: item, + path: fullPath, + size: size, + category: category, + isSelected: true, + lastModified: attrs[.modificationDate] as? Date + )) + } + } + } + } catch { + Logger.shared.log("Cannot enumerate \(path): \(error.localizedDescription)", level: .warning) + } + + return items + } + + private func makeCleanupItem( + name: String, + path: String, + category: CleaningCategory, + isSelected: Bool = true, + minimumSize: Int64 = 1024 + ) -> CleanableItem? { + report(path) + var isDirectory: ObjCBool = false + guard fileManager.fileExists(atPath: path, isDirectory: &isDirectory), + fileManager.isReadableFile(atPath: path) else { return nil } + + if isDirectory.boolValue { + let size = directorySize(path: path) + guard size > minimumSize else { return nil } + return CleanableItem( + name: name, + path: path, + size: size, + category: category, + isSelected: isSelected, + lastModified: fileModDate(path: path) + ) + } + + guard let attrs = try? fileManager.attributesOfItem(atPath: path), + let size = attrs[.size] as? Int64, + size > minimumSize else { return nil } + + return CleanableItem( + name: name, + path: path, + size: size, + category: category, + isSelected: isSelected, + lastModified: attrs[.modificationDate] as? Date + ) + } + + private func deduplicatedItems(_ items: [CleanableItem]) -> [CleanableItem] { + var seenPaths: Set = [] + var uniqueItems: [CleanableItem] = [] + + for item in items { + let normalizedPath = normalizePath(item.path) + if seenPaths.insert(normalizedPath).inserted { + uniqueItems.append(item) + } + } + + return uniqueItems + } + + private func normalizePath(_ path: String) -> String { + (path as NSString).standardizingPath + } + + private func directorySize(path: String) -> Int64 { + var totalSize: Int64 = 0 + + guard let enumerator = fileManager.enumerator( + at: URL(fileURLWithPath: path), + includingPropertiesForKeys: [.fileSizeKey, .isRegularFileKey], + options: [.skipsHiddenFiles] + ) else { return 0 } + + var count = 0 + for case let fileURL as URL in enumerator { + count += 1 + if count > 10000 { break } // Safety limit for very large directories + + report(fileURL.path) + guard let values = try? fileURL.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey]), + let isFile = values.isRegularFile, isFile, + let size = values.fileSize else { continue } + totalSize += Int64(size) + } + + return totalSize + } + + private func fileModDate(path: String) -> Date? { + try? fileManager.attributesOfItem(atPath: path)[.modificationDate] as? Date + } + + // MARK: - Purgeable Space Helpers + + struct SnapshotInfo { + let name: String + let size: Int64 + let date: Date? + } + + /// Get local Time Machine snapshots and their sizes + private func getLocalSnapshots() -> [SnapshotInfo] { + var snapshots: [SnapshotInfo] = [] + + let task = Process() + task.executableURL = URL(fileURLWithPath: "/usr/bin/tmutil") + task.arguments = ["listlocalsnapshots", "/"] + let pipe = Pipe() + task.standardOutput = pipe + task.standardError = Pipe() + + do { + try task.run() + task.waitUntilExit() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + guard let output = String(data: data, encoding: .utf8) else { return [] } + + // Parse snapshot names (format: com.apple.TimeMachine.2026-04-08-123456.local) + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd-HHmmss" + + for line in output.components(separatedBy: "\n") { + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty, trimmed.contains("TimeMachine") else { continue } + + // Extract date from snapshot name + var snapshotDate: Date? + let parts = trimmed.components(separatedBy: ".") + for part in parts { + if let date = dateFormatter.date(from: part) { + snapshotDate = date + break + } + } + + // Get snapshot size via tmutil + let sizeBytes = getSnapshotSize(name: trimmed) + + if sizeBytes > 0 { + snapshots.append(SnapshotInfo( + name: trimmed, + size: sizeBytes, + date: snapshotDate + )) + } + } + } catch { + Logger.shared.log("tmutil listlocalsnapshots failed: \(error.localizedDescription)", level: .info) + } + + return snapshots + } + + /// Get size of a specific local snapshot via APFS snapshot listing + private func getSnapshotSize(name: String) -> Int64 { + let task = Process() + task.executableURL = URL(fileURLWithPath: "/usr/sbin/diskutil") + task.arguments = ["apfs", "listSnapshots", "/", "-plist"] + let pipe = Pipe() + task.standardOutput = pipe + task.standardError = Pipe() + + do { + try task.run() + task.waitUntilExit() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + guard let plist = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any], + let snapshots = plist["Snapshots"] as? [[String: Any]] else { + Logger.shared.log("Could not parse APFS snapshot plist for \(name)", level: .info) + return 0 + } + + for snapshot in snapshots { + if let snapshotName = snapshot["SnapshotName"] as? String, + snapshotName == name, + let dataSize = snapshot["DataSize"] as? Int64 { + return dataSize + } + } + + Logger.shared.log("Snapshot \(name) not found in APFS listing", level: .info) + } catch { + Logger.shared.log("diskutil apfs listSnapshots failed: \(error.localizedDescription)", level: .warning) + } + + return 0 + } + + /// Calculate total local snapshot size from disk usage difference + private func getLocalSnapshotSize() -> Int64 { + // The difference between "Volume Used Space" visible to the filesystem + // and actual container usage can indicate snapshot overhead. + // However, without root access, we can only check if tmutil reports snapshots. + + let task = Process() + task.executableURL = URL(fileURLWithPath: "/usr/bin/tmutil") + task.arguments = ["listlocalsnapshots", "/"] + let pipe = Pipe() + task.standardOutput = pipe + task.standardError = Pipe() + + do { + try task.run() + task.waitUntilExit() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + guard let output = String(data: data, encoding: .utf8) else { return 0 } + + let snapshotCount = output.components(separatedBy: "\n") + .filter { $0.contains("TimeMachine") || $0.contains("com.apple") } + .count + + if snapshotCount == 0 { return 0 } + + // Check if system reports purgeable via newer diskutil + // On systems that support it, "Purgeable Space" appears in diskutil info + let diskTask = Process() + diskTask.executableURL = URL(fileURLWithPath: "/usr/sbin/diskutil") + diskTask.arguments = ["info", "-plist", "/"] + let diskPipe = Pipe() + diskTask.standardOutput = diskPipe + diskTask.standardError = Pipe() + try diskTask.run() + diskTask.waitUntilExit() + + let diskData = diskPipe.fileHandleForReading.readDataToEndOfFile() + if let plist = try? PropertyListSerialization.propertyList(from: diskData, format: nil) as? [String: Any], + let purgeable = plist["APFSContainerFree"] as? Int64, + let volumeFree = plist["FreeSpace"] as? Int64 { + // Purgeable is roughly the difference (snapshots that can be freed) + let purgeableEstimate = max(0, volumeFree - purgeable) + if purgeableEstimate > 10 * 1024 * 1024 { // Only report if > 10 MB + return purgeableEstimate + } + } + } catch { + Logger.shared.log("Purgeable space detection failed: \(error.localizedDescription)", level: .warning) + } + + return 0 + } +} diff --git a/PureMac/Services/SchedulerService.swift b/PureMac/Services/SchedulerService.swift new file mode 100644 index 0000000..fbb87aa --- /dev/null +++ b/PureMac/Services/SchedulerService.swift @@ -0,0 +1,149 @@ +import Foundation +import Combine + +@MainActor +class SchedulerService: ObservableObject { + @Published var config: ScheduleConfig { + didSet { saveConfig() } + } + + private var timer: Timer? + private let configKey = "PureMac.ScheduleConfig" + private var onTrigger: (() async -> Void)? + + init() { + if let data = UserDefaults.standard.data(forKey: configKey), + var saved = try? JSONDecoder().decode(ScheduleConfig.self, from: data) { + // Refuse to honor an already-past nextRunDate loaded from disk - + // otherwise an attacker who can write our plist (same-UID foothold) + // sets nextRunDate to a past timestamp and our first tick after + // launch triggers autoClean. Reset the schedule so the first run + // is always at least one full interval after launch. + if let next = saved.nextRunDate, next.timeIntervalSinceNow < 60 { + saved.nextRunDate = nil + saved.lastRunDate = nil + } + self.config = saved + } else { + self.config = ScheduleConfig() + } + } + + func setTrigger(_ handler: @escaping () async -> Void) { + self.onTrigger = handler + } + + func start() { + stop() + guard config.isEnabled else { return } + + updateNextRunDate() + + timer = Timer.scheduledTimer(withTimeInterval: 60, repeats: true) { [weak self] _ in + Task { @MainActor [weak self] in + self?.checkAndRun() + } + } + } + + func stop() { + timer?.invalidate() + timer = nil + } + + func updateSchedule(interval: ScheduleInterval) { + config.interval = interval + updateNextRunDate() + if config.isEnabled { + start() + } + } + + func toggleEnabled(_ enabled: Bool) { + config.isEnabled = enabled + if enabled { + start() + } else { + stop() + } + } + + // MARK: - Private + + private func checkAndRun() { + guard config.isEnabled, + let nextRun = config.nextRunDate, + Date() >= nextRun else { return } + + config.lastRunDate = Date() + updateNextRunDate() + + Task { + await onTrigger?() + } + } + + private func updateNextRunDate() { + let base = config.lastRunDate ?? Date() + config.nextRunDate = base.addingTimeInterval(config.interval.seconds) + } + + private func saveConfig() { + if let data = try? JSONEncoder().encode(config) { + UserDefaults.standard.set(data, forKey: configKey) + } + } + + // MARK: - LaunchAgent Management + + func installLaunchAgent() { + let plistName = "com.puremac.scheduler" + let plistPath = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/LaunchAgents/\(plistName).plist") + + guard let appPath = Bundle.main.executablePath else { return } + + let intervalSeconds = Int(config.interval.seconds) + let plistContent = """ + + + + + Label + \(plistName) + ProgramArguments + + \(appPath) + --scheduled-clean + + StartInterval + \(intervalSeconds) + RunAtLoad + + + + """ + + try? plistContent.write(to: plistPath, atomically: true, encoding: .utf8) + + let task = Process() + task.executableURL = URL(fileURLWithPath: "/bin/launchctl") + task.arguments = ["load", plistPath.path] + try? task.run() + task.waitUntilExit() + } + + func uninstallLaunchAgent() { + let plistName = "com.puremac.scheduler" + let plistPath = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/LaunchAgents/\(plistName).plist") + + let task = Process() + task.executableURL = URL(fileURLWithPath: "/bin/launchctl") + task.arguments = ["unload", plistPath.path] + try? task.run() + task.waitUntilExit() + + try? FileManager.default.removeItem(at: plistPath) + } +} diff --git a/PureMac/Services/SystemMonitor.swift b/PureMac/Services/SystemMonitor.swift new file mode 100644 index 0000000..82810cf --- /dev/null +++ b/PureMac/Services/SystemMonitor.swift @@ -0,0 +1,144 @@ +import Foundation +import Darwin + +/// Lightweight live system telemetry for the menu-bar monitor (CPU / memory / +/// disk). Polls on a timer only while a SwiftUI view is observing it; the menu +/// bar's `MenuBarExtra` keeps a single shared instance alive, and `start()` / +/// `stop()` gate the timer so the app does no background sampling when the +/// monitor is disabled in Settings. +/// +/// All readings use public Mach / Foundation APIs (no sandbox-incompatible +/// shelling out), so this stays valid under the app's hardened-runtime, +/// notarized build. +@MainActor +final class SystemMonitor: ObservableObject { + static let shared = SystemMonitor() + + /// 0.0 - 1.0 fraction of total CPU time spent non-idle since the last sample. + @Published private(set) var cpuUsage: Double = 0 + @Published private(set) var memoryUsed: Int64 = 0 + @Published private(set) var memoryTotal: Int64 = 0 + @Published private(set) var diskUsed: Int64 = 0 + @Published private(set) var diskTotal: Int64 = 0 + + var memoryFraction: Double { + guard memoryTotal > 0 else { return 0 } + return Double(memoryUsed) / Double(memoryTotal) + } + + var diskFraction: Double { + guard diskTotal > 0 else { return 0 } + return Double(diskUsed) / Double(diskTotal) + } + + private var timer: Timer? + /// Previous CPU tick counters, kept to turn the kernel's monotonically + /// increasing totals into a per-interval delta. + private var previousBusy: UInt64 = 0 + private var previousTotal: UInt64 = 0 + /// Number of live observers; the timer runs only while > 0 so two views + /// (menu-bar label + dropdown) share one timer and the app idles cleanly. + private var observerCount = 0 + + private init() {} + + /// Begin (or keep) sampling. Refcounted so multiple observers share a timer. + func start(interval: TimeInterval = 2.0) { + observerCount += 1 + guard timer == nil else { return } + memoryTotal = Int64(ProcessInfo.processInfo.physicalMemory) + sample() + let t = Timer(timeInterval: interval, repeats: true) { [weak self] _ in + Task { @MainActor in self?.sample() } + } + // .common so sampling continues while a menu/popover tracks the run loop. + RunLoop.main.add(t, forMode: .common) + timer = t + } + + /// Release one observer; the timer stops once the last one goes away. + func stop() { + observerCount = max(0, observerCount - 1) + guard observerCount == 0 else { return } + timer?.invalidate() + timer = nil + } + + private func sample() { + sampleCPU() + sampleMemory() + sampleDisk() + } + + // MARK: - CPU + + private func sampleCPU() { + var info = host_cpu_load_info() + var count = mach_msg_type_number_t( + MemoryLayout.stride / MemoryLayout.stride + ) + let result = withUnsafeMutablePointer(to: &info) { + $0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { + host_statistics(mach_host_self(), HOST_CPU_LOAD_INFO, $0, &count) + } + } + guard result == KERN_SUCCESS else { return } + + let user = UInt64(info.cpu_ticks.0) + let system = UInt64(info.cpu_ticks.1) + let idle = UInt64(info.cpu_ticks.2) + let nice = UInt64(info.cpu_ticks.3) + let busy = user &+ system &+ nice + let total = busy &+ idle + + defer { previousBusy = busy; previousTotal = total } + // First sample has no prior baseline to diff against. + guard previousTotal != 0, total > previousTotal else { return } + + let busyDelta = Double(busy &- previousBusy) + let totalDelta = Double(total &- previousTotal) + guard totalDelta > 0 else { return } + cpuUsage = min(1, max(0, busyDelta / totalDelta)) + } + + // MARK: - Memory + + private func sampleMemory() { + var stats = vm_statistics64() + var count = mach_msg_type_number_t( + MemoryLayout.stride / MemoryLayout.stride + ) + let result = withUnsafeMutablePointer(to: &stats) { + $0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { + host_statistics64(mach_host_self(), HOST_VM_INFO64, $0, &count) + } + } + guard result == KERN_SUCCESS else { return } + + let pageSize = Int64(vm_kernel_page_size) + // "App Memory" + wired + compressed mirrors what Activity Monitor counts + // as memory pressure; free + purgeable + most file-backed pages are not + // pressure, so they're excluded. + let used = (Int64(stats.active_count) + + Int64(stats.wire_count) + + Int64(stats.compressor_page_count)) * pageSize + memoryUsed = used + } + + // MARK: - Disk + + private func sampleDisk() { + let url = URL(fileURLWithPath: NSHomeDirectory()) + guard let values = try? url.resourceValues(forKeys: [ + .volumeTotalCapacityKey, + .volumeAvailableCapacityForImportantUsageKey, + ]) else { return } + + if let total = values.volumeTotalCapacity { + diskTotal = Int64(total) + } + if let available = values.volumeAvailableCapacityForImportantUsage { + diskUsed = max(0, diskTotal - available) + } + } +} diff --git a/PureMac/Services/UpdateService.swift b/PureMac/Services/UpdateService.swift new file mode 100644 index 0000000..a46db1a --- /dev/null +++ b/PureMac/Services/UpdateService.swift @@ -0,0 +1,43 @@ +import Foundation +import AppKit + +#if canImport(Sparkle) +import Sparkle +#endif + +final class UpdateService: ObservableObject { + static let shared = UpdateService() + + #if canImport(Sparkle) + private var updaterController: SPUStandardUpdaterController? + #endif + + private init() { + #if canImport(Sparkle) + // Do not start automatic checking by default; keep control minimal. + updaterController = SPUStandardUpdaterController(startingUpdater: false, updaterDelegate: nil, userDriverDelegate: nil) + #endif + } + + func checkForUpdates() { + #if canImport(Sparkle) + DispatchQueue.main.async { + // Only start the Sparkle updater if an appcast/feed URL is configured. + if let _ = Bundle.main.object(forInfoDictionaryKey: "SUFeedURL") as? String { + self.updaterController?.startUpdater() + self.updaterController?.updater.checkForUpdates() + } else { + // Fallback: open Releases page when no feed is configured. + if let url = URL(string: "https://github.com/momenbasel/PureMac/releases/latest") { + NSWorkspace.shared.open(url) + } + } + } + #else + // Fallback: open Releases page + if let url = URL(string: "https://github.com/momenbasel/PureMac/releases/latest") { + NSWorkspace.shared.open(url) + } + #endif + } +} diff --git a/PureMac/ViewModels/AppState.swift b/PureMac/ViewModels/AppState.swift new file mode 100644 index 0000000..cb62eac --- /dev/null +++ b/PureMac/ViewModels/AppState.swift @@ -0,0 +1,1096 @@ +import SwiftUI +import Combine +import UserNotifications +import AppKit + +// InstalledApp is defined in AppInfoFetcher.swift + +enum AppSection: Hashable { + case apps + case orphans + case cleaning(CleaningCategory) +} + +extension Notification.Name { + /// Posted by the Finder Services handler ("Uninstall with PureMac") with a + /// `["path": String]` userInfo pointing at the right-clicked .app bundle. + static let pureMacExternalUninstall = Notification.Name("PureMac.ExternalUninstall") +} + +/// Cold-launch buffer for Finder Services. A "Uninstall with PureMac" request +/// can arrive before the SwiftUI scene (and thus AppState) exists; the posted +/// notification then has no subscriber and is lost (NotificationCenter has no +/// replay). AppDelegate stashes the path here and AppState drains it in init. +enum ExternalUninstallBuffer { + // Written by the Finder Services handler and drained by AppState — both + // run on the main thread, so a plain static is sufficient here. + static var pendingPath: String? +} + +/// Standalone observable for the live scan-path ticker. The scan engine reports +/// the filesystem path it is touching ~10×/sec. Routing that through AppState's +/// own `@Published` storage republished the *entire* view tree at that rate, +/// which surfaced as window-drag / button-hover lag and a Smart Scan that +/// looked frozen until you switched sidebar sections and forced a fresh render +/// (issues #119, #120). Isolating the high-frequency value here means only the +/// small ticker label observes it, so the rest of the UI stays still. +@MainActor +final class ScanProgressTicker: ObservableObject { + @Published var path: String = "" +} + +@MainActor +final class AppState: ObservableObject { + typealias AppFileScanner = @MainActor ( + _ app: InstalledApp, + _ locations: Locations, + _ completion: @escaping (Set) -> Void + ) -> Void + + // MARK: - Scan / Clean State + + @Published var selectedCategory: CleaningCategory = .smartScan + @Published var scanState: ScanState = .idle + @Published var categoryResults: [CleaningCategory: CategoryResult] = [:] + @Published var diskInfo = DiskInfo() + @Published var totalJunkSize: Int64 = 0 + @Published var totalFreedSpace: Int64 = 0 + @Published var scanProgress: Double = 0 + @Published var cleanProgress: Double = 0 + @Published var currentScanCategory: String = "" + /// Live filesystem path the scan engine is touching, feeding the dashboard's + /// ticker. Deliberately NOT a `@Published` on AppState — it updates ~10×/sec + /// and would otherwise invalidate the whole view tree (issues #119, #120). + /// Only the ticker label observes this object directly. + let scanTicker = ScanProgressTicker() + @Published var showCleanConfirmation = false + @Published var lastCleanedDate: Date? + @Published var selectedCleanupItems: Set = [] + @Published var deselectedItems: Set = [] + @Published var hasFullDiskAccess: Bool = true + @Published var fdaBannerDismissed: Bool = false + @Published var cleanError: String? + /// True when the most recent clean error is rooted in a TCC/FDA refusal + /// (i.e. items survived even the admin pass). MainWindow uses this to + /// route the user into the PermissionSheet instead of the generic alert. + @Published var cleanErrorIsFDAFixable: Bool = false + /// Items that survived the most recent clean attempt — used to re-run the + /// operation after the user grants Full Disk Access without forcing them + /// to re-select anything. + @Published var pendingPermissionRetryItems: [CleanableItem] = [] + + // MARK: - App Uninstaller State + + @Published var installedApps: [InstalledApp] = [] + @Published var selectedApp: InstalledApp? + @Published var discoveredFiles: [URL] = [] + @Published var selectedFiles: Set = [] + @Published var orphanedFiles: [URL] = [] + @Published var isSearchingOrphans: Bool = false + @Published var isLoadingApps: Bool = false + @Published var isScanningAppFiles: Bool = false + @Published var removalError: String? + @Published var removalNeedsFullDiskAccess = false + /// Snapshot of the URLs that failed the most recent uninstall due to a + /// permission denial. Frozen at finishRemoval time so AppFilesView's + /// retry path operates on the failed batch even if the user clicks a + /// different app or mutates selection while the FDA sheet is open. + @Published var lastFailedRemovalURLs: [URL] = [] + @Published var appFileScanLocationCount: Int = 0 + /// Set when a right-clicked app arrives via the Finder Services handler. + /// MainWindow consumes it on both onChange AND onAppear so a request that + /// lands before MainWindow mounts (cold launch, or while onboarding is + /// still showing) is still surfaced — a one-shot token would be missed. + @Published var pendingExternalApp: InstalledApp? + + private var externalUninstallObserver: AnyCancellable? + + // MARK: - Services + + var scheduler = SchedulerService() + private let scanEngine = ScanEngine() + private let cleaningEngine = CleaningEngine() + private let locationsProvider: () -> Locations + private let appFileScanner: AppFileScanner + + // MARK: - Computed + + var totalItemCount: Int { + categoryResults.values.reduce(0) { $0 + $1.itemCount } + } + + var currentCategoryResult: CategoryResult? { + categoryResults[selectedCategory] + } + + var allResults: [CategoryResult] { + CleaningCategory.scannable.compactMap { categoryResults[$0] }.filter { $0.totalSize > 0 } + } + + var totalSelectedSize: Int64 { + allResults.flatMap { $0.items }.filter { isItemSelected($0) }.reduce(0) { $0 + $1.size } + } + + var currentAppFileSearchLocationCount: Int { + if isScanningAppFiles && appFileScanLocationCount > 0 { + return appFileScanLocationCount + } + return discoveredFiles.count + } + + // MARK: - Init + + init( + performStartupTasks: Bool = true, + locationsProvider: @escaping () -> Locations = Locations.init, + appFileScanner: @escaping AppFileScanner = AppState.defaultAppFileScanner + ) { + self.locationsProvider = locationsProvider + self.appFileScanner = appFileScanner + + // Listen for right-click "Uninstall with PureMac" hand-offs from the + // Finder Services handler in AppDelegate. + externalUninstallObserver = NotificationCenter.default + .publisher(for: .pureMacExternalUninstall) + .receive(on: RunLoop.main) + .sink { [weak self] note in + let path = (note.userInfo?["path"] as? String) ?? ExternalUninstallBuffer.pendingPath + ExternalUninstallBuffer.pendingPath = nil + guard let path else { return } + Task { @MainActor in self?.presentExternalUninstall(appPath: path) } + } + // Drain a request that arrived before this AppState existed (cold launch + // via Finder Services — the notification fired with no subscriber). + if let buffered = ExternalUninstallBuffer.pendingPath { + ExternalUninstallBuffer.pendingPath = nil + presentExternalUninstall(appPath: buffered) + } + + if performStartupTasks { + loadDiskInfo() + checkFullDiskAccess() + loadInstalledApps() + scheduler.setTrigger { [weak self] in + await self?.runScheduledScan() + } + // Only arm the scheduler once onboarding has completed. Before + // the first launch the defaults plist may have been + // attacker-planted with autoClean=true; wait for human consent + // via onboarding. + if UserDefaults.standard.bool(forKey: "PureMac.OnboardingComplete") { + scheduler.start() + } + } + } + + // MARK: - App Loading + + func loadInstalledApps() { + isLoadingApps = true + Task.detached(priority: .userInitiated) { + let apps = AppInfoFetcher.shared.fetchInstalledApps() + await MainActor.run { [weak self] in + self?.installedApps = apps + self?.isLoadingApps = false + } + } + } + + /// Resolve a right-clicked .app (via the Finder Services handler) into the + /// uninstaller: select it, kick off the related-files scan, and signal + /// MainWindow to surface the Installed Apps section. + func presentExternalUninstall(appPath: String) { + let url = URL(fileURLWithPath: appPath) + if let cached = installedApps.first(where: { $0.path.standardizedFileURL == url.standardizedFileURL }) { + applyExternalUninstall(cached) + return + } + // Not in the cached list (non-standard location, or a cold start before + // loadInstalledApps finished). Resolve off the main thread — fetchApp + // walks the entire bundle to size it, which would beachball the UI for + // a multi-gigabyte app. + Task.detached(priority: .userInitiated) { + let app = AppInfoFetcher.shared.fetchApp(at: url) + await MainActor.run { [weak self] in + guard let self else { return } + guard let app else { + Logger.shared.log("Finder uninstall request rejected (non-app or protected): \(appPath)", level: .warning) + return + } + self.applyExternalUninstall(app) + } + } + } + + private func applyExternalUninstall(_ app: InstalledApp) { + selectedApp = app + pendingExternalApp = app + scanForAppFiles(app) + } + + func scanForAppFiles(_ app: InstalledApp) { + discoveredFiles = [] + selectedFiles = [] + isScanningAppFiles = true + let locations = locationsProvider() + appFileScanLocationCount = locations.appSearch.paths.count + appFileScanner(app, locations) { [weak self] urls in + guard let self else { return } + let sorted = urls.sorted { $0.path < $1.path } + self.discoveredFiles = sorted + self.selectedFiles = urls + self.isScanningAppFiles = false + self.appFileScanLocationCount = 0 + } + } + + func removeSelectedFiles() { + // Re-entrance guard: if a previous removal is still resolving and + // the FDA sheet/retry hasn't finished, a second call would race- + // overwrite `lastFailedRemovalURLs` before the first batch's retry + // closure read it. We can't gate on `removalNeedsFullDiskAccess` + // alone — AppFilesView clears that flag the moment it hands off to + // the coordinator, leaving a window where a second remove call + // would pass the guard while the coordinator is still polling. + // PermissionCoordinator.isRequesting covers the full sheet-open + + // retry-pending span. + guard !removalNeedsFullDiskAccess, + !PermissionCoordinator.shared.isRequesting else { + Logger.shared.log("Refused duplicate removeSelectedFiles while FDA flow is active", level: .info) + return + } + // Safety guard: never allow a high-risk home dotpath (listed in + // Conditions.swift) to be trashed no matter how it ended up in the + // selection. Catches selection-time additions that slipped past the + // scanner-side filters. + let allURLs = Array(selectedFiles) + let (urls, blocked): ([URL], [URL]) = allURLs.reduce(into: ([], [])) { acc, url in + let resolved = url.resolvingSymlinksInPath().path + let isBlocked = highRiskHomeDotPaths.contains { root in + resolved == root || resolved.hasPrefix(root + "/") + } + if isBlocked { + acc.1.append(url) + } else { + acc.0.append(url) + } + } + removalError = nil + removalNeedsFullDiskAccess = false + if !blocked.isEmpty { + let blockedList = blocked.map(\.path).joined(separator: ", ") + Logger.shared.log("Refused to delete \(blocked.count) high-risk home dotpath(s): \(blockedList)", level: .warning) + selectedFiles.subtract(blocked) + } + guard !urls.isEmpty else { + if !blocked.isEmpty { + removalError = "Refused to delete \(blocked.count) protected item(s) (home credential directory or similar)." + } + return + } + trashDirectly(urls: urls) { [weak self] removed, needsFullDiskAccess, needsAdmin, failed in + Task { @MainActor in + guard let self else { return } + + self.applyRemovedAppFiles(removed) + + guard !needsAdmin.isEmpty else { + self.finishRemoval( + removedAny: !removed.isEmpty, + needsFullDiskAccess: needsFullDiskAccess, + attemptedAdmin: false, + failed: failed, + adminError: nil + ) + return + } + + let items = needsAdmin.map { self.cleanableUninstallItem(for: $0) } + let adminResult = await self.cleaningEngine.cleanWithAdminPrivileges(items: items) + let adminRemoved = needsAdmin.filter { adminResult.cleanedPaths.contains($0.path) } + let adminFailed = needsAdmin.filter { !adminResult.cleanedPaths.contains($0.path) } + + self.applyRemovedAppFiles(adminRemoved) + for url in adminRemoved { + Logger.shared.log("Removed \(url.path) with administrator privileges", level: .info) + } + + self.finishRemoval( + removedAny: !removed.isEmpty || !adminRemoved.isEmpty, + needsFullDiskAccess: needsFullDiskAccess, + attemptedAdmin: true, + failed: failed + adminFailed, + adminError: adminResult.errors.joined(separator: "; ") + ) + } + } + } + + /// Move files to the Trash via FileManager.trashItem so the syscall + /// originates from PureMac itself - TCC then registers PureMac in the + /// Full Disk Access list. The previous AppleScript-via-Finder bridge + /// caused the syscall to originate from Finder, which is why granting + /// FDA to PureMac made no difference (issue #75). + private func trashDirectly(urls: [URL], completion: @escaping ([URL], Bool, [URL], [URL]) -> Void) { + DispatchQueue.global(qos: .userInitiated).async { + let hasFullDiskAccess = FullDiskAccessManager.shared.hasFullDiskAccess + var removed: [URL] = [] + var needsFullDiskAccess = false + var needsAdmin: [URL] = [] + var failed: [URL] = [] + + for url in urls { + var resulting: NSURL? + do { + try FileManager.default.trashItem(at: url, resultingItemURL: &resulting) + removed.append(url) + } catch { + let nsError = error as NSError + if Self.isMissingFileError(nsError) { + Logger.shared.log("Trash skipped for \(url.path): file no longer exists", level: .info) + removed.append(url) + } else if Self.isPermissionDeniedError(nsError) { + if hasFullDiskAccess || Self.isLikelyAdministratorRemovalPath(url) { + needsAdmin.append(url) + } else { + needsFullDiskAccess = true + failed.append(url) + } + } else { + Logger.shared.log("Trash failed for \(url.path): \(error.localizedDescription)", level: .error) + failed.append(url) + } + } + } + completion(removed, needsFullDiskAccess, needsAdmin, failed) + } + } + + private func applyRemovedAppFiles(_ urls: [URL]) { + guard !urls.isEmpty else { return } + // Animate the row sweep-out (AppFilesView attaches the per-row + // transitions). NSWorkspace is the Reduce Motion check available + // outside a View's Environment. + if NSWorkspace.shared.accessibilityDisplayShouldReduceMotion { + discoveredFiles.removeAll { urls.contains($0) } + } else { + withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { + discoveredFiles.removeAll { urls.contains($0) } + } + } + selectedFiles.subtract(urls) + Logger.shared.log("Removed \(urls.count) file\(urls.count == 1 ? "" : "s")", level: .info) + } + + private func finishRemoval( + removedAny: Bool, + needsFullDiskAccess: Bool, + attemptedAdmin: Bool, + failed: [URL], + adminError: String? + ) { + // Freeze the failed batch before the FDA sheet opens so the retry + // path can't be poisoned by later selection edits or app switches. + lastFailedRemovalURLs = needsFullDiskAccess ? failed : [] + removalNeedsFullDiskAccess = needsFullDiskAccess + if let message = removalFailureMessage( + needsFullDiskAccess: needsFullDiskAccess, + attemptedAdmin: attemptedAdmin, + failed: failed, + adminError: adminError + ) { + removalError = message + Logger.shared.log(message, level: .error) + } + if removedAny { + pruneMissingInstalledApps() + } + } + + /// Public bridge so views (e.g. AppFilesView) can build CleanableItem rows + /// from raw URLs when retrying via the PermissionCoordinator. + func makeUninstallCleanableItem(for url: URL) -> CleanableItem { + cleanableUninstallItem(for: url) + } + + private func cleanableUninstallItem(for url: URL) -> CleanableItem { + let values = try? url.resourceValues(forKeys: [ + .totalFileAllocatedSizeKey, + .fileAllocatedSizeKey, + .contentModificationDateKey, + ]) + let size = Int64(values?.totalFileAllocatedSize ?? values?.fileAllocatedSize ?? 0) + return CleanableItem( + name: url.lastPathComponent, + path: url.path, + size: size, + category: .systemJunk, + isSelected: true, + lastModified: values?.contentModificationDate + ) + } + + private func removalFailureMessage( + needsFullDiskAccess: Bool, + attemptedAdmin: Bool, + failed: [URL], + adminError: String? + ) -> String? { + if needsFullDiskAccess { + let prefix = failed.isEmpty ? "Some selected files" : "\(failed.count) file\(failed.count == 1 ? "" : "s")" + return "\(prefix) could not be removed because PureMac does not have Full Disk Access. Grant Full Disk Access in System Settings, then try again." + } + + if !failed.isEmpty { + if attemptedAdmin { + return "\(failed.count) file\(failed.count == 1 ? "" : "s") could not be removed with administrator privileges. The items may have changed or macOS denied access." + } + return "\(failed.count) file\(failed.count == 1 ? "" : "s") could not be removed. Check that the items still exist and are not in use." + } + + if let adminError, !adminError.isEmpty { + return "Administrator removal failed: \(adminError)" + } + return nil + } + + private nonisolated static func isMissingFileError(_ nsError: NSError) -> Bool { + (nsError.domain == NSCocoaErrorDomain && + (nsError.code == NSFileNoSuchFileError || nsError.code == NSFileReadNoSuchFileError)) || + (nsError.domain == NSPOSIXErrorDomain && nsError.code == Int(ENOENT)) + } + + private nonisolated static func isPermissionDeniedError(_ nsError: NSError) -> Bool { + (nsError.domain == NSCocoaErrorDomain && + (nsError.code == NSFileReadNoPermissionError || nsError.code == NSFileWriteNoPermissionError)) || + (nsError.domain == NSPOSIXErrorDomain && + (nsError.code == Int(EACCES) || nsError.code == Int(EPERM))) + } + + private nonisolated static func isLikelyAdministratorRemovalPath(_ url: URL) -> Bool { + let path = (url.resolvingSymlinksInPath().path as NSString).standardizingPath + let home = FileManager.default.homeDirectoryForCurrentUser.path + + return hasAppBundleComponent(path, rootedAt: "/Applications") + || hasAppBundleComponent(path, rootedAt: "\(home)/Applications") + || isDirectFile(path, in: "/private/var/db/receipts", extensions: ["plist", "bom"]) + || isDirectFile(path, in: "/var/db/receipts", extensions: ["plist", "bom"]) + || isDirectFile(path, in: "/Library/LaunchDaemons", extensions: ["plist"]) + || isDirectFile(path, in: "/Library/LaunchAgents", extensions: ["plist"]) + } + + private nonisolated static func hasAppBundleComponent(_ path: String, rootedAt root: String) -> Bool { + let normalizedRoot = (root as NSString).standardizingPath + guard path != normalizedRoot else { return false } + let rootWithSeparator = normalizedRoot.hasSuffix("/") ? normalizedRoot : normalizedRoot + "/" + guard path.hasPrefix(rootWithSeparator) else { return false } + let relative = String(path.dropFirst(rootWithSeparator.count)) + return relative.split(separator: "/").contains { component in + component.lowercased().hasSuffix(".app") + } + } + + private nonisolated static func isDirectFile(_ path: String, in root: String, extensions: Set) -> Bool { + let parent = ((path as NSString).deletingLastPathComponent as NSString).standardizingPath + guard parent == (root as NSString).standardizingPath else { return false } + return extensions.contains((path as NSString).pathExtension.lowercased()) + } + + private func pruneMissingInstalledApps() { + let fileManager = FileManager.default + installedApps.removeAll { !fileManager.fileExists(atPath: $0.path.path) } + + if let selectedApp, !fileManager.fileExists(atPath: selectedApp.path.path) { + self.selectedApp = nil + discoveredFiles = [] + selectedFiles = [] + } + } + + func findOrphans() { + isSearchingOrphans = true + orphanedFiles = [] + Task.detached(priority: .userInitiated) { + let locations = Locations() + let knownApps = await MainActor.run { self.installedApps } + let knownIDs = Set(knownApps.map { $0.bundleIdentifier.normalizedForMatching() }) + let knownNames = Set(knownApps.map { $0.appName.normalizedForMatching() }) + // Paths the user marked "Always Ignore" (issue #114). These were + // false positives for them, so they stay hidden from every scan + // until the user forgets the list in Settings. + let ignored = Set(UserDefaults.standard.stringArray(forKey: Self.ignoredOrphansKey) ?? []) + + var orphans: [URL] = [] + let fm = FileManager.default + + for path in locations.reverseSearch.paths { + guard let contents = try? fm.contentsOfDirectory(atPath: path) else { continue } + for item in contents { + let normalized = item.normalizedForMatching() + + // Skip known system items + if skipReverse.contains(where: { normalized.hasPrefix($0) }) { continue } + + // Check if this item belongs to any known app + let belongsToApp = knownIDs.contains(where: { normalized.contains($0) }) || + knownNames.contains(where: { normalized.contains($0) }) + + if !belongsToApp { + let fullPath = URL(fileURLWithPath: path).appendingPathComponent(item) + if ignored.contains(fullPath.path) { continue } + if OrphanSafetyPolicy.isSafeCandidate(fullPath) { + orphans.append(fullPath) + } + } + } + } + + let sorted = orphans.sorted { $0.lastPathComponent < $1.lastPathComponent } + await MainActor.run { [weak self] in + self?.orphanedFiles = sorted + self?.isSearchingOrphans = false + } + } + } + + // MARK: - Orphan ignore list (#114) + + static let ignoredOrphansKey = "settings.orphans.ignored" + + /// Number of paths currently on the "always ignore" list. Read from + /// UserDefaults each access so the Settings row tracks live changes. + var ignoredOrphanCount: Int { + UserDefaults.standard.stringArray(forKey: Self.ignoredOrphansKey)?.count ?? 0 + } + + /// Permanently hide the given orphans from future scans and sweep them out + /// of the current results. + func ignoreOrphans(_ urls: [URL]) { + guard !urls.isEmpty else { return } + var ignored = Set(UserDefaults.standard.stringArray(forKey: Self.ignoredOrphansKey) ?? []) + for url in urls { ignored.insert(url.path) } + UserDefaults.standard.set(Array(ignored), forKey: Self.ignoredOrphansKey) + + let urlSet = Set(urls) + if NSWorkspace.shared.accessibilityDisplayShouldReduceMotion { + orphanedFiles.removeAll { urlSet.contains($0) } + } else { + withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { + orphanedFiles.removeAll { urlSet.contains($0) } + } + } + Logger.shared.log("Ignoring \(urls.count) orphan(s) in future scans", level: .info) + } + + /// Forget every ignored path so they can surface again on the next scan. + func clearIgnoredOrphans() { + UserDefaults.standard.removeObject(forKey: Self.ignoredOrphansKey) + objectWillChange.send() + } + + // MARK: - Selection + + func isItemSelected(_ item: CleanableItem) -> Bool { + if item.isSelected { + return !deselectedItems.contains(item.id) + } + return selectedCleanupItems.contains(item.id) + } + + func toggleItem(_ item: CleanableItem) { + if isItemSelected(item) { + if item.isSelected { + deselectedItems.insert(item.id) + } else { + selectedCleanupItems.remove(item.id) + } + } else { + if item.isSelected { + deselectedItems.remove(item.id) + } else { + selectedCleanupItems.insert(item.id) + } + } + } + + func selectAllInCategory(_ category: CleaningCategory) { + guard let result = categoryResults[category] else { return } + for item in result.items { + if item.isSelected { + deselectedItems.remove(item.id) + } else { + selectedCleanupItems.insert(item.id) + } + } + } + + func deselectAllInCategory(_ category: CleaningCategory) { + guard let result = categoryResults[category] else { return } + for item in result.items { + if item.isSelected { + deselectedItems.insert(item.id) + } else { + selectedCleanupItems.remove(item.id) + } + } + } + + func selectedSizeInCategory(_ category: CleaningCategory) -> Int64 { + guard let result = categoryResults[category] else { return 0 } + return result.items.filter { isItemSelected($0) }.reduce(0) { $0 + $1.size } + } + + func selectedCountInCategory(_ category: CleaningCategory) -> Int { + guard let result = categoryResults[category] else { return 0 } + return result.items.filter { isItemSelected($0) }.count + } + + // MARK: - Helper Methods + + func categorySize(for category: CleaningCategory) -> String { + guard let result = categoryResults[category], result.totalSize > 0 else { return "" } + return result.formattedSize + } + + func categoryBinding(for category: CleaningCategory) -> Binding { + Binding( + get: { [weak self] in + guard let self else { return false } + return self.selectedCountInCategory(category) > 0 + }, + set: { [weak self] newValue in + guard let self else { return } + if newValue { + self.selectAllInCategory(category) + } else { + self.deselectAllInCategory(category) + } + } + ) + } + + func itemBinding(for item: CleanableItem) -> Binding { + Binding( + get: { [weak self] in + self?.isItemSelected(item) ?? false + }, + set: { [weak self] _ in + self?.toggleItem(item) + } + ) + } + + private func clearSelectionState() { + selectedCleanupItems.removeAll() + deselectedItems.removeAll() + } + + private func clearSelectionState(for category: CleaningCategory) { + guard let result = categoryResults[category] else { return } + for item in result.items { + selectedCleanupItems.remove(item.id) + deselectedItems.remove(item.id) + } + } + + // MARK: - Full Disk Access + + func checkFullDiskAccess() { + Task.detached { + let granted = FullDiskAccessManager.shared.hasFullDiskAccess + await MainActor.run { [weak self] in + self?.hasFullDiskAccess = granted + } + } + } + + func openFullDiskAccessSettings() { + FullDiskAccessManager.shared.openFullDiskAccessSettings() + } + + /// Request Full Disk Access via the rich PermissionCoordinator sheet and + /// retry the supplied items once the user grants permission. Used by both + /// the cleanup and app-uninstall flows so they share a single UI surface. + /// + /// The retry callback captures `items` directly rather than reading + /// `pendingPermissionRetryItems` at fire time — that field is mutable + /// app-wide and a second permission request would clobber it before the + /// first callback resolves, sending the wrong items to retryCleanItems. + func requestFullDiskAccessAndRetry(items: [CleanableItem], context: PermissionCoordinator.PromptContext) { + pendingPermissionRetryItems = items + let capturedItems = items + PermissionCoordinator.shared.requestAccess( + context: context, + failedPaths: items.map { $0.path } + ) { [weak self] in + Task { @MainActor [weak self] in + guard let self else { return } + self.pendingPermissionRetryItems = [] + self.cleanError = nil + self.cleanErrorIsFDAFixable = false + guard !capturedItems.isEmpty else { return } + await self.retryCleanItems(capturedItems) + } + } + } + + private func retryCleanItems(_ items: [CleanableItem]) async { + scanState = .cleaning(progress: 0) + cleanProgress = 0 + + var result = await cleaningEngine.cleanItems(items) { [weak self] progress in + Task { @MainActor [weak self] in + self?.cleanProgress = progress + self?.scanState = .cleaning(progress: progress) + } + } + if !result.requiresAdmin.isEmpty { + let admin = await cleaningEngine.cleanWithAdminPrivileges(items: result.requiresAdmin) + result.cleanedPaths.formUnion(admin.cleanedPaths) + result.itemsCleaned += admin.itemsCleaned + result.freedSpace += admin.freedSpace + result.errors.append(contentsOf: admin.errors) + } + + totalFreedSpace = result.freedSpace + lastCleanedDate = Date() + + for (cat, catResult) in categoryResults { + let remaining = catResult.items.filter { !result.cleanedPaths.contains($0.path) } + let cleared = catResult.items.filter { result.cleanedPaths.contains($0.path) } + for item in cleared { + selectedCleanupItems.remove(item.id) + deselectedItems.remove(item.id) + } + if remaining.isEmpty { + categoryResults.removeValue(forKey: cat) + } else { + categoryResults[cat] = CategoryResult( + category: cat, + items: remaining, + totalSize: remaining.reduce(0) { $0 + $1.size } + ) + } + } + totalJunkSize = categoryResults.values.reduce(0) { $0 + $1.totalSize } + + // Route survivors back through the same outcome path the original + // cleanup uses. Without this, an FDA revocation between grant and + // retry would silently drop errors instead of re-popping the sheet. + let survivors = items.filter { !result.cleanedPaths.contains($0.path) } + handleCleanOutcome(errors: result.errors, survivors: survivors) + + scanState = .cleaned + loadDiskInfo() + try? await Task.sleep(nanoseconds: 3_000_000_000) + scanState = .idle + totalFreedSpace = 0 + } + + // MARK: - Disk Info + + func loadDiskInfo() { + Task { + let info = await scanEngine.getDiskInfo() + self.diskInfo = info + } + } + + // MARK: - Scanning + + func startSmartScan() { + guard !scanState.isActive else { return } + + scanState = .scanning(progress: 0, currentCategory: "Preparing...") + categoryResults = [:] + totalJunkSize = 0 + scanProgress = 0 + clearSelectionState() + + Task { + let categories = CleaningCategory.scannable + let total = categories.count + + for (index, category) in categories.enumerated() { + let progress = Double(index) / Double(total) + scanProgress = progress + currentScanCategory = category.rawValue + scanState = .scanning(progress: progress, currentCategory: category.rawValue) + + let result = await scanEngine.scanCategory(category) { [weak self] path in + Task { @MainActor [weak self] in + self?.scanTicker.path = path + } + } + categoryResults[category] = result + totalJunkSize += result.totalSize + } + + scanProgress = 1.0 + scanTicker.path = "" + scanState = .completed + loadDiskInfo() + } + } + + func scanSingleCategory(_ category: CleaningCategory) { + guard !scanState.isActive else { return } + + scanState = .scanning(progress: 0, currentCategory: category.rawValue) + scanProgress = 0 + + Task { + scanProgress = 0.5 + clearSelectionState(for: category) + let result = await scanEngine.scanCategory(category) { [weak self] path in + Task { @MainActor [weak self] in + self?.scanTicker.path = path + } + } + categoryResults[category] = result + + totalJunkSize = categoryResults.values.reduce(0) { $0 + $1.totalSize } + scanProgress = 1.0 + scanTicker.path = "" + scanState = .completed + } + } + + // MARK: - Cleaning + + func cleanAll() { + guard !scanState.isActive else { return } + + let itemsToClean = allResults.flatMap { $0.items }.filter { isItemSelected($0) } + guard !itemsToClean.isEmpty else { return } + + scanState = .cleaning(progress: 0) + cleanProgress = 0 + + Task { + var result = await cleaningEngine.cleanItems(itemsToClean) { [weak self] progress in + Task { @MainActor [weak self] in + self?.cleanProgress = progress + self?.scanState = .cleaning(progress: progress) + } + } + + // Escalate root-owned items via "with administrator privileges". + // One auth prompt covers the entire batch. + if !result.requiresAdmin.isEmpty { + let admin = await cleaningEngine.cleanWithAdminPrivileges(items: result.requiresAdmin) + result.cleanedPaths.formUnion(admin.cleanedPaths) + result.itemsCleaned += admin.itemsCleaned + result.freedSpace += admin.freedSpace + result.errors.append(contentsOf: admin.errors) + } + + totalFreedSpace = result.freedSpace + lastCleanedDate = Date() + if result.itemsCleaned > 0 { Haptics.successWithSound() } + + let survivors = itemsToClean.filter { !result.cleanedPaths.contains($0.path) } + + for (cat, catResult) in categoryResults { + let remaining = catResult.items.filter { !result.cleanedPaths.contains($0.path) } + let cleared = catResult.items.filter { result.cleanedPaths.contains($0.path) } + for item in cleared { + selectedCleanupItems.remove(item.id) + deselectedItems.remove(item.id) + } + if remaining.isEmpty { + categoryResults.removeValue(forKey: cat) + } else { + categoryResults[cat] = CategoryResult( + category: cat, + items: remaining, + totalSize: remaining.reduce(0) { $0 + $1.size } + ) + } + } + totalJunkSize = categoryResults.values.reduce(0) { $0 + $1.totalSize } + + handleCleanOutcome(errors: result.errors, survivors: survivors) + + scanState = .cleaned + loadDiskInfo() + + try? await Task.sleep(nanoseconds: 3_000_000_000) + scanState = .idle + totalFreedSpace = 0 + } + } + + func cleanCategory(_ category: CleaningCategory) { + guard let result = categoryResults[category], !scanState.isActive else { return } + + let selectedItems = result.items.filter { isItemSelected($0) } + guard !selectedItems.isEmpty else { return } + + scanState = .cleaning(progress: 0) + cleanProgress = 0 + + Task { + var cleanResult = await cleaningEngine.cleanItems(selectedItems) { [weak self] progress in + Task { @MainActor [weak self] in + self?.cleanProgress = progress + self?.scanState = .cleaning(progress: progress) + } + } + + if !cleanResult.requiresAdmin.isEmpty { + let admin = await cleaningEngine.cleanWithAdminPrivileges(items: cleanResult.requiresAdmin) + cleanResult.cleanedPaths.formUnion(admin.cleanedPaths) + cleanResult.itemsCleaned += admin.itemsCleaned + cleanResult.freedSpace += admin.freedSpace + cleanResult.errors.append(contentsOf: admin.errors) + } + + totalFreedSpace = cleanResult.freedSpace + lastCleanedDate = Date() + + if let existing = categoryResults[category] { + let remaining = existing.items.filter { !cleanResult.cleanedPaths.contains($0.path) } + let cleared = existing.items.filter { cleanResult.cleanedPaths.contains($0.path) } + for item in cleared { + selectedCleanupItems.remove(item.id) + deselectedItems.remove(item.id) + } + if remaining.isEmpty { + categoryResults.removeValue(forKey: category) + } else { + categoryResults[category] = CategoryResult( + category: category, + items: remaining, + totalSize: remaining.reduce(0) { $0 + $1.size } + ) + } + } + totalJunkSize = categoryResults.values.reduce(0) { $0 + $1.totalSize } + + let survivors = selectedItems.filter { !cleanResult.cleanedPaths.contains($0.path) } + handleCleanOutcome(errors: cleanResult.errors, survivors: survivors) + + scanState = .cleaned + loadDiskInfo() + + try? await Task.sleep(nanoseconds: 3_000_000_000) + scanState = .idle + totalFreedSpace = 0 + } + } + + /// Inspect a clean batch's leftovers and either route the user into the + /// PermissionSheet (FDA is the most likely cause) or surface a richer + /// error alert that lists actual paths instead of "Check the log". + private func handleCleanOutcome(errors: [String], survivors: [CleanableItem]) { + guard !errors.isEmpty || !survivors.isEmpty else { + cleanError = nil + cleanErrorIsFDAFixable = false + pendingPermissionRetryItems = [] + return + } + + let fdaGranted = FullDiskAccessManager.shared.hasFullDiskAccess + let likelyFDA = !fdaGranted && !survivors.isEmpty + cleanErrorIsFDAFixable = likelyFDA + pendingPermissionRetryItems = survivors + + if likelyFDA { + cleanError = String( + format: String(localized: "%lld item(s) need Full Disk Access to remove. Tap Grant Access to fix in one step."), + Int64(survivors.count) + ) + } else if !survivors.isEmpty { + let preview = survivors.prefix(2).map { ($0.path as NSString).lastPathComponent }.joined(separator: ", ") + let extra = survivors.count > 2 ? String(format: String(localized: " and %lld more"), Int64(survivors.count - 2)) : "" + cleanError = String( + format: String(localized: "Couldn't remove %@%@. They may be in use or protected by macOS."), + preview, extra + ) + } else if let first = errors.first { + cleanError = first + } + } + + // MARK: - Purgeable + + func purgePurgeable() { + guard !scanState.isActive else { return } + + scanState = .cleaning(progress: 0) + + Task { + scanState = .cleaning(progress: 0.5) + let freed = await cleaningEngine.purgePurgeableSpace() + totalFreedSpace = freed + scanState = .cleaned + loadDiskInfo() + + try? await Task.sleep(nanoseconds: 3_000_000_000) + scanState = .idle + totalFreedSpace = 0 + } + } + + // MARK: - Scheduled Scan + + private func runScheduledScan() async { + let categories = scheduler.config.categoriesToScan + var totalFound: Int64 = 0 + clearSelectionState() + categoryResults = [:] + + for category in categories { + let result = await scanEngine.scanCategory(category) + categoryResults[category] = result + totalFound += result.totalSize + } + + totalJunkSize = totalFound + + if scheduler.config.autoClean && totalFound >= scheduler.config.minimumCleanSize { + cleanAll() + } + + // Purgeable space is intentionally NOT auto-purged: macOS reserves and + // reclaims it on its own and PureMac does not claim to free it. See + // CleaningCategory.scannable. + + if scheduler.config.notifyOnCompletion { + sendNotification(freed: totalFound) + } + } + + private func sendNotification(freed: Int64) { + let content = UNMutableNotificationContent() + content.title = "PureMac" + let sizeStr = ByteCountFormatter.string(fromByteCount: freed, countStyle: .file) + content.body = String(format: NSLocalizedString("Found %@ of junk files.", comment: ""), sizeStr) + content.sound = .default + + let request = UNNotificationRequest( + identifier: UUID().uuidString, + content: content, + trigger: nil + ) + + UNUserNotificationCenter.current().add(request) + } + + private static func defaultAppFileScanner( + app: InstalledApp, + locations: Locations, + completion: @escaping (Set) -> Void + ) { + let appInfo = AppPathFinder.AppInfo( + appName: app.appName, + bundleIdentifier: app.bundleIdentifier, + path: app.path, + entitlements: nil, + teamIdentifier: nil + ) + let finder = AppPathFinder(appInfo: appInfo, locations: locations) + finder.findPathsAsync(completion: completion) + } +} diff --git a/PureMac/Views/Apps/AppFilesView.swift b/PureMac/Views/Apps/AppFilesView.swift new file mode 100644 index 0000000..a922357 --- /dev/null +++ b/PureMac/Views/Apps/AppFilesView.swift @@ -0,0 +1,504 @@ +import SwiftUI + +/// View-side grouping of discovered leftovers into CleanMyMac-style buckets. +/// Purely presentational — AppState's flat `discoveredFiles` stays the source +/// of truth so the removal/selection logic is untouched. +enum LeftoverGroup: String, CaseIterable, Identifiable { + case application = "Application" + case caches = "Caches" + case appSupport = "Application Support" + case preferences = "Preferences" + case logs = "Logs" + case containers = "Containers" + case launchAgents = "Launch Agents" + case other = "Other Files" + + var id: String { rawValue } + + var icon: String { + switch self { + case .application: return "app.fill" + case .caches: return "internaldrive.fill" + case .appSupport: return "shippingbox.fill" + case .preferences: return "gearshape.fill" + case .logs: return "doc.text.fill" + case .containers: return "cube.box.fill" + case .launchAgents: return "bolt.fill" + case .other: return "doc.fill" + } + } + + var tint: Color { + switch self { + case .application: return Tint.blue + case .caches: return Tint.orange + case .appSupport: return Tint.purple + case .preferences: return Tint.cyan + case .logs: return Tint.yellow + case .containers: return Tint.pink + case .launchAgents: return Tint.red + case .other: return Tint.green + } + } + + static func categorize(_ url: URL) -> LeftoverGroup { + let path = url.path + if path.hasSuffix(".app") { return .application } + if path.contains("/Caches/") { return .caches } + if path.contains("/Application Support/") { return .appSupport } + if path.contains("/Preferences/") { return .preferences } + if path.contains("/Logs/") || path.contains("/DiagnosticReports/") { return .logs } + if path.contains("/Containers/") || path.contains("/Group Containers/") { return .containers } + if path.contains("/LaunchAgents/") || path.contains("/LaunchDaemons/") { return .launchAgents } + return .other + } +} + +struct AppFilesView: View { + @EnvironmentObject var appState: AppState + let app: InstalledApp + + @State private var collapsedGroups: Set = [] + @State private var iconHovering = false + /// One-pass size cache so group headers and the selected-size counter + /// don't re-stat the disk on every render. + @State private var sizeCache: [URL: Int64] = [:] + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + private var totalSelectedSize: Int64 { + appState.selectedFiles.reduce(Int64(0)) { total, url in + total + (cachedSize(url) ?? 0) + } + } + + /// Discovered files bucketed for display, preserving the sorted order + /// inside each bucket. Only non-empty groups are shown. + private var groupedFiles: [(group: LeftoverGroup, urls: [URL])] { + let buckets = Dictionary(grouping: appState.discoveredFiles, by: LeftoverGroup.categorize) + return LeftoverGroup.allCases.compactMap { group in + guard let urls = buckets[group], !urls.isEmpty else { return nil } + return (group, urls) + } + } + + var body: some View { + VStack(spacing: 0) { + header + + Divider() + + // Content + if appState.isScanningAppFiles { + scanningState + } else if appState.discoveredFiles.isEmpty { + EmptyStateView( + "No Related Files", + systemImage: "checkmark.circle", + description: LocalizedStringKey( + String(format: String(localized: "No additional files found for %@."), app.appName) + ), + tint: Tint.green + ) + } else { + fileGroupsList + + actionBar + } + } + .onAppear { rebuildSizeCache() } + .onChange(of: appState.discoveredFiles) { _ in rebuildSizeCache() } + .onChange(of: appState.removalNeedsFullDiskAccess) { needs in + // FDA-fixable removals jump straight into the rich sheet, the + // same flow cleanup uses. The user grants permission once and we + // re-fire the failed batch — using the frozen snapshot from + // AppState so a mid-sheet selection change or app switch doesn't + // re-trash the wrong files. + guard needs else { return } + let toRetry = appState.lastFailedRemovalURLs + let items = toRetry.map { appState.makeUninstallCleanableItem(for: $0) } + appState.removalError = nil + appState.removalNeedsFullDiskAccess = false + appState.lastFailedRemovalURLs = [] + appState.requestFullDiskAccessAndRetry( + items: items, + context: .uninstall(appName: app.appName, failedCount: items.count) + ) + } + .alert("Removal Failed", isPresented: Binding( + get: { appState.removalError != nil && !appState.removalNeedsFullDiskAccess }, + set: { + if !$0 { + appState.removalError = nil + appState.removalNeedsFullDiskAccess = false + } + } + )) { + Button("OK", role: .cancel) { + appState.removalError = nil + appState.removalNeedsFullDiskAccess = false + } + } message: { + Text(appState.removalError ?? "") + } + } + + // MARK: - Header + + private var header: some View { + HStack(spacing: 12) { + Image(nsImage: app.icon) + .resizable() + .frame(width: 48, height: 48) + .scaleEffect(iconHovering && !reduceMotion ? 1.06 : 1) + .animation(reduceMotion ? nil : MotionTokens.snappy, value: iconHovering) + .onHover { iconHovering = $0 } + + VStack(alignment: .leading, spacing: 2) { + Text(app.appName) + .font(.title3.bold()) + Text(app.bundleIdentifier) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + if !appState.discoveredFiles.isEmpty { + VStack(alignment: .trailing, spacing: 2) { + Text(filesCountText(count: appState.discoveredFiles.count)) + .font(.callout) + .foregroundStyle(.secondary) + .monospacedDigit() + .contentTransition(.numericText()) + CountUpBytes(bytes: totalSelectedSize) + .font(.callout.bold()) + } + .animation(reduceMotion ? nil : .spring(response: 0.4, dampingFraction: 0.9), + value: appState.discoveredFiles.count) + } + } + .padding() + } + + // MARK: - Scanning state + + private var scanningState: some View { + VStack(spacing: 14) { + Spacer() + if reduceMotion { + ProgressView(LocalizedStringKey("Scanning for related files...")) + } else { + SearchPulse() + Text("Scanning for related files...") + .font(.system(size: 13, weight: .medium)) + } + Text(checkingLocationsText(count: appState.currentAppFileSearchLocationCount)) + .font(.caption) + .foregroundStyle(.secondary) + .monospacedDigit() + .contentTransition(.numericText()) + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + // MARK: - Grouped list + + private var fileGroupsList: some View { + List { + ForEach(groupedFiles, id: \.group) { entry in + DisclosureGroup(isExpanded: groupExpansionBinding(entry.group)) { + // No .staggered() inside the lazy List — a delayed reveal + // would blank rows as they scroll in. The removal + // transition still sweeps deleted rows out. + ForEach(entry.urls, id: \.self) { fileURL in + FileRow( + fileURL: fileURL, + isSelected: fileSelectionBinding(for: fileURL), + fileSize: cachedSize(fileURL), + onRemove: { removeSingleFile(fileURL) } + ) + .transition( + reduceMotion + ? .opacity + : .asymmetric( + insertion: .opacity, + removal: .move(edge: .leading).combined(with: .opacity) + ) + ) + } + } label: { + groupHeader(entry.group, urls: entry.urls) + } + } + } + .id(app.id) + } + + private func groupHeader(_ group: LeftoverGroup, urls: [URL]) -> some View { + let groupSize = urls.reduce(Int64(0)) { $0 + (cachedSize($1) ?? 0) } + let allSelected = urls.allSatisfy { appState.selectedFiles.contains($0) } + + return HStack(spacing: 10) { + Toggle(isOn: Binding( + get: { allSelected }, + set: { selected in + if selected { + appState.selectedFiles.formUnion(urls) + } else { + appState.selectedFiles.subtract(urls) + } + } + )) { + EmptyView() + } + .toggleStyle(AnimatedCheckboxStyle()) + .labelsHidden() + + IconTile(systemName: group.icon, tint: group.tint, size: 22, corner: 6) + Text(LocalizedStringKey(group.rawValue)) + .font(.system(size: 12.5, weight: .semibold)) + Text("\(urls.count)") + .font(.system(size: 11, weight: .semibold)) + .monospacedDigit() + .foregroundStyle(.secondary) + .padding(.horizontal, 6) + .padding(.vertical, 1) + .background(Capsule().fill(Color.primary.opacity(0.06))) + Spacer() + Text(ByteCountFormatter.string(fromByteCount: groupSize, countStyle: .file)) + .font(.system(size: 12, weight: .semibold)) + .monospacedDigit() + .foregroundStyle(.secondary) + } + .padding(.vertical, 2) + } + + private func groupExpansionBinding(_ group: LeftoverGroup) -> Binding { + Binding( + get: { !collapsedGroups.contains(group) }, + set: { expanded in + let change = { + if expanded { + collapsedGroups.remove(group) + } else { + collapsedGroups.insert(group) + } + } + if reduceMotion { + change() + } else { + withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { change() } + } + } + ) + } + + // MARK: - Action bar + + private var actionBar: some View { + HStack { + Button("Select All") { + appState.selectedFiles = Set(appState.discoveredFiles) + } + Button("Deselect All") { + appState.selectedFiles.removeAll() + } + + Spacer() + + if !appState.selectedFiles.isEmpty { + Button(role: .destructive) { + appState.removeSelectedFiles() + } label: { + Text(removeFilesLabel) + } + .buttonStyle(GlowProminentButtonStyle(tint: Tint.red, gradient: TintGradient.destructive)) + .transition( + reduceMotion + ? .opacity + : .move(edge: .trailing).combined(with: .opacity) + ) + } + } + .animation(reduceMotion ? nil : .spring(response: 0.35, dampingFraction: 0.8), + value: appState.selectedFiles.isEmpty) + .padding() + .background(.bar) + } + + // MARK: - Helpers + + private func filesCountText(count: Int) -> String { + String(format: String(localized: "%lld files"), Int64(count)) + } + + private func checkingLocationsText(count: Int) -> String { + String(format: String(localized: "Checking %lld locations..."), Int64(count)) + } + + private var removeFilesLabel: String { + String( + format: String(localized: "Remove %lld files (%@)"), + Int64(appState.selectedFiles.count), + ByteCountFormatter.string(fromByteCount: totalSelectedSize, countStyle: .file) + ) + } + + private func fileSelectionBinding(for url: URL) -> Binding { + Binding( + get: { appState.selectedFiles.contains(url) }, + set: { selected in + if selected { + appState.selectedFiles.insert(url) + } else { + appState.selectedFiles.remove(url) + } + } + ) + } + + private func cachedSize(_ url: URL) -> Int64? { + if let cached = sizeCache[url] { return cached } + return fileSize(url) + } + + private func rebuildSizeCache() { + var cache: [URL: Int64] = [:] + for url in appState.discoveredFiles { + cache[url] = fileSize(url) ?? 0 + } + sizeCache = cache + } + + private func fileSize(_ url: URL) -> Int64? { + FileSizeCalculator.size(of: url) + } + + private func removeSingleFile(_ url: URL) { + appState.selectedFiles = [url] + appState.removeSelectedFiles() + } +} + +/// Magnifier over expanding sonar rings — the "actively searching" beat for +/// the related-files scan. Only built when Reduce Motion is off. +private struct SearchPulse: View { + @State private var pulse = false + + var body: some View { + ZStack { + ForEach(0..<2, id: \.self) { i in + Circle() + .stroke(Tint.blue.opacity(0.35), lineWidth: 1.5) + .frame(width: 44, height: 44) + .scaleEffect(pulse ? 1.8 : 1.0) + .opacity(pulse ? 0 : 0.7) + .animation( + .easeOut(duration: 1.4) + .repeatForever(autoreverses: false) + .delay(Double(i) * 0.7), + value: pulse + ) + } + Circle() + .fill(Tint.blue.opacity(0.12)) + .frame(width: 44, height: 44) + Image(systemName: "magnifyingglass") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(Tint.blue) + } + .frame(width: 56, height: 56) + .onAppear { pulse = true } + } +} + +// MARK: - File Row with hover-to-reveal actions + +struct FileRow: View { + let fileURL: URL + @Binding var isSelected: Bool + let fileSize: Int64? + let onRemove: () -> Void + + @State private var isHovering = false + @State private var showConfirmation = false + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + Toggle(isOn: $isSelected) { + HStack { + Image(nsImage: NSWorkspace.shared.icon(forFile: fileURL.path)) + .resizable() + .frame(width: 16, height: 16) + + VStack(alignment: .leading, spacing: 2) { + Text(fileURL.lastPathComponent) + .lineLimit(1) + Text(fileURL.path) + .font(.caption) + .foregroundStyle(.tertiary) + .lineLimit(1) + .truncationMode(.middle) + } + + Spacer() + + // Buttons stay in the layout permanently and fade with + // hover, so the trailing size badge never jumps sideways. + Button { + NSWorkspace.shared.selectFile(fileURL.path, inFileViewerRootedAtPath: "") + } label: { + Image(systemName: "folder") + .font(.caption) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .help("Reveal in Finder") + .opacity(isHovering ? 1 : 0) + .scaleEffect(reduceMotion ? 1 : (isHovering ? 1 : 0.8)) + .allowsHitTesting(isHovering) + + Button { + showConfirmation = true + } label: { + Image(systemName: "trash") + .font(.caption) + .foregroundStyle(.red) + } + .buttonStyle(.plain) + .help("Remove this file") + .opacity(isHovering ? 1 : 0) + .scaleEffect(reduceMotion ? 1 : (isHovering ? 1 : 0.8)) + .allowsHitTesting(isHovering) + + if let size = fileSize { + Text(ByteCountFormatter.string(fromByteCount: size, countStyle: .file)) + .monospacedDigit() + .foregroundStyle(.secondary) + } + } + } + .toggleStyle(AnimatedCheckboxStyle()) + .padding(.vertical, 2) + .padding(.horizontal, 4) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(isHovering ? Color.primary.opacity(0.06) : Color.clear) + ) + .scaleEffect(isHovering && !reduceMotion ? 1.01 : 1) + .animation(reduceMotion ? nil : MotionTokens.snappy, value: isHovering) + .onHover { isHovering = $0 } + .alert( + Text( + String(format: String(localized: "Remove %@?"), fileURL.lastPathComponent) + ), + isPresented: $showConfirmation + ) { + Button("Cancel", role: .cancel) {} + Button("Remove", role: .destructive) { onRemove() } + } message: { + Text("This will permanently delete this file. This action cannot be undone.") + } + } +} diff --git a/PureMac/Views/Apps/AppListView.swift b/PureMac/Views/Apps/AppListView.swift new file mode 100644 index 0000000..b2dad5c --- /dev/null +++ b/PureMac/Views/Apps/AppListView.swift @@ -0,0 +1,162 @@ +import SwiftUI + +struct AppListView: View { + @EnvironmentObject var appState: AppState + @State private var searchText = "" + @State private var selection: InstalledApp.ID? + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var sortOrder: [KeyPathComparator] = [ + .init(\.appName, order: .forward) + ] + + private var filteredApps: [InstalledApp] { + let base: [InstalledApp] + if searchText.isEmpty { + base = appState.installedApps + } else { + let query = searchText.lowercased() + base = appState.installedApps.filter { + $0.appName.lowercased().contains(query) || + $0.bundleIdentifier.lowercased().contains(query) + } + } + return base.sorted(using: sortOrder) + } + + var body: some View { + HSplitView { + // Cap the left pane's maxWidth so dragging the splitter cannot + // push it past half the window and break the layout (#60). + appTable + .frame(minWidth: 300, idealWidth: 380, maxWidth: 600) + + fileDetail + .frame(minWidth: 300) + } + .searchable(text: $searchText, prompt: "Search apps") + .navigationTitle(installedAppsTitle) + .toolbar { + ToolbarItemGroup { + Button { + appState.loadInstalledApps() + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + + // ToolbarItems can't run insertion transitions on macOS 13 — + // keep the button mounted and fade it with the selection. + Button(uninstallLabel(count: appState.selectedFiles.count), role: .destructive) { + appState.removeSelectedFiles() + } + .buttonStyle(.borderedProminent) + .tint(.red) + .opacity(appState.selectedFiles.isEmpty ? 0 : 1) + .disabled(appState.selectedFiles.isEmpty) + .animation(reduceMotion ? nil : .spring(response: 0.35, dampingFraction: 0.8), + value: appState.selectedFiles.isEmpty) + } + } + } + + private var installedAppsTitle: String { + String(format: String(localized: "Installed Apps (%lld)"), Int64(appState.installedApps.count)) + } + + private func uninstallLabel(count: Int) -> String { + String(format: String(localized: "Uninstall (%lld files)"), Int64(count)) + } + + // MARK: - App Table (left side) + + private var appTable: some View { + Group { + if appState.isLoadingApps { + VStack(spacing: 12) { + ProgressView(LocalizedStringKey("Loading installed apps...")) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if appState.installedApps.isEmpty { + EmptyStateView( + "No Apps Found", + systemImage: "square.grid.2x2", + description: "Could not find any installed applications.", + action: { appState.loadInstalledApps() }, + actionLabel: "Retry" + ) + } else { + Table(filteredApps, selection: $selection, sortOrder: $sortOrder) { + TableColumn("Application", value: \.appName) { app in + HStack(spacing: 8) { + HoverScaleIcon(icon: app.icon) + Text(app.appName) + } + } + .width(min: 150) + + TableColumn("Size", value: \.size) { app in + Text(app.formattedSize) + .monospacedDigit() + .foregroundStyle(.secondary) + } + .width(ideal: 70) + } + .onChange(of: selection) { newValue in + guard let id = newValue, + let app = appState.installedApps.first(where: { $0.id == id }) + else { return } + // Skip when the selection was just synced from an external + // (Finder Services) hand-off that already scanned this app, + // so we don't fire a redundant second scan. + guard appState.selectedApp?.id != app.id else { return } + appState.selectedApp = app + appState.scanForAppFiles(app) + } + .onChange(of: appState.selectedApp) { app in + // Reflect an externally-driven selection (Finder Services) + // in the table highlight. + if selection != app?.id { selection = app?.id } + } + .onAppear { + // Sync the highlight when this view mounts already pointed + // at an externally-selected app. + if selection != appState.selectedApp?.id { + selection = appState.selectedApp?.id + } + } + } + } + } + + // MARK: - File Detail (right side) + + @ViewBuilder + private var fileDetail: some View { + if let app = appState.selectedApp { + AppFilesView(app: app) + } else { + EmptyStateView( + "Select an App", + systemImage: "cursorarrow.click.2", + description: "Select an app from the list to see all its related files across your system.", + tint: Tint.purple + ) + } + } +} + +/// App icon that scales up slightly on hover inside a Table cell. +private struct HoverScaleIcon: View { + let icon: NSImage + + @State private var hovering = false + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + Image(nsImage: icon) + .resizable() + .frame(width: 20, height: 20) + .scaleEffect(hovering && !reduceMotion ? 1.12 : 1) + .animation(reduceMotion ? nil : MotionTokens.snappy, value: hovering) + .onHover { hovering = $0 } + } +} diff --git a/PureMac/Views/CategoryDetailView.swift b/PureMac/Views/CategoryDetailView.swift new file mode 100644 index 0000000..d87e62b --- /dev/null +++ b/PureMac/Views/CategoryDetailView.swift @@ -0,0 +1,299 @@ +import SwiftUI + +struct CategoryDetailView: View { + @EnvironmentObject var appState: AppState + let category: CleaningCategory + + @State private var sortDescending: Bool = true + @State private var searchText = "" + @State private var showConfirmation = false + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + private var result: CategoryResult? { + appState.categoryResults[category] + } + + var body: some View { + VStack(spacing: 0) { + heroCard + .padding(.horizontal, 20) + .padding(.top, 16) + .padding(.bottom, 12) + + Group { + if let result = result { + if result.items.isEmpty { + EmptyStateView("All Clean", systemImage: "checkmark.circle", description: "No junk files found in this category.", tint: Tint.green) + } else { + fileList(result) + } + } else { + EmptyStateView("Not Scanned", systemImage: category.icon, description: "Run a scan to analyze this category.", action: { appState.scanSingleCategory(category) }, actionLabel: "Scan Now", tint: category.color) + } + } + } + .searchable(text: $searchText, prompt: "Filter files") + .navigationTitle(Text(LocalizedStringKey(category.rawValue))) + .toolbar { + ToolbarItem(placement: .automatic) { + Button { + appState.scanSingleCategory(category) + } label: { + Label("Scan", systemImage: "arrow.clockwise") + } + .disabled(appState.scanState.isActive) + } + + ToolbarItemGroup(placement: .automatic) { + if let result = result, !result.items.isEmpty { + Button("Select All") { + appState.selectAllInCategory(category) + } + Button("Deselect All") { + appState.deselectAllInCategory(category) + } + Button(action: { sortDescending.toggle() }) { + Label { + Text(LocalizedStringKey(sortDescending ? "Largest First" : "Smallest First")) + } icon: { + Image(systemName: "arrow.up.arrow.down") + } + } + .help(LocalizedStringKey(sortDescending ? "Sorted: Largest First" : "Sorted: Smallest First")) + } + } + + ToolbarItem(placement: .automatic) { + if let _ = result, !appState.scanState.isActive { + let selectedSize = appState.selectedSizeInCategory(category) + let selectedCount = appState.selectedCountInCategory(category) + if selectedSize > 0 { + Button { + showConfirmation = true + } label: { + Label { + Text(cleanItemsLabel(count: selectedCount)) + } icon: { + Image(systemName: "trash") + } + } + } + } + } + } + .confirmationDialog(cleanConfirmationTitle, isPresented: $showConfirmation, titleVisibility: .visible) { + Button("Clean", role: .destructive) { + appState.cleanCategory(category) + } + Button("Cancel", role: .cancel) {} + } message: { + Text("This will permanently delete the selected files. This cannot be undone.") + } + } + + private func cleanItemsLabel(count: Int) -> String { + String(format: String(localized: "Clean %lld items"), Int64(count)) + } + + private var cleanConfirmationTitle: String { + String( + format: String(localized: "Clean %@?"), + ByteCountFormatter.string(fromByteCount: appState.selectedSizeInCategory(category), countStyle: .file) + ) + } + + // MARK: - Hero + + private var heroCard: some View { + let totalSize = result?.totalSize ?? 0 + let itemCount = result?.itemCount ?? 0 + let isScanning = appState.scanState.isActive + + return CardSurface(padding: 18) { + HStack(alignment: .center, spacing: 16) { + IconTile(systemName: category.icon, tint: category.color, size: 56, corner: 14) + + VStack(alignment: .leading, spacing: 4) { + Text(LocalizedStringKey(category.rawValue)) + .font(.system(size: 22, weight: .semibold)) + Text(LocalizedStringKey(category.description)) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + if itemCount > 0 { + Text(itemsAndSizeText(itemCount: itemCount, totalSize: totalSize)) + .font(.system(size: 11.5, weight: .medium)) + .monospacedDigit() + .contentTransition(reduceMotion ? .identity : .numericText()) + .foregroundStyle(category.color) + .padding(.top, 2) + .animation(reduceMotion ? nil : MotionTokens.gentle, value: totalSize) + } + } + + Spacer() + + Button { + appState.scanSingleCategory(category) + } label: { + Label { + Text(scanButtonLabel(isScanning: isScanning, hasResult: result != nil)) + } icon: { + Image(systemName: "arrow.clockwise") + } + .font(.system(size: 12.5, weight: .semibold)) + } + .buttonStyle(.bordered) + .controlSize(.large) + .disabled(isScanning) + } + } + } + + private func itemsAndSizeText(itemCount: Int, totalSize: Int64) -> String { + String( + format: String(localized: "%lld items · %@"), + Int64(itemCount), + ByteCountFormatter.string(fromByteCount: totalSize, countStyle: .file) + ) + } + + private func scanButtonLabel(isScanning: Bool, hasResult: Bool) -> LocalizedStringKey { + if isScanning { return "Scanning…" } + if hasResult { return "Rescan" } + return "Scan" + } + + // MARK: - File List + + private func fileList(_ result: CategoryResult) -> some View { + let items = sortedItems(result.items).filter { item in + searchText.isEmpty || item.name.localizedCaseInsensitiveContains(searchText) || item.path.localizedCaseInsensitiveContains(searchText) + } + return List { + Section { + // No .staggered() here: List is lazy, so a delayed-reveal + // modifier would blank each row for ~0.45s as it scrolls into + // view on large scans. The row hover/selection polish carries + // the motion; the list-level sort/filter animation handles + // reorders. + ForEach(Array(items.enumerated()), id: \.element.id) { _, item in + FileRowView(item: item) + } + } header: { + let selectedCount = appState.selectedCountInCategory(category) + let totalCount = result.itemCount + Text( + String( + format: String(localized: "%lld of %lld selected"), + Int64(selectedCount), + Int64(totalCount) + ) + ) + .monospacedDigit() + .contentTransition(reduceMotion ? .identity : .numericText()) + .animation(reduceMotion ? nil : MotionTokens.gentle, value: selectedCount) + } + } + // CleanableItem ids are stable, so SwiftUI move-animates re-sorts and + // fades filtered rows instead of snapping. + .animation(reduceMotion ? nil : .spring(response: 0.4, dampingFraction: 0.85), value: sortDescending) + .animation(reduceMotion ? nil : .easeOut(duration: 0.2), value: searchText) + } + + private func sortedItems(_ items: [CleanableItem]) -> [CleanableItem] { + items.sorted { sortDescending ? $0.size > $1.size : $0.size < $1.size } + } +} + +// MARK: - File Row View + +private struct FileRowView: View { + @EnvironmentObject var appState: AppState + let item: CleanableItem + + @State private var hovering = false + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + private var isSelected: Bool { + appState.isItemSelected(item) + } + + var body: some View { + Toggle(isOn: Binding( + get: { isSelected }, + set: { _ in appState.toggleItem(item) } + )) { + HStack { + Image(systemName: fileIcon) + .foregroundStyle(.secondary) + .frame(width: 20) + + VStack(alignment: .leading, spacing: 2) { + Text(item.name) + .lineLimit(1) + .truncationMode(.middle) + + if !item.path.isEmpty { + Text(item.path) + .font(.caption) + .foregroundStyle(.tertiary) + .lineLimit(1) + .truncationMode(.middle) + } + } + + Spacer() + + if let date = item.lastModified { + Text(date, style: .date) + .font(.caption) + .foregroundStyle(.secondary) + } + + Text(item.formattedSize) + .font(.callout) + .fontWeight(.medium) + .monospacedDigit() + .frame(width: 80, alignment: .trailing) + } + } + .toggleStyle(AnimatedCheckboxStyle(tint: item.category.color)) + .padding(.vertical, 2) + .padding(.horizontal, 4) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill( + hovering + ? Color.primary.opacity(0.06) + : (isSelected ? item.category.color.opacity(0.04) : .clear) + ) + ) + .animation(reduceMotion ? nil : MotionTokens.snappy, value: hovering) + .animation(reduceMotion ? nil : .easeOut(duration: 0.15), value: isSelected) + .onHover { hovering = $0 } + .contextMenu { + if !item.path.isEmpty { + Button("Reveal in Finder") { + NSWorkspace.shared.selectFile(item.path, inFileViewerRootedAtPath: "") + } + } + } + } + + private var fileIcon: String { + let ext = (item.name as NSString).pathExtension.lowercased() + switch ext { + case "log", "txt": return "doc.text" + case "zip", "gz", "tar": return "doc.zipper" + case "dmg", "iso": return "opticaldisc" + case "app": return "app" + case "pkg": return "shippingbox" + default: + var isDir: ObjCBool = false + if FileManager.default.fileExists(atPath: item.path, isDirectory: &isDir), isDir.boolValue { + return "folder" + } + return "doc" + } + } +} diff --git a/PureMac/Views/Components/AppBundleDragHandle.swift b/PureMac/Views/Components/AppBundleDragHandle.swift new file mode 100644 index 0000000..70771da --- /dev/null +++ b/PureMac/Views/Components/AppBundleDragHandle.swift @@ -0,0 +1,78 @@ +import AppKit +import SwiftUI + +/// Draggable PureMac.app affordance. The user grabs the icon out of our sheet +/// and drops it into the Full Disk Access list inside System Settings, which +/// auto-adds the bundle without making them hunt for it in Finder. +/// +/// macOS's Privacy & Security panes accept file-URL drops in their service +/// lists. We expose the running app's bundle URL via `NSItemProvider` so the +/// drop registers as a legitimate filesystem drag — identical to dragging +/// from Finder, just sourced from inside the app. +struct AppBundleDragHandle: View { + @State private var hovering = false + + private var bundleURL: URL { + Bundle.main.bundleURL + } + + var body: some View { + VStack(spacing: 8) { + ZStack { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(Color.primary.opacity(hovering ? 0.10 : 0.06)) + .frame(width: 84, height: 84) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .strokeBorder( + hovering ? Tint.blue.opacity(0.6) : Color.primary.opacity(0.10), + style: StrokeStyle(lineWidth: hovering ? 1.5 : 0.5, + dash: hovering ? [] : [3, 3]) + ) + ) + + appIcon + .frame(width: 56, height: 56) + .scaleEffect(hovering ? 1.04 : 1.0) + .shadow(color: .black.opacity(hovering ? 0.18 : 0.08), + radius: hovering ? 8 : 4, y: hovering ? 3 : 1) + } + .animation(.easeOut(duration: 0.18), value: hovering) + + VStack(spacing: 1) { + Text("PureMac.app") + .font(.system(size: 11.5, weight: .semibold)) + Text("Drag to the Settings list") + .font(.system(size: 10.5)) + .foregroundStyle(.secondary) + } + } + .padding(12) + .onHover { hovering = $0 } + .help("Drag this icon into the Full Disk Access list in System Settings.") + // NSItemProvider(object: NSURL) is exactly what Finder emits when + // you drag an .app — it exposes both the file representation and + // the URL string under public.file-url, which is the shape System + // Settings' Privacy panes are built to receive. `contentsOf:` works + // too but registers as a generic file copy operation; Settings' + // FDA list reads the URL representation directly. + .onDrag { + let provider = NSItemProvider(object: bundleURL as NSURL) + provider.suggestedName = bundleURL.lastPathComponent + return provider + } + } + + @ViewBuilder + private var appIcon: some View { + if let nsImage = NSWorkspace.shared.icon(forFile: bundleURL.path) as NSImage? { + Image(nsImage: nsImage) + .resizable() + .interpolation(.high) + } else { + Image(systemName: "app.fill") + .font(.system(size: 36)) + .foregroundStyle(Tint.blue) + } + } +} diff --git a/PureMac/Views/Components/AppTheme.swift b/PureMac/Views/Components/AppTheme.swift new file mode 100644 index 0000000..cd1ba0a --- /dev/null +++ b/PureMac/Views/Components/AppTheme.swift @@ -0,0 +1,331 @@ +import SwiftUI + +/// User-overridable appearance setting that lives independently of the system +/// preference, mirroring the prototype's titlebar light/dark toggle. +enum AppearanceMode: String, CaseIterable, Identifiable { + case system, light, dark + var id: String { rawValue } + + var label: String { + switch self { + case .system: return "System" + case .light: return "Light" + case .dark: return "Dark" + } + } + + var icon: String { + switch self { + case .system: return "circle.lefthalf.filled" + case .light: return "sun.max.fill" + case .dark: return "moon.fill" + } + } + + var colorScheme: ColorScheme? { + switch self { + case .system: return nil + case .light: return .light + case .dark: return .dark + } + } +} + +@MainActor +final class ThemeManager: ObservableObject { + static let shared = ThemeManager() + + @AppStorage("PureMac.Appearance") private var rawValue: String = AppearanceMode.system.rawValue + + var appearance: AppearanceMode { + get { AppearanceMode(rawValue: rawValue) ?? .system } + set { rawValue = newValue.rawValue; objectWillChange.send() } + } +} + +/// Centralized accent palette. One blue, one green for success, one orange +/// for warning, one red for destructive. Other tints exist for categorical +/// differentiation but the surface chrome only uses these four. +enum Tint { + static let blue = Color(red: 0.04, green: 0.52, blue: 1.00) + static let green = Color(red: 0.18, green: 0.78, blue: 0.47) + static let orange = Color(red: 1.00, green: 0.58, blue: 0.04) + static let purple = Color(red: 0.55, green: 0.32, blue: 0.87) + static let pink = Color(red: 1.00, green: 0.30, blue: 0.50) + static let cyan = Color(red: 0.30, green: 0.78, blue: 0.95) + static let red = Color(red: 1.00, green: 0.27, blue: 0.23) + static let yellow = Color(red: 1.00, green: 0.78, blue: 0.04) +} + +/// Shared animation vocabulary so every surface moves with the same feel. +/// Hover/selection feedback uses `snappy`, entrances and state swaps use +/// `gentle`, press acknowledgment uses `press`. +enum MotionTokens { + static let snappy = Animation.spring(response: 0.3, dampingFraction: 0.7) + static let gentle = Animation.spring(response: 0.5, dampingFraction: 0.85) + static let press = Animation.easeOut(duration: 0.12) +} + +/// Gradient pairs built from the flat `Tint` palette. Reserved for primary +/// CTAs and focal chrome — secondary surfaces stay flat. +enum TintGradient { + static let accent = LinearGradient( + colors: [Tint.blue, Tint.purple], + startPoint: .topLeading, endPoint: .bottomTrailing + ) + static let destructive = LinearGradient( + colors: [Tint.red, Tint.red.opacity(0.8)], + startPoint: .top, endPoint: .bottom + ) + static func of(_ color: Color) -> LinearGradient { + LinearGradient(colors: [color, color.opacity(0.65)], startPoint: .top, endPoint: .bottom) + } +} + +/// Tinted square icon container used in the sidebar and on dashboard cards. +/// Single muted fill, thin border. When `glow` is set (selected sidebar row, +/// emphasized card) the tile picks up a gradient fill and a soft tinted halo. +struct IconTile: View { + let systemName: String + var tint: Color = Tint.blue + var size: CGFloat = 26 + var corner: CGFloat = 7 + var glow: Bool = false + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: corner, style: .continuous) + .fill( + LinearGradient( + colors: [tint.opacity(glow ? 0.30 : 0.14), tint.opacity(0.14)], + startPoint: .topLeading, endPoint: .bottomTrailing + ) + ) + .shadow(color: tint.opacity(glow ? 0.45 : 0), radius: glow ? 5 : 0) + Image(systemName: systemName) + .font(.system(size: size * 0.52, weight: .semibold)) + .foregroundStyle(tint) + .shadow(color: tint.opacity(glow ? 0.5 : 0), radius: glow ? 3 : 0) + } + .frame(width: size, height: size) + .animation(reduceMotion ? nil : MotionTokens.snappy, value: glow) + } +} + +/// Card surface. Flat fill, hairline border, single soft shadow. No accent +/// stripe by default — content hierarchy carries the meaning, not chrome. +/// Pass `material` for a vibrancy/glass panel (used on focal hero states +/// where a tinted backdrop sits behind the card). +struct CardSurface: View { + var padding: CGFloat = 16 + /// Retained for callsite compatibility; the accent line is intentionally + /// not rendered in the restrained design. + var accent: Color? = nil + var elevation: CardElevation = .standard + var material: Material? = nil + @ViewBuilder var content: Content + + var body: some View { + content + .padding(padding) + .background( + ZStack { + if let material { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(material) + } else { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color(nsColor: .controlBackgroundColor)) + } + } + ) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder( + material != nil ? Color.white.opacity(0.14) : Color.primary.opacity(0.07), + lineWidth: material != nil ? 1 : 0.5 + ) + ) + .shadow(color: .black.opacity(elevation.ambient), radius: elevation.ambientRadius, y: elevation.ambientY) + } +} + +enum CardElevation { + case flat, standard, raised + + var ambient: Double { + switch self { + case .flat: return 0.0 + case .standard: return 0.04 + case .raised: return 0.07 + } + } + + var ambientRadius: CGFloat { + switch self { + case .flat: return 0 + case .standard: return 4 + case .raised: return 10 + } + } + + var ambientY: CGFloat { + switch self { + case .flat: return 0 + case .standard: return 1 + case .raised: return 3 + } + } +} + +/// Small status pill. Solid tint background at low opacity, no gradient. +struct StatusChip: View { + let label: String + var systemImage: String? = nil + var tint: Color = Tint.blue + + var body: some View { + HStack(spacing: 4) { + if let systemImage { + Image(systemName: systemImage) + .font(.system(size: 9, weight: .bold)) + } + Text(label) + .font(.system(size: 11, weight: .semibold)) + .monospacedDigit() + } + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(Capsule().fill(tint.opacity(0.14))) + .foregroundStyle(tint) + } +} + +/// Hover/press feedback for tappable cards. Plain mode is a subtle scale; +/// `lift` mode adds the CleanMyMac-style float (rise + soft shadow). Under +/// Reduce Motion the scale/offset are dropped and only the shadow remains. +struct PressableScale: ViewModifier { + @State private var hovering = false + @State private var pressing = false + var hoverScale: CGFloat = 1.006 + var lift: Bool = false + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + func body(content: Content) -> some View { + content + .scaleEffect(reduceMotion ? 1.0 : (pressing ? 0.97 : (hovering ? (lift ? 1.02 : hoverScale) : 1.0))) + .offset(y: lift && hovering && !reduceMotion ? -2 : 0) + .shadow(color: .black.opacity(lift && hovering ? 0.12 : 0), + radius: lift && hovering ? 14 : 0, + y: lift && hovering ? 6 : 0) + .animation(reduceMotion ? nil : MotionTokens.snappy, value: hovering) + .animation(reduceMotion ? nil : MotionTokens.press, value: pressing) + .onHover { hovering = $0 } + .simultaneousGesture( + DragGesture(minimumDistance: 0) + .onChanged { _ in pressing = true } + .onEnded { _ in pressing = false } + ) + } +} + +extension View { + func pressable(hoverScale: CGFloat = 1.006, lift: Bool = false) -> some View { + modifier(PressableScale(hoverScale: hoverScale, lift: lift)) + } +} + +/// Gradient capsule CTA with a soft tinted glow — the primary-action style. +/// Hover lifts the glow and scale; press squeezes. `breathes` adds a gentle +/// idle glow pulse (radius only, no scale) for the single hero CTA on an +/// otherwise calm screen. All motion is suppressed under Reduce Motion. +struct GlowProminentButtonStyle: ButtonStyle { + var tint: Color = Tint.blue + var gradient: LinearGradient = TintGradient.accent + var breathes: Bool = false + + func makeBody(configuration: Configuration) -> some View { + GlowBody(configuration: configuration, tint: tint, gradient: gradient, breathes: breathes) + } + + private struct GlowBody: View { + let configuration: ButtonStyleConfiguration + let tint: Color + let gradient: LinearGradient + let breathes: Bool + + @State private var hovering = false + @State private var breathe = false + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + configuration.label + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.white) + .padding(.horizontal, 18) + .padding(.vertical, 9) + .background(Capsule().fill(gradient)) + .overlay(Capsule().strokeBorder(.white.opacity(0.18), lineWidth: 0.5)) + .shadow(color: tint.opacity(hovering ? 0.45 : (breathe ? 0.40 : 0.22)), + radius: hovering ? 14 : (breathe ? 12 : 7), y: 3) + .scaleEffect(reduceMotion ? 1 : (configuration.isPressed ? 0.97 : (hovering ? 1.03 : 1))) + .animation(reduceMotion ? nil : MotionTokens.snappy, value: hovering) + .animation(reduceMotion ? nil : MotionTokens.press, value: configuration.isPressed) + .onHover { hovering = $0 } + .onAppear { + guard breathes, !reduceMotion else { return } + withAnimation(.easeInOut(duration: 1.2).repeatForever(autoreverses: true)) { + breathe = true + } + } + } + } +} + +/// Checkbox replacement with a springy check pop. Visually matches the size +/// of the native control so adopting it doesn't shift row layout. Keeps the +/// native checkbox's accessibility semantics (role, checked value, Space-key +/// toggling, VoiceOver) via accessibilityRepresentation so swapping it in +/// doesn't regress keyboard/screen-reader users. +struct AnimatedCheckboxStyle: ToggleStyle { + var tint: Color = Tint.blue + + func makeBody(configuration: Configuration) -> some View { + CheckBody(configuration: configuration, tint: tint) + .accessibilityRepresentation { + Toggle(isOn: configuration.$isOn) { configuration.label } + .toggleStyle(.checkbox) + } + } + + private struct CheckBody: View { + let configuration: ToggleStyleConfiguration + let tint: Color + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + HStack(spacing: 8) { + ZStack { + RoundedRectangle(cornerRadius: 4.5, style: .continuous) + .fill(configuration.isOn ? tint : Color.primary.opacity(0.05)) + RoundedRectangle(cornerRadius: 4.5, style: .continuous) + .strokeBorder(configuration.isOn ? tint : Color.primary.opacity(0.25), lineWidth: 1) + Image(systemName: "checkmark") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(.white) + .scaleEffect(configuration.isOn ? 1 : 0.3) + .opacity(configuration.isOn ? 1 : 0) + } + .frame(width: 15, height: 15) + .animation(reduceMotion ? nil : .spring(response: 0.25, dampingFraction: 0.6), value: configuration.isOn) + + configuration.label + } + .contentShape(Rectangle()) + .onTapGesture { configuration.isOn.toggle() } + } + } +} diff --git a/PureMac/Views/Components/AppearancePill.swift b/PureMac/Views/Components/AppearancePill.swift new file mode 100644 index 0000000..907462e --- /dev/null +++ b/PureMac/Views/Components/AppearancePill.swift @@ -0,0 +1,39 @@ +import SwiftUI + +/// Inline 3-segment toggle (system / light / dark) with an animated active +/// indicator that slides between segments. Replaces the SwiftUI `Menu` which +/// looked like a generic dropdown affordance. +struct AppearancePill: View { + @Binding var selection: AppearanceMode + @Namespace private var indicator + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + HStack(spacing: 2) { + ForEach(AppearanceMode.allCases) { mode in + Button { + withAnimation(reduceMotion ? nil : .spring(response: 0.32, dampingFraction: 0.78)) { + selection = mode + } + } label: { + Image(systemName: mode.icon) + .font(.system(size: 12, weight: .semibold)) + .frame(width: 28, height: 22) + .foregroundStyle(selection == mode ? Color.primary : .secondary) + .background( + ZStack { + if selection == mode { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color.primary.opacity(0.10)) + .matchedGeometryEffect(id: "indicator", in: indicator) + } + } + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help(LocalizedStringKey(mode.label)) + } + } + } +} diff --git a/PureMac/Views/Components/ConfettiView.swift b/PureMac/Views/Components/ConfettiView.swift new file mode 100644 index 0000000..5e9b90d --- /dev/null +++ b/PureMac/Views/Components/ConfettiView.swift @@ -0,0 +1,199 @@ +import SwiftUI + +/// Tasteful one-shot confetti, redesigned away from the old harsh CAEmitter +/// rectangles. A `Canvas` + `TimelineView` particle system: mixed shapes +/// (streamers, discs, rings), a soft brand palette, gentle gravity with a +/// little horizontal sway, a 3D-style tumble, and a graceful fade. Fire with +/// `ConfettiView(trigger:)` — each rising edge fires one burst that quiets +/// itself. Honors Reduce Motion (renders nothing). +struct ConfettiView: View { + enum Mode { + /// Pieces spawn above the frame and flutter down (ambient rain). + case rain + /// Pieces explode radially from a point (anchored celebration — + /// pair the origin with the visual element being celebrated). + case burst(origin: UnitPoint) + } + + /// Flip to fire a burst. Each rising edge fires once. + let trigger: Bool + var mode: Mode = .rain + + @State private var particles: [Particle] = [] + @State private var start: Date? + @State private var generation = 0 + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + private let duration: TimeInterval = 2.6 + + var body: some View { + GeometryReader { geo in + TimelineView(.animation(paused: start == nil)) { timeline in + Canvas { ctx, size in + guard let start else { return } + let t = timeline.date.timeIntervalSince(start) + guard t <= duration else { return } + for p in particles { + draw(p, at: t, in: size, ctx: &ctx) + } + } + } + // The parent flips `trigger` (via .toggle()) as a fire pulse, so a + // burst should play on ANY change — not just the rising edge, which + // would skip every other cleanup. + .onChange(of: trigger) { _ in + fire(in: geo.size) + } + .allowsHitTesting(false) + } + } + + private func fire(in size: CGSize) { + guard !reduceMotion, size.width > 0 else { return } + switch mode { + case .rain: + particles = (0..<90).map { _ in Particle.random(width: size.width) } + case .burst(let origin): + let point = CGPoint(x: size.width * origin.x, y: size.height * origin.y) + particles = (0..<90).map { _ in Particle.burst(from: point) } + } + start = Date() + generation += 1 + let g = generation + // Quiet the timeline once the burst has fully faded so we don't keep + // re-rendering an empty canvas. Guard on `generation` so a later burst + // isn't cut short by an earlier burst's cleanup timer. + DispatchQueue.main.asyncAfter(deadline: .now() + duration + 0.1) { + guard generation == g else { return } + start = nil + particles = [] + } + } + + private func draw(_ p: Particle, at t: Double, in size: CGSize, ctx: inout GraphicsContext) { + // Vertical: initial downward velocity + gravity. Horizontal: drift + + // a sine sway so pieces flutter instead of dropping like stones. + let x = p.x0 + p.drift * t + p.swayAmp * sin(p.swayFreq * t + p.phase) + let y = p.y0 + p.vy0 * t + 0.5 * p.gravity * t * t + + guard y < size.height + 40 else { return } + + let fade: Double + if t > p.fadeStart { + fade = max(0, 1 - (t - p.fadeStart) / (duration - p.fadeStart)) + } else { + fade = 1 + } + guard fade > 0.01 else { return } + + let angle = p.spin * t + // Tumble: squash one axis on a cosine so pieces appear to flip in 3D. + let tumble = abs(cos(p.tumbleSpeed * t + p.phase)) + let w = p.size.width * (0.25 + 0.75 * tumble) + let h = p.size.height + + var sub = ctx + sub.translateBy(x: x, y: y) + sub.rotate(by: .radians(angle)) + sub.opacity = fade + + let rect = CGRect(x: -w / 2, y: -h / 2, width: w, height: h) + switch p.shape { + case .streamer: + sub.fill(Path(roundedRect: rect, cornerRadius: min(w, h) * 0.45), with: .color(p.color)) + case .disc: + sub.fill(Path(ellipseIn: CGRect(x: -h / 2, y: -h / 2, width: h, height: h)), with: .color(p.color)) + case .ring: + let r = h / 2 + let ring = Path(ellipseIn: CGRect(x: -r, y: -r, width: h, height: h)) + sub.stroke(ring, with: .color(p.color), lineWidth: max(1.4, h * 0.22)) + } + } +} + +private enum ConfettiShape: CaseIterable { case streamer, disc, ring } + +private struct Particle { + var x0: CGFloat + var y0: CGFloat + var vy0: Double + var gravity: Double + var drift: Double + var swayAmp: Double + var swayFreq: Double + var phase: Double + var spin: Double + var tumbleSpeed: Double + var fadeStart: Double + var color: Color + var size: CGSize + var shape: ConfettiShape + + /// Soft, slightly desaturated brand palette — premium, not garish. + static let palette: [Color] = [ + Color(red: 0.27, green: 0.56, blue: 1.00), // blue + Color(red: 0.30, green: 0.80, blue: 0.56), // green + Color(red: 1.00, green: 0.66, blue: 0.30), // amber + Color(red: 0.62, green: 0.45, blue: 0.92), // purple + Color(red: 1.00, green: 0.46, blue: 0.62), // pink + Color(red: 0.36, green: 0.80, blue: 0.94), // cyan + ] + + /// Radial explosion from a fixed point. Same physics as the rain mode — + /// the polar launch velocity plus the existing gravity term naturally + /// produces the arc — with an upward bias so the burst blooms before it + /// falls, and an earlier fade so pieces don't litter the screen. + static func burst(from point: CGPoint) -> Particle { + let shape = ConfettiShape.allCases.randomElement()! + let dim: CGSize + switch shape { + case .streamer: dim = CGSize(width: .random(in: 5...8), height: .random(in: 11...16)) + case .disc: dim = CGSize(width: 9, height: .random(in: 7...10)) + case .ring: dim = CGSize(width: 10, height: .random(in: 9...13)) + } + let angle = Double.random(in: 0...(2 * .pi)) + let speed = Double.random(in: 160...340) + return Particle( + x0: point.x, + y0: point.y, + vy0: sin(angle) * speed - .random(in: 140...220), + gravity: .random(in: 240...340), + drift: cos(angle) * speed, + swayAmp: .random(in: 4...14), + swayFreq: .random(in: 1.4...3.0), + phase: .random(in: 0...(2 * .pi)), + spin: .random(in: -6...6), + tumbleSpeed: .random(in: 2.5...5.5), + fadeStart: .random(in: 1.0...1.5), + color: palette.randomElement()!, + size: dim, + shape: shape + ) + } + + static func random(width: CGFloat) -> Particle { + let shape = ConfettiShape.allCases.randomElement()! + let dim: CGSize + switch shape { + case .streamer: dim = CGSize(width: .random(in: 5...8), height: .random(in: 11...16)) + case .disc: dim = CGSize(width: 9, height: .random(in: 7...10)) + case .ring: dim = CGSize(width: 10, height: .random(in: 9...13)) + } + return Particle( + x0: .random(in: 0...width), + y0: .random(in: -60 ... -10), + vy0: .random(in: 90...170), + gravity: .random(in: 130...210), + drift: .random(in: -40...40), + swayAmp: .random(in: 8...26), + swayFreq: .random(in: 1.4...3.0), + phase: .random(in: 0...(2 * .pi)), + spin: .random(in: -5...5), + tumbleSpeed: .random(in: 2.5...5.5), + fadeStart: .random(in: 1.3...1.9), + color: palette.randomElement()!, + size: dim, + shape: shape + ) + } +} diff --git a/PureMac/Views/Components/DashboardCharts.swift b/PureMac/Views/Components/DashboardCharts.swift new file mode 100644 index 0000000..c698c13 --- /dev/null +++ b/PureMac/Views/Components/DashboardCharts.swift @@ -0,0 +1,446 @@ +import SwiftUI +import Charts + +// MARK: - Health Ring +// +// Premium animated radial gauge that replaces the flat StorageGauge. An +// angular-gradient sweep with a soft tinted glow, a rounded cap, and a +// spring-driven fill. The center number rolls via numericText. Honors +// Reduce Motion (no spring, no idle shimmer). + +struct HealthRing: View { + /// Primary fill, 0...1 (e.g. fraction of disk used). + let percent: Double + var tint: Color = Tint.blue + var warnTint: Color = Tint.orange + var stressThreshold: Double = 0.85 + var lineWidth: CGFloat = 14 + var subtitle: LocalizedStringKey = "USED" + + @State private var sweep: Double = 0 + @State private var displayPercent: Int = 0 + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + private var clamped: Double { max(0, min(1, percent)) } + private var stress: Bool { clamped >= stressThreshold } + private var arcColor: Color { stress ? warnTint : tint } + private var trailColor: Color { stress ? Tint.red : Tint.purple } + + var body: some View { + ZStack { + Circle() + .stroke(Color.primary.opacity(0.06), lineWidth: lineWidth) + + // Soft glow layer underneath the crisp arc. + Circle() + .trim(from: 0, to: sweep) + .stroke(arcColor.opacity(0.5), + style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .blur(radius: 9) + + Circle() + .trim(from: 0, to: sweep) + .stroke( + AngularGradient( + gradient: Gradient(colors: [arcColor.opacity(0.85), trailColor, arcColor]), + center: .center, + startAngle: .degrees(-90), + endAngle: .degrees(-90 + 360 * max(sweep, 0.0001)) + ), + style: StrokeStyle(lineWidth: lineWidth, lineCap: .round) + ) + .rotationEffect(.degrees(-90)) + + // Endpoint highlight dot for a polished finish. The dot sits at the + // top of the ring and the whole layer rotates about the ring + // center so it orbits to the arc's end. Radius is derived from the + // real frame so it tracks the stroke at any size. + if sweep > 0.02 { + GeometryReader { geo in + let radius = (min(geo.size.width, geo.size.height) - lineWidth) / 2 + Circle() + .fill(.white) + .frame(width: lineWidth * 0.42, height: lineWidth * 0.42) + .position(x: geo.size.width / 2, y: geo.size.height / 2 - radius) + .shadow(color: arcColor.opacity(0.7), radius: 4) + } + .rotationEffect(.degrees(360 * sweep)) + } + + VStack(spacing: 1) { + Text("\(displayPercent)%") + .font(.system(size: 44, weight: .semibold)) + .monospacedDigit() + .contentTransition(.numericText()) + .foregroundStyle(stress ? warnTint : Color.primary) + Text(subtitle) + .font(.system(size: 10, weight: .semibold)) + .tracking(0.8) + .foregroundStyle(.secondary) + } + } + .onAppear { animate(to: clamped) } + .onChange(of: clamped) { animate(to: $0) } + } + + private func animate(to value: Double) { + if reduceMotion { + sweep = value + displayPercent = Int(round(value * 100)) + return + } + withAnimation(.spring(response: 1.0, dampingFraction: 0.82)) { + sweep = value + } + withAnimation(.easeOut(duration: 0.9)) { + displayPercent = Int(round(value * 100)) + } + } +} + +// MARK: - Storage Donut +// +// Custom multi-segment donut (Used / Junk / Purgeable / Free) drawn from +// trimmed arcs so it works on macOS 13 (SectorMark is 14+). Segments grow in +// with a spring on appear and animate when values change. + +struct StorageDonut: View { + struct Segment: Identifiable { + /// Stable id (e.g. "used"/"junk") so legend rows keep identity across + /// the many @Published refreshes that rebuild this array — otherwise a + /// fresh UUID per render replays the staggered entrance animation. + let id: String + let value: Double // raw byte fraction; normalized internally + let color: Color + let label: LocalizedStringKey + let display: String + } + + let segments: [Segment] + var lineWidth: CGFloat = 16 + var gap: Double = 0.006 // angular gap between segments (fraction of circle) + /// When set (legend hover), every other segment dims for cross-highlight. + var highlightedID: String? = nil + + @State private var reveal: CGFloat = 0 + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + private var total: Double { max(segments.reduce(0) { $0 + $1.value }, 0.0001) } + + var body: some View { + // A single segment is a full ring — no inter-segment hairline, so the + // gap would otherwise carve a false missing slice. + let effectiveGap = segments.count <= 1 ? 0 : gap + let fractions = segments.map { $0.value / total } + var cursor = 0.0 + var ranges: [(start: Double, end: Double)] = [] + for frac in fractions { + let start = cursor + cursor += frac + // Trim back the trailing edge by `gap` for a hairline between + // segments, clamped so a tiny slice never inverts. + ranges.append((start, max(start, cursor - effectiveGap))) + } + + return ZStack { + Circle().stroke(Color.primary.opacity(0.05), lineWidth: lineWidth) + ForEach(Array(segments.enumerated()), id: \.element.id) { idx, seg in + Circle() + .trim(from: ranges[idx].start * reveal, to: ranges[idx].end * reveal) + .stroke(seg.color, style: StrokeStyle(lineWidth: lineWidth, lineCap: .butt)) + .rotationEffect(.degrees(-90)) + .opacity(highlightedID == nil || highlightedID == seg.id ? 1 : 0.25) + } + } + .animation(reduceMotion ? nil : .easeOut(duration: 0.2), value: highlightedID) + .onAppear { + if reduceMotion { reveal = 1; return } + withAnimation(.spring(response: 1.0, dampingFraction: 0.85)) { reveal = 1 } + } + } +} + +// MARK: - Category Bar Chart +// +// Horizontal Swift Charts bar chart of junk size per category. BarMark is +// available on macOS 13. Used in the completed-scan state to give an +// at-a-glance "where the space is" graphic above the toggle list. + +struct CategoryBarChart: View { + struct Bar: Identifiable { + // Category is unique per bar, so derive a stable id from it. + var id: String { category.rawValue } + let category: CleaningCategory + let size: Int64 + var name: String { String(localized: String.LocalizationValue(category.rawValue)) } + } + + let bars: [Bar] + + @State private var reveal: Double = 0 + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + // Reserve ~28% trailing headroom so the byte-size annotation on the + // longest bar renders fully instead of clipping at the chart edge + // (the macOS 14+ annotation overflowResolution isn't available here). + let maxSize = bars.map(\.size).max() ?? 0 + let upper = max(Int64(1), Int64(Double(maxSize) * 1.28)) + + return Chart(bars) { bar in + BarMark( + x: .value("Size", Double(bar.size) * reveal), + y: .value("Category", bar.name) + ) + .foregroundStyle(bar.category.color.gradient) + .cornerRadius(5) + .annotation(position: .trailing, alignment: .leading) { + Text(ByteCountFormatter.string(fromByteCount: bar.size, countStyle: .file)) + .font(.system(size: 10, weight: .semibold)) + .monospacedDigit() + .foregroundStyle(.secondary) + .opacity(reveal) + } + } + .onAppear { + if reduceMotion { reveal = 1; return } + withAnimation(.spring(response: 0.8, dampingFraction: 0.85)) { reveal = 1 } + } + .chartXScale(domain: 0...upper) + .chartXAxis(.hidden) + .chartYAxis { + AxisMarks(preset: .aligned, position: .leading) { _ in + AxisValueLabel() + } + } + .chartLegend(.hidden) + .frame(height: max(120, CGFloat(bars.count) * 34)) + } +} + +// MARK: - Legend chip + +struct LegendChip: View { + let color: Color + let label: LocalizedStringKey + let value: String + + var body: some View { + HStack(spacing: 7) { + RoundedRectangle(cornerRadius: 3, style: .continuous) + .fill(color) + .frame(width: 10, height: 10) + VStack(alignment: .leading, spacing: 0) { + Text(label) + .font(.system(size: 10.5)) + .foregroundStyle(.secondary) + Text(value) + .font(.system(size: 12, weight: .semibold)) + .monospacedDigit() + } + } + } +} + +// MARK: - Success medal +// +// Center checkmark with two concentric rings that expand and fade once on +// appear — a calm, premium "done" beat to pair with the freed-space number. + +struct SuccessMedal: View { + var tint: Color = Tint.green + var size: CGFloat = 120 + + @State private var pop = false + @State private var ripple = false + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + ZStack { + ForEach(0..<2) { i in + Circle() + .stroke(tint.opacity(ripple ? 0 : 0.45), lineWidth: 2) + .scaleEffect(ripple ? 1.5 + CGFloat(i) * 0.25 : 0.7) + } + Circle() + .fill(tint.opacity(0.14)) + .scaleEffect(pop ? 1 : 0.6) + + Image(systemName: "checkmark") + .font(.system(size: size * 0.42, weight: .bold)) + .foregroundStyle(tint) + .scaleEffect(pop ? 1 : 0.3) + .opacity(pop ? 1 : 0) + } + .frame(width: size, height: size) + .onAppear { + if reduceMotion { pop = true; return } + withAnimation(.spring(response: 0.5, dampingFraction: 0.6)) { pop = true } + withAnimation(.easeOut(duration: 1.1)) { ripple = true } + } + } +} + +// MARK: - Staggered reveal +// +// Drop-in entrance animation: fades + lifts content with a per-index delay so +// grids and lists cascade in. Honors Reduce Motion. + +struct StaggeredReveal: ViewModifier { + let index: Int + var baseDelay: Double = 0.04 + @State private var shown = false + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + func body(content: Content) -> some View { + content + .opacity(shown ? 1 : 0) + .offset(y: shown ? 0 : 10) + .onAppear { + if reduceMotion { shown = true; return } + withAnimation(.spring(response: 0.5, dampingFraction: 0.85) + .delay(Double(index) * baseDelay)) { + shown = true + } + } + } +} + +extension View { + func staggered(_ index: Int, baseDelay: Double = 0.04) -> some View { + modifier(StaggeredReveal(index: index, baseDelay: baseDelay)) + } +} + +// MARK: - Count-up bytes +// +// Animatable byte counter. `.contentTransition(.numericText())` only rolls +// when the change happens inside an animation transaction — values assigned +// during a plain data refresh snap instead. Driving the formatter through +// `animatableData` makes the roll-up play every time the target changes. +// Styling (font/color) flows in from the environment of the call site. + +struct CountUpBytes: View { + let bytes: Int64 + + @State private var shown: Double = 0 + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + Color.clear + .frame(width: 0, height: 0) + .modifier(ByteRollEffect(value: shown)) + .onAppear { animate(to: bytes) } + .onChange(of: bytes) { animate(to: $0) } + } + + private func animate(to target: Int64) { + if reduceMotion { + shown = Double(target) + return + } + withAnimation(.easeOut(duration: 0.8)) { + shown = Double(target) + } + } +} + +private struct ByteRollEffect: AnimatableModifier { + var value: Double + + var animatableData: Double { + get { value } + set { value = newValue } + } + + func body(content: Content) -> some View { + Text(ByteCountFormatter.string(fromByteCount: Int64(max(0, value)), countStyle: .file)) + .monospacedDigit() + } +} + +// MARK: - Scanning gauge +// +// Active-scan focal element: a rotating trimmed arc over a radar wedge sweep +// with two expanding halo rings. Reduce Motion freezes every loop and falls +// back to the static arc + rolling percent. + +struct ScanningGauge: View { + let progress: Double + var tint: Color = Tint.blue + var label: LocalizedStringKey = "SCANNING" + + @State private var rotate = false + @State private var pulse = false + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + ZStack { + // Expanding halo rings, SuccessMedal-style but repeating. + if !reduceMotion { + ForEach(0..<2, id: \.self) { i in + Circle() + .stroke(tint.opacity(0.35), lineWidth: 1.5) + .scaleEffect(pulse ? 1.22 : 0.9) + .opacity(pulse ? 0 : 0.6) + .animation( + .easeOut(duration: 1.6) + .repeatForever(autoreverses: false) + .delay(Double(i) * 0.8), + value: pulse + ) + } + } + + // Radar wedge sweeping the ring interior. TimelineView keeps the + // angle deterministic; the branch above removes it entirely under + // Reduce Motion. + if !reduceMotion { + TimelineView(.animation) { timeline in + let t = timeline.date.timeIntervalSinceReferenceDate + let angle = (t.truncatingRemainder(dividingBy: 3.0) / 3.0) * 360.0 + Circle() + .fill( + AngularGradient( + colors: [tint.opacity(0), tint.opacity(0), tint.opacity(0.22)], + center: .center + ) + ) + .rotationEffect(.degrees(angle)) + } + .padding(14) + .clipShape(Circle()) + } + + Circle() + .stroke(Color.primary.opacity(0.07), lineWidth: 10) + + Circle() + .trim(from: 0, to: CGFloat(max(0.05, min(0.95, progress)))) + .stroke(tint, style: StrokeStyle(lineWidth: 10, lineCap: .round)) + .rotationEffect(.degrees(rotate ? 360 : 0)) + .animation( + reduceMotion ? nil : .linear(duration: 4).repeatForever(autoreverses: false), + value: rotate + ) + + VStack(spacing: 2) { + Text("\(Int(progress * 100))%") + .font(.system(size: 36, weight: .semibold)) + .monospacedDigit() + .contentTransition(reduceMotion ? .identity : .numericText()) + .animation(reduceMotion ? nil : .easeOut(duration: 0.3), value: Int(progress * 100)) + Text(label) + .font(.system(size: 10, weight: .medium)) + .tracking(0.6) + .foregroundStyle(.secondary) + } + } + .onAppear { + guard !reduceMotion else { return } + rotate = true + pulse = true + } + } +} diff --git a/PureMac/Views/Components/EmptyStateView.swift b/PureMac/Views/Components/EmptyStateView.swift new file mode 100644 index 0000000..90c5265 --- /dev/null +++ b/PureMac/Views/Components/EmptyStateView.swift @@ -0,0 +1,71 @@ +import SwiftUI + +struct EmptyStateView: View { + let title: LocalizedStringKey + let systemImage: String + let description: LocalizedStringKey + var action: (() -> Void)? + var actionLabel: LocalizedStringKey? + /// Halo/icon tint — positive states pass a color (e.g. green for "All + /// Clean"); neutral states keep the secondary look. + var tint: Color? + + @State private var floating = false + @State private var popped = false + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + init(_ title: LocalizedStringKey, systemImage: String, description: LocalizedStringKey, + action: (() -> Void)? = nil, actionLabel: LocalizedStringKey? = nil, tint: Color? = nil) { + self.title = title + self.systemImage = systemImage + self.description = description + self.action = action + self.actionLabel = actionLabel + self.tint = tint + } + + var body: some View { + VStack(spacing: 12) { + Spacer() + ZStack { + Circle() + .fill((tint ?? Color.secondary).opacity(0.10)) + .frame(width: 96, height: 96) + Image(systemName: systemImage) + .font(.system(size: 40)) + .foregroundStyle(tint ?? Color.secondary) + } + // One-shot entrance pop, then a gentle idle float. Both skipped + // under Reduce Motion. + .scaleEffect(popped || reduceMotion ? 1 : 0.8) + .offset(y: reduceMotion ? 0 : (floating ? -5 : 5)) + .onAppear { + guard !reduceMotion else { return } + withAnimation(.spring(response: 0.4, dampingFraction: 0.6)) { + popped = true + } + withAnimation(.easeInOut(duration: 2.4).repeatForever(autoreverses: true)) { + floating = true + } + } + + Text(title) + .font(.title3.bold()) + .staggered(0, baseDelay: 0.08) + Text(description) + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 300) + .staggered(1, baseDelay: 0.08) + if let action, let label = actionLabel { + Button(action: action) { Text(label) } + .buttonStyle(.borderedProminent) + .padding(.top, 4) + .staggered(2, baseDelay: 0.08) + } + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/PureMac/Views/Components/FDADemoView.swift b/PureMac/Views/Components/FDADemoView.swift new file mode 100644 index 0000000..52d8a9d --- /dev/null +++ b/PureMac/Views/Components/FDADemoView.swift @@ -0,0 +1,197 @@ +import SwiftUI + +/// A short looping illustration of what the user is about to do in the real +/// System Settings pane. Built entirely in SwiftUI — no GIFs, no video, no +/// external assets. The point is to make the FDA step feel concrete so the +/// user knows exactly which toggle to flip before they even open Settings. +/// +/// Frames cycle every ~5 seconds: idle → row highlights → toggle flips → +/// green check appears → hold → reset. +struct FDADemoView: View { + enum Frame: Int, CaseIterable { + case idle + case highlight + case toggleOn + case granted + case hold + } + + @State private var frame: Frame = .idle + @State private var isActive = true + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + private var rows: [DemoRow] { + [ + DemoRow(name: "Finder", systemImage: "macwindow", granted: true), + DemoRow(name: "PureMac", systemImage: "sparkles", + granted: frame == .toggleOn || frame == .granted || frame == .hold, + isPureMac: true), + DemoRow(name: "Terminal", systemImage: "terminal", granted: true), + ] + } + + var body: some View { + VStack(spacing: 14) { + settingsCard + + HStack(spacing: 6) { + Image(systemName: "lock.shield") + .font(.system(size: 11, weight: .semibold)) + Text("Privacy & Security → Full Disk Access") + .font(.system(size: 11.5, weight: .medium)) + } + .foregroundStyle(.secondary) + } + .onAppear { + // Reduce Motion: freeze on the end state — the user sees the + // "toggled on, granted" frame as a static illustration instead + // of an infinite loop. + if reduceMotion { + frame = .hold + return + } + isActive = true + cycle() + } + .onDisappear { isActive = false } + } + + // MARK: - Settings card + + private var settingsCard: some View { + VStack(spacing: 0) { + // Faux titlebar + HStack(spacing: 6) { + Circle().fill(Color(red: 1.0, green: 0.36, blue: 0.34)).frame(width: 9, height: 9) + Circle().fill(Color(red: 1.0, green: 0.78, blue: 0.27)).frame(width: 9, height: 9) + Circle().fill(Color(red: 0.31, green: 0.78, blue: 0.36)).frame(width: 9, height: 9) + Spacer() + Text("Full Disk Access") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.secondary) + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color.primary.opacity(0.04)) + + Divider() + + VStack(spacing: 0) { + ForEach(Array(rows.enumerated()), id: \.offset) { idx, row in + rowView(row) + if idx != rows.count - 1 { + Divider().padding(.leading, 46) + } + } + } + } + .background(Color(nsColor: .controlBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder(Color.primary.opacity(0.10), lineWidth: 0.5) + ) + .shadow(color: .black.opacity(0.08), radius: 12, y: 4) + } + + private func rowView(_ row: DemoRow) -> some View { + let highlight = row.isPureMac && (frame == .highlight) + let succeeded = row.isPureMac && (frame == .granted || frame == .hold) + + return HStack(spacing: 12) { + ZStack { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(row.isPureMac ? Tint.blue.opacity(0.16) : Color.primary.opacity(0.08)) + Image(systemName: row.systemImage) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(row.isPureMac ? Tint.blue : .secondary) + } + .frame(width: 26, height: 26) + + Text(row.name) + .font(.system(size: 13, weight: row.isPureMac ? .semibold : .regular)) + + Spacer() + + if succeeded { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 14)) + .foregroundStyle(Tint.green) + .transition(.scale.combined(with: .opacity)) + } + + ToggleSwitch(isOn: row.granted) + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(highlight ? Tint.blue.opacity(0.10) : .clear) + .padding(.horizontal, 6) + .padding(.vertical, 2) + ) + .animation(.easeInOut(duration: 0.35), value: highlight) + .animation(.easeInOut(duration: 0.35), value: succeeded) + } + + // MARK: - Cycle + + private func cycle() { + guard isActive, !reduceMotion else { return } + + let schedule: [(Frame, Double)] = [ + (.idle, 0.6), + (.highlight, 0.9), + (.toggleOn, 0.8), + (.granted, 0.4), + (.hold, 1.6), + ] + + var accumulated: Double = 0 + for (target, hold) in schedule { + DispatchQueue.main.asyncAfter(deadline: .now() + accumulated) { + guard isActive else { return } + withAnimation(.easeInOut(duration: 0.4)) { + frame = target + } + } + accumulated += hold + } + // Re-arm. The isActive guard at the top of cycle() is what stops the + // chain when the view disappears. + DispatchQueue.main.asyncAfter(deadline: .now() + accumulated + 0.4) { + guard isActive else { return } + withAnimation(.easeInOut(duration: 0.3)) { + frame = .idle + } + cycle() + } + } + + private struct DemoRow { + let name: String + let systemImage: String + let granted: Bool + var isPureMac: Bool = false + } +} + +/// Tiny stand-in for an OS-style toggle. NSSwitch via SwiftUI would also work +/// but rendering one ourselves gives us full control over the animation. +private struct ToggleSwitch: View { + let isOn: Bool + + var body: some View { + ZStack(alignment: isOn ? .trailing : .leading) { + Capsule() + .fill(isOn ? Tint.green : Color.secondary.opacity(0.30)) + Circle() + .fill(Color.white) + .shadow(color: .black.opacity(0.15), radius: 1.5, y: 0.5) + .padding(2) + } + .frame(width: 32, height: 19) + .animation(.spring(response: 0.35, dampingFraction: 0.78), value: isOn) + } +} diff --git a/PureMac/Views/Components/MenuBarMonitorView.swift b/PureMac/Views/Components/MenuBarMonitorView.swift new file mode 100644 index 0000000..6dfea94 --- /dev/null +++ b/PureMac/Views/Components/MenuBarMonitorView.swift @@ -0,0 +1,129 @@ +import SwiftUI +import AppKit + +/// Zero-size helper that captures SwiftUI's `openWindow` action into +/// `WindowOpener.shared` when the main window appears, so the AppKit menu-bar +/// popover can reopen the window after it's been closed. +struct WindowOpenerCapture: View { + @Environment(\.openWindow) private var openWindow + + var body: some View { + Color.clear + .frame(width: 0, height: 0) + .onAppear { WindowOpener.shared.open = { id in openWindow(id: id) } } + } +} + +/// Drop-down panel hosted in the menu-bar `NSPopover` (via `NSHostingController`) +/// with live CPU / memory / disk meters and quick actions. Kept self-contained +/// so the menu bar surface stays decoupled from the main window's `AppState`. +struct MenuBarMonitorView: View { + @ObservedObject private var monitor = SystemMonitor.shared + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("System Monitor") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.primary) + + VStack(spacing: 10) { + MeterRow(title: "CPU", tint: Tint.blue, + fraction: monitor.cpuUsage, + detail: "\(Int((monitor.cpuUsage * 100).rounded()))%") + MeterRow(title: "Memory", tint: Tint.purple, + fraction: monitor.memoryFraction, + detail: byteDetail(monitor.memoryUsed, monitor.memoryTotal)) + MeterRow(title: "Disk", tint: Tint.green, + fraction: monitor.diskFraction, + detail: byteDetail(monitor.diskUsed, monitor.diskTotal)) + } + + Divider() + + HStack { + Button { + openMainWindow() + } label: { + Text("Open PureMac") + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + + Spacer() + + Button { + NSApp.terminate(nil) + } label: { + Text("Quit PureMac") + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + .padding(14) + .frame(width: 248) + .onAppear { monitor.start() } + .onDisappear { monitor.stop() } + } + + private func byteDetail(_ used: Int64, _ total: Int64) -> String { + let u = ByteCountFormatter.string(fromByteCount: used, countStyle: .memory) + let t = ByteCountFormatter.string(fromByteCount: total, countStyle: .memory) + return "\(u) / \(t)" + } + + /// Bring the app forward and surface the main window. The app stays alive + /// after its window closes only while the monitor is enabled (see + /// `AppDelegate.applicationShouldTerminateAfterLastWindowClosed`), so this + /// reopens a fresh window when none is left, otherwise just focuses it. + private func openMainWindow() { + NSApp.activate(ignoringOtherApps: true) + // Exclude the menu-bar popover's own panel; a real content window is + // titled and can become main. + if let existing = NSApp.windows.first(where: { + $0.canBecomeMain && $0.styleMask.contains(.titled) + }) { + existing.makeKeyAndOrderFront(nil) + } else { + // No content window left — reopen via the captured openWindow action + // (the popover has no working openWindow environment of its own). + WindowOpener.shared.open?("main") + } + } +} + +/// One labeled meter: title on the left, a thin tinted progress bar, and a +/// trailing numeric detail. Mirrors the restrained chrome used elsewhere. +private struct MeterRow: View { + let title: LocalizedStringKey + let tint: Color + let fraction: Double + let detail: String + + private var clamped: Double { max(0, min(1, fraction)) } + + var body: some View { + VStack(spacing: 4) { + HStack { + Text(title) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + Spacer() + Text(detail) + .font(.system(size: 11, weight: .semibold)) + .monospacedDigit() + .foregroundStyle(.primary) + } + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule() + .fill(Color.primary.opacity(0.08)) + Capsule() + .fill(tint) + .frame(width: max(2, geo.size.width * clamped)) + } + } + .frame(height: 5) + } + } +} diff --git a/PureMac/Views/Components/PermissionSheet.swift b/PureMac/Views/Components/PermissionSheet.swift new file mode 100644 index 0000000..ebeb437 --- /dev/null +++ b/PureMac/Views/Components/PermissionSheet.swift @@ -0,0 +1,356 @@ +import SwiftUI + +/// Premium Full Disk Access prompt that replaces the bare permission-denied +/// alert. Auto-polls FDA state, auto-retries the failed operation on grant, +/// and offers escape hatches for the "PureMac isn't in the list" case. +struct PermissionSheet: View { + @ObservedObject private var coordinator = PermissionCoordinator.shared + @State private var appeared = false + @State private var pulse = false + @State private var showAdvanced = false + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + VStack(spacing: 0) { + header + Divider().opacity(0.4) + body_ + Divider().opacity(0.4) + footer + } + .frame(width: 540) + .background(Color(nsColor: .windowBackgroundColor)) + .animation(reduceMotion ? nil : .spring(response: 0.4, dampingFraction: 0.8), + value: coordinator.hasFullDiskAccess) + .onAppear { + if reduceMotion { + appeared = true + return + } + withAnimation(.easeOut(duration: 0.4)) { appeared = true } + withAnimation(.easeInOut(duration: 1.4).repeatForever(autoreverses: true)) { + pulse = true + } + } + } + + // MARK: - Header + + private var header: some View { + HStack(spacing: 14) { + ZStack { + let tint = coordinator.hasFullDiskAccess ? Tint.green : Tint.blue + Circle() + .fill(tint.opacity(0.14)) + .frame(width: 40, height: 40) + headerIcon + .foregroundStyle(tint) + } + + VStack(alignment: .leading, spacing: 2) { + Text(coordinator.context.headline) + .font(.system(size: 16, weight: .bold)) + Text(coordinator.hasFullDiskAccess + ? "Access granted. Retrying…" + : "1-tap setup. We'll detect the change automatically.") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + } + + Spacer() + } + .padding(.horizontal, 22) + .padding(.vertical, 18) + .opacity(appeared ? 1 : 0) + .offset(y: appeared ? 0 : -6) + } + + @ViewBuilder + private var headerIcon: some View { + let name = coordinator.hasFullDiskAccess ? "checkmark.shield.fill" : "lock.shield.fill" + let base = Image(systemName: name) + .font(.system(size: 18, weight: .semibold)) + if #available(macOS 14.0, *) { + base.contentTransition(.symbolEffect(.replace)) + } else { + base + } + } + + // MARK: - Body + + @ViewBuilder + private var body_: some View { + if coordinator.hasFullDiskAccess { + grantedBody + .transition( + reduceMotion + ? .opacity + : .scale(scale: 0.9).combined(with: .opacity) + ) + } else { + requestBody + .transition(.opacity) + } + } + + private var requestBody: some View { + VStack(alignment: .leading, spacing: 18) { + // Two paths the user can choose between: drag the PureMac bundle + // straight into the FDA list, OR have us reveal it in Finder so + // they can drag from there. Drag-from-our-sheet is faster but + // some users will still prefer the Finder flow they recognize. + HStack(alignment: .center, spacing: 14) { + AppBundleDragHandle() + + Divider().frame(maxHeight: 110) + + VStack(spacing: 8) { + Button { + Haptics.tap() + coordinator.openSettingsAndReveal() + } label: { + Label("Open Settings & reveal PureMac", systemImage: "gear") + .font(.system(size: 13, weight: .semibold)) + .frame(maxWidth: .infinity) + .padding(.vertical, 4) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .keyboardShortcut(.defaultAction) + + Text("Or drag the icon on the left straight into Settings.") + .font(.system(size: 11)) + .foregroundStyle(.tertiary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + } + + // Step-by-step strip + HStack(spacing: 0) { + stepCell(number: 1, title: "Turn on PureMac", + caption: "Toggle the row that appears.") + stepDivider + stepCell(number: 2, title: "Authenticate", + caption: "Touch ID or password.") + stepDivider + stepCell(number: 3, title: "Done", + caption: "We auto-retry — no need to come back.") + } + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color.primary.opacity(0.04)) + ) + + // Listening indicator + HStack(spacing: 8) { + ZStack { + Circle() + .stroke(Tint.blue.opacity(0.3), lineWidth: 1.5) + .frame(width: 10, height: 10) + Circle() + .fill(Tint.blue) + .frame(width: 6, height: 6) + .scaleEffect(pulse ? 1.4 : 0.8) + .opacity(pulse ? 0.4 : 1) + } + Text("Watching for permission change…") + .font(.system(size: 11.5)) + .foregroundStyle(.secondary) + Spacer() + Button(showAdvanced ? "Hide help" : "PureMac not in the list?") { + withAnimation(.easeInOut(duration: 0.25)) { showAdvanced.toggle() } + } + .buttonStyle(.link) + .font(.system(size: 11.5)) + } + + if showAdvanced { + advancedPanel + .transition(.opacity.combined(with: .move(edge: .top))) + } + + if !coordinator.failedItemPaths.isEmpty { + blockedList + } + } + .padding(.horizontal, 22) + .padding(.vertical, 18) + .opacity(appeared ? 1 : 0) + } + + private var advancedPanel: some View { + VStack(alignment: .leading, spacing: 10) { + Text("Try this if PureMac doesn't appear:") + .font(.system(size: 11.5, weight: .semibold)) + .foregroundStyle(.secondary) + + HStack(spacing: 8) { + advancedButton( + icon: "folder.badge.gearshape", + title: "Reveal app", + subtitle: "Drag PureMac.app into the list" + ) { + FullDiskAccessManager.shared.revealAppInFinder() + } + advancedButton( + icon: "arrow.clockwise.circle", + title: "Reset + reprompt", + subtitle: "Clear stale TCC entry" + ) { + coordinator.resetAndReprime() + } + } + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color.primary.opacity(0.04)) + ) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder(Color.primary.opacity(0.06), lineWidth: 0.5) + ) + } + + private func advancedButton(icon: String, title: LocalizedStringKey, + subtitle: LocalizedStringKey, + action: @escaping () -> Void) -> some View { + Button(action: action) { + HStack(spacing: 10) { + IconTile(systemName: icon, tint: Tint.orange, size: 30, corner: 7) + VStack(alignment: .leading, spacing: 1) { + Text(title) + .font(.system(size: 12.5, weight: .semibold)) + .foregroundStyle(.primary) + Text(subtitle) + .font(.system(size: 10.5)) + .foregroundStyle(.secondary) + } + Spacer(minLength: 0) + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color(nsColor: .controlBackgroundColor)) + ) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .strokeBorder(Color.primary.opacity(0.08), lineWidth: 0.5) + ) + } + .buttonStyle(.plain) + } + + private var blockedList: some View { + DisclosureGroup { + VStack(alignment: .leading, spacing: 4) { + ForEach(coordinator.failedItemPaths.prefix(6), id: \.self) { path in + Text(path) + .font(.system(size: 10.5, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + if coordinator.failedItemPaths.count > 6 { + Text(remainingText(coordinator.failedItemPaths.count - 6)) + .font(.system(size: 10.5)) + .foregroundStyle(.tertiary) + } + } + .padding(.top, 4) + } label: { + Text(blockedHeaderText(coordinator.failedItemPaths.count)) + .font(.system(size: 11.5, weight: .semibold)) + .foregroundStyle(.secondary) + } + } + + private func remainingText(_ count: Int) -> String { + String(format: String(localized: "+ %lld more"), Int64(count)) + } + + private func blockedHeaderText(_ count: Int) -> String { + String( + format: String(localized: "%lld blocked path(s)"), + Int64(count) + ) + } + + private func stepCell(number: Int, title: LocalizedStringKey, + caption: LocalizedStringKey) -> some View { + HStack(alignment: .top, spacing: 8) { + Text("\(number)") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(.white) + .frame(width: 18, height: 18) + .background(Circle().fill(Tint.blue)) + VStack(alignment: .leading, spacing: 1) { + Text(title) + .font(.system(size: 11.5, weight: .semibold)) + Text(caption) + .font(.system(size: 10)) + .foregroundStyle(.secondary) + .lineLimit(2) + } + Spacer(minLength: 0) + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var stepDivider: some View { + Rectangle() + .fill(Color.primary.opacity(0.08)) + .frame(width: 0.5) + .padding(.vertical, 6) + } + + private var grantedBody: some View { + VStack(spacing: 10) { + // SuccessMedal pops + ripples once on its own and already + // honors Reduce Motion — no shared `pulse` state reuse. + SuccessMedal(size: 72) + Text("Access granted") + .font(.system(size: 15, weight: .bold)) + Text("Retrying the operation now.") + .font(.system(size: 11.5)) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 28) + } + + // MARK: - Footer + + private var footer: some View { + HStack(spacing: 12) { + Button("Skip for now") { + coordinator.dismiss(callRetry: false) + } + .buttonStyle(.link) + .foregroundStyle(.secondary) + + Spacer() + + if !coordinator.hasFullDiskAccess { + Button("I granted it — retry") { + coordinator.refreshStatus() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + if coordinator.hasFullDiskAccess { + coordinator.dismiss(callRetry: true) + } + } + } + .buttonStyle(.borderedProminent) + .controlSize(.regular) + } + } + .padding(.horizontal, 22) + .padding(.vertical, 14) + } +} diff --git a/PureMac/Views/DashboardView.swift b/PureMac/Views/DashboardView.swift new file mode 100644 index 0000000..fc24c58 --- /dev/null +++ b/PureMac/Views/DashboardView.swift @@ -0,0 +1,976 @@ +import SwiftUI + +/// Landing screen modeled after the new prototype: +/// hero gauge + stats + quick actions + suggestion cards. +/// Replaces the old SmartScanView idle/completed states with a richer +/// at-a-glance view, and delegates active-scan progress to inline state UI. +struct DashboardView: View { + @EnvironmentObject var appState: AppState + @State private var showConfirmation = false + @State private var fireCleanConfetti = false + @State private var lastCleanedScanState: Bool = false + @State private var hoveredSegment: String? + /// Confetti burst origin as a fraction of the dashboard, derived from the + /// SuccessMedal's real frame so the burst tracks it across window sizes + /// and RTL layout instead of a hand-aimed constant. + @State private var burstOrigin: UnitPoint = UnitPoint(x: 0.25, y: 0.28) + @State private var dashboardSize: CGSize = .zero + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + private let dashboardSpace = "dashboard" + + /// Hero cards rise in with a slight settle and dissolve out; under + /// Reduce Motion both directions collapse to a plain cross-fade. + private var heroTransition: AnyTransition { + reduceMotion + ? .opacity + : .asymmetric( + insertion: .opacity + .combined(with: .offset(y: 12)) + .combined(with: .scale(scale: 0.98, anchor: .top)), + removal: .opacity + ) + } + + var body: some View { + ZStack { + // Quiet tinted wash so the glass hero states have color to + // refract. Static — no Reduce Motion concerns. + LinearGradient( + colors: [Tint.blue.opacity(0.08), Tint.purple.opacity(0.05), .clear], + startPoint: .top, endPoint: .bottom + ) + .ignoresSafeArea() + + ScrollView { + VStack(alignment: .leading, spacing: 20) { + switch appState.scanState { + case .idle: + hero + .transition(heroTransition) + stats + if appState.diskInfo.totalSpace > 0 { + sectionHeader("Storage composition") + storageComposition + } + if !suggestionRows.isEmpty { + sectionHeader("Suggested for you") + suggestions + } + case .scanning: + scanningHero + .transition(heroTransition) + if !appState.allResults.isEmpty { + sectionHeader("Found so far") + liveResults + } + case .completed: + completedHero + .transition(heroTransition) + if appState.totalJunkSize > 0 { + sectionHeader("By category") + categoryChartCard + resultsList + } + case .cleaning: + cleaningHero + .transition(heroTransition) + case .cleaned: + cleanedHero + .transition(heroTransition) + } + } + .padding(.horizontal, 28) + .padding(.vertical, 24) + .frame(maxWidth: 920, alignment: .leading) + .animation(reduceMotion ? nil : MotionTokens.gentle, value: appState.scanState) + } + + // Celebratory burst when a clean cycle finishes with something + // freed. Origin tracks the SuccessMedal's real frame (see + // burstOrigin) so it explodes from the celebration on any window + // size / layout direction. + // allowsHitTesting=false keeps Done clickable through particles. + ConfettiView(trigger: fireCleanConfetti, mode: .burst(origin: burstOrigin)) + .allowsHitTesting(false) + } + .coordinateSpace(name: dashboardSpace) + .background( + GeometryReader { geo in + Color.clear.onAppear { dashboardSize = geo.size } + .onChange(of: geo.size) { dashboardSize = $0 } + } + ) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .onChange(of: appState.scanState) { newState in + // Fire only on the rising edge of .cleaned with freed > 0 so + // the burst doesn't replay when the user navigates back to the + // dashboard while .cleaned is still on screen. + let isCleaned: Bool = { + if case .cleaned = newState { return true } + return false + }() + if isCleaned && !lastCleanedScanState && appState.totalFreedSpace > 0 { + if reduceMotion { + // Confetti renders nothing under Reduce Motion; no need + // to wait for an entrance spring that isn't playing. + fireCleanConfetti.toggle() + } else { + // Let the hero settle and the counter roll before the + // burst so the celebration lands as one beat. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.55) { + fireCleanConfetti.toggle() + } + } + } + lastCleanedScanState = isCleaned + } + .confirmationDialog( + cleanConfirmationTitle, + isPresented: $showConfirmation, + titleVisibility: .visible + ) { + Button("Clean", role: .destructive) { appState.cleanAll() } + Button("Cancel", role: .cancel) {} + } message: { + Text("This will permanently delete the selected files. This cannot be undone.") + } + } + + private var cleanConfirmationTitle: String { + String( + format: String(localized: "Clean %@?"), + ByteCountFormatter.string(fromByteCount: appState.totalSelectedSize, countStyle: .file) + ) + } + + // MARK: - Hero (idle) + + private var hero: some View { + let total = appState.diskInfo.totalSpace + let used = appState.diskInfo.usedSpace + let free = appState.diskInfo.freeSpace + let percentUsed = total > 0 ? Double(used) / Double(total) : 0 + let stress = percentUsed > 0.85 + // Below this width the side-by-side ring + storage column overflows the + // card, so the hero stacks vertically and the ring shrinks. + let compact = dashboardSize.width > 0 && dashboardSize.width < 660 + let ringSize: CGFloat = compact ? 132 : 180 + + return CardSurface(padding: 24, accent: stress ? Tint.orange : Tint.blue, elevation: .raised) { + AdaptiveStack(compact: compact, spacing: compact ? 18 : 28) { + ZStack { + // Slow atmospheric drift behind the ring — barely-there + // ambient depth, frozen under Reduce Motion. + HeroDrift(tint: stress ? Tint.orange : Tint.blue) + HealthRing(percent: percentUsed) + .frame(width: ringSize, height: ringSize) + } + + VStack(alignment: .leading, spacing: 14) { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Text("Storage") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + .tracking(0.6) + if stress { + StatusChip(label: String(localized: "Low space"), + systemImage: "exclamationmark.triangle.fill", + tint: Tint.orange) + } + } + CountUpBytes(bytes: free) + .font(.system(size: 34, weight: .semibold)) + .foregroundStyle(stress ? Tint.orange : Color.primary) + Text(freeOfText(total: total)) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + } + Spacer() + Button { + appState.startSmartScan() + } label: { + Label("Smart Scan", systemImage: "sparkles") + .padding(.horizontal, 4) + } + .buttonStyle(GlowProminentButtonStyle(breathes: true)) + } + + storageBreakdown(used: used, total: total) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + + private func freeOfText(total: Int64) -> String { + String( + format: String(localized: "free of %@"), + ByteCountFormatter.string(fromByteCount: total, countStyle: .file) + ) + } + + private func storageBreakdown(used: Int64, total: Int64) -> some View { + let usedPct = total > 0 ? Double(used) / Double(total) : 0 + let junkPct = total > 0 ? min(0.4, Double(appState.totalJunkSize) / Double(total)) : 0 + + return VStack(alignment: .leading, spacing: 8) { + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule() + .fill(Color.primary.opacity(0.08)) + HStack(spacing: 0) { + Capsule() + .fill(LinearGradient(colors: [Tint.blue, Tint.purple], startPoint: .leading, endPoint: .trailing)) + .frame(width: geo.size.width * CGFloat(usedPct)) + } + if junkPct > 0 { + Capsule() + .fill(Tint.orange) + .frame(width: max(8, geo.size.width * CGFloat(junkPct))) + .offset(x: geo.size.width * CGFloat(usedPct - junkPct)) + .opacity(0.85) + } + } + } + .frame(height: 10) + + HStack(spacing: 16) { + LegendDot(color: Tint.blue, label: "Used", value: ByteCountFormatter.string(fromByteCount: used, countStyle: .file)) + if appState.totalJunkSize > 0 { + LegendDot(color: Tint.orange, label: "Junk", + value: ByteCountFormatter.string(fromByteCount: appState.totalJunkSize, countStyle: .file)) + } + if appState.diskInfo.purgeableSpace > 0 { + LegendDot(color: Tint.green, label: "Purgeable", + value: ByteCountFormatter.string(fromByteCount: appState.diskInfo.purgeableSpace, countStyle: .file)) + } + Spacer() + Text(percentUsedText(usedPct)) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + .monospacedDigit() + } + } + } + + private func percentUsedText(_ usedPct: Double) -> String { + String(format: String(localized: "%lld%% used"), Int64(usedPct * 100)) + } + + // MARK: - Stats + + private var stats: some View { + let free = appState.diskInfo.freeSpace + let total = appState.diskInfo.totalSpace + let percentUsed = total > 0 ? Double(total - free) / Double(total) : 0 + + // Four across when there's room, two when the dashboard is narrow so the + // cards don't crush their values. + let columnCount = dashboardSize.width > 0 && dashboardSize.width < 660 ? 2 : 4 + return LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: columnCount), spacing: 12) { + StatCard( + icon: "internaldrive.fill", + tint: Tint.blue, + label: "Free Space", + value: ByteCountFormatter.string(fromByteCount: free, countStyle: .file), + delta: total > 0 ? freeSpaceDelta(total: total, percentUsed: percentUsed) : nil, + byteValue: free + ) + .staggered(0) + StatCard( + icon: "trash.circle.fill", + tint: Tint.orange, + label: "Junk Found", + value: appState.totalJunkSize > 0 + ? ByteCountFormatter.string(fromByteCount: appState.totalJunkSize, countStyle: .file) + : "—", + delta: appState.allResults.isEmpty + ? String(localized: "Run a scan") + : junkFoundDelta(count: appState.allResults.count), + byteValue: appState.totalJunkSize > 0 ? appState.totalJunkSize : nil + ) + .staggered(1) + StatCard( + icon: "square.grid.2x2.fill", + tint: Tint.purple, + label: "Apps", + value: "\(appState.installedApps.count)", + delta: String(localized: "installed") + ) + .staggered(2) + StatCard( + icon: "memorychip.fill", + tint: Tint.green, + label: "Purgeable", + value: appState.diskInfo.purgeableSpace > 0 + ? ByteCountFormatter.string(fromByteCount: appState.diskInfo.purgeableSpace, countStyle: .file) + : "—", + delta: String(localized: "Managed by macOS"), + byteValue: appState.diskInfo.purgeableSpace > 0 ? appState.diskInfo.purgeableSpace : nil + ) + .staggered(3) + } + } + + private func freeSpaceDelta(total: Int64, percentUsed: Double) -> String { + String( + format: String(localized: "of %@ · %lld%% used"), + ByteCountFormatter.string(fromByteCount: total, countStyle: .file), + Int64(percentUsed * 100) + ) + } + + private func junkFoundDelta(count: Int) -> String { + String(format: String(localized: "across %lld categories"), Int64(count)) + } + + // MARK: - Storage composition + + private var storageComposition: some View { + let total = appState.diskInfo.totalSpace + let free = appState.diskInfo.freeSpace + let purge = max(0, appState.diskInfo.purgeableSpace) + let junk = max(0, min(appState.totalJunkSize, appState.diskInfo.usedSpace)) + // "Used" excludes the junk + purgeable slices so the four segments sum + // to the whole disk without double-counting. + let usedCore = max(0, appState.diskInfo.usedSpace - junk - purge) + + var segments: [StorageDonut.Segment] = [] + func add(_ id: String, _ value: Int64, _ color: Color, _ label: LocalizedStringKey) { + guard value > 0 else { return } + segments.append(.init( + id: id, + value: Double(value), + color: color, + label: label, + display: ByteCountFormatter.string(fromByteCount: value, countStyle: .file) + )) + } + add("used", usedCore, Tint.blue, "Used") + add("junk", junk, Tint.orange, "Junk") + add("purgeable", purge, Tint.green, "Purgeable") + add("free", free, Color.primary.opacity(0.14), "Free") + + return CardSurface(padding: 18, elevation: .standard) { + HStack(alignment: .center, spacing: 24) { + ZStack { + StorageDonut(segments: segments, highlightedID: hoveredSegment) + .frame(width: 132, height: 132) + VStack(spacing: 1) { + Text(ByteCountFormatter.string(fromByteCount: total, countStyle: .file)) + .font(.system(size: 17, weight: .bold)) + .monospacedDigit() + Text("total") + .font(.system(size: 10)) + .foregroundStyle(.secondary) + } + } + + VStack(alignment: .leading, spacing: 12) { + ForEach(Array(segments.enumerated()), id: \.element.id) { idx, seg in + HoverableLegendChip( + color: seg.color, label: seg.label, value: seg.display + ) { hovering in + hoveredSegment = hovering ? seg.id : nil + } + .staggered(idx) + } + } + Spacer(minLength: 0) + } + } + } + + // MARK: - Suggestions + + private var suggestions: some View { + VStack(spacing: 10) { + ForEach(Array(suggestionRows.enumerated()), id: \.offset) { idx, row in + SuggestionRow(suggestion: row) + .staggered(idx) + } + } + } + + private var suggestionRows: [Suggestion] { + var out: [Suggestion] = [] + // Surface the largest pending category as a contextual nudge. + if let biggest = appState.allResults.max(by: { $0.totalSize < $1.totalSize }), biggest.totalSize > 0 { + let title = String( + format: String(localized: "%@ is using %@"), + String(localized: String.LocalizationValue(biggest.category.rawValue)), + biggest.formattedSize + ) + out.append(Suggestion( + icon: biggest.category.icon, + tint: biggest.category.color, + title: title, + subtitle: String(localized: String.LocalizationValue(biggest.category.description)), + pill: biggest.formattedSize + )) + } + if !appState.hasFullDiskAccess { + out.append(Suggestion( + icon: "lock.shield.fill", + tint: Tint.orange, + title: String(localized: "Grant Full Disk Access for full results"), + subtitle: String(localized: "Without it, most caches and uninstall flows fail."), + pill: String(localized: "Action") + )) + } + return out + } + + // MARK: - Scanning state + + private var scanningHero: some View { + CardSurface(padding: 24, accent: Tint.blue, elevation: .raised, material: .ultraThinMaterial) { + HStack(alignment: .center, spacing: 28) { + ScanningGauge(progress: appState.scanProgress) + .frame(width: 180, height: 180) + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + sparklesIcon + Text("Scanning your Mac") + .font(.system(size: 22, weight: .bold)) + } + + // Category line slides up as the scan advances. + Text(currentlyInText) + .font(.system(size: 13)) + .foregroundStyle(.secondary) + .id(appState.currentScanCategory) + .transition( + reduceMotion + ? .opacity + : .asymmetric( + insertion: .move(edge: .bottom).combined(with: .opacity), + removal: .move(edge: .top).combined(with: .opacity) + ) + ) + + ShimmerProgressBar(progress: appState.scanProgress) + .frame(maxWidth: 320) + .padding(.top, 2) + + // Live file-path ticker — the "it's really working" + // signal. Observes the standalone ScanProgressTicker so the + // ~10Hz path churn re-renders only this label, not the whole + // dashboard (issues #119, #120). + ScanPathTicker(ticker: appState.scanTicker) + } + .animation(reduceMotion ? nil : .easeOut(duration: 0.18), value: appState.currentScanCategory) + Spacer(minLength: 0) + } + } + } + + private var currentlyInText: String { + String( + format: String(localized: "Currently in: %@"), + String(localized: String.LocalizationValue(appState.currentScanCategory)) + ) + } + + private var liveResults: some View { + CardSurface(padding: 0) { + VStack(spacing: 0) { + ForEach(appState.allResults.prefix(8)) { result in + HStack(spacing: 12) { + IconTile(systemName: result.category.icon, tint: result.category.color, size: 26) + Text(LocalizedStringKey(result.category.rawValue)) + .font(.system(size: 13)) + Spacer() + Text(result.formattedSize) + .font(.system(size: 13, weight: .semibold)) + .monospacedDigit() + .foregroundStyle(.secondary) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .transition( + reduceMotion + ? .opacity + : .move(edge: .bottom).combined(with: .opacity) + ) + if result.id != appState.allResults.prefix(8).last?.id { + Divider().padding(.leading, 54) + } + } + } + .animation(reduceMotion ? nil : .spring(response: 0.4, dampingFraction: 0.85), + value: appState.allResults.count) + } + } + + // MARK: - Completed state + + private var completedHero: some View { + let isClean = appState.totalJunkSize <= 0 + return CardSurface(padding: 24, accent: isClean ? Tint.green : Tint.orange, elevation: .raised) { + VStack(alignment: .leading, spacing: 14) { + HStack(alignment: .firstTextBaseline) { + if !isClean { + CountUpBytes(bytes: appState.totalJunkSize) + .font(.system(size: 40, weight: .semibold)) + Text("found") + .font(.system(size: 16)) + .foregroundStyle(.secondary) + } else { + HStack(spacing: 10) { + cleanSealIcon + Text("Your Mac is clean") + .font(.system(size: 22, weight: .bold)) + .foregroundStyle(Tint.green) + } + } + Spacer() + Button("Scan Again") { appState.startSmartScan() } + .controlSize(.large) + } + if !isClean { + HStack { + if appState.totalSelectedSize > 0 { + Button { + showConfirmation = true + } label: { + Label { + Text(cleanSelectedLabel) + } icon: { + Image(systemName: "sparkles") + } + .padding(.horizontal, 6) + } + .buttonStyle(GlowProminentButtonStyle()) + } + Spacer() + } + } + } + } + } + + private var cleanSelectedLabel: String { + String( + format: String(localized: "Clean %@"), + ByteCountFormatter.string(fromByteCount: appState.totalSelectedSize, countStyle: .file) + ) + } + + private var categoryChartCard: some View { + let bars = appState.allResults + .filter { $0.totalSize > 0 } + .sorted { $0.totalSize > $1.totalSize } + .prefix(8) + .map { CategoryBarChart.Bar(category: $0.category, size: $0.totalSize) } + + return CardSurface(padding: 18, elevation: .standard) { + CategoryBarChart(bars: Array(bars)) + } + } + + private var resultsList: some View { + CardSurface(padding: 0) { + VStack(spacing: 0) { + ForEach(Array(appState.allResults.enumerated()), id: \.element.id) { idx, result in + CategoryToggleRow(result: result) + .staggered(idx) + if result.id != appState.allResults.last?.id { + Divider().padding(.leading, 54) + } + } + } + } + } + + @ViewBuilder + private var cleanSealIcon: some View { + let base = Image(systemName: "checkmark.seal.fill") + .font(.system(size: 26, weight: .bold)) + .foregroundStyle(Tint.green) + if #available(macOS 14.0, *) { + base.symbolEffect(.bounce, value: appState.totalJunkSize) + } else { + base + } + } + + @ViewBuilder + private var sparklesIcon: some View { + let base = Image(systemName: "sparkles") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle( + LinearGradient(colors: [Tint.blue, Tint.purple], + startPoint: .topLeading, endPoint: .bottomTrailing) + ) + if #available(macOS 14.0, *) { + base.symbolEffect(.variableColor.iterative, options: .repeating) + } else { + base + } + } + + private var cleaningHero: some View { + CardSurface(padding: 24, accent: Tint.orange, elevation: .raised, material: .ultraThinMaterial) { + HStack(alignment: .center, spacing: 28) { + ScanningGauge(progress: appState.cleanProgress, tint: Tint.orange, label: "CLEANING") + .frame(width: 180, height: 180) + VStack(alignment: .leading, spacing: 8) { + Text("Cleaning…") + .font(.system(size: 22, weight: .bold)) + Text(percentCompleteText) + .font(.system(size: 13)) + .foregroundStyle(.secondary) + ShimmerProgressBar(progress: appState.cleanProgress, tint: Tint.orange) + .frame(maxWidth: 320) + .padding(.top, 2) + } + Spacer(minLength: 0) + } + } + } + + private var percentCompleteText: String { + String(format: String(localized: "%lld%% complete"), Int64(appState.cleanProgress * 100)) + } + + /// Convert the medal's frame (in dashboard coordinates) into a UnitPoint + /// for the confetti emitter. Falls back to the existing value until the + /// dashboard size is known. + private func updateBurstOrigin(medalFrame: CGRect) { + guard dashboardSize.width > 0, dashboardSize.height > 0 else { return } + burstOrigin = UnitPoint( + x: max(0, min(1, medalFrame.midX / dashboardSize.width)), + y: max(0, min(1, medalFrame.midY / dashboardSize.height)) + ) + } + + private var cleanedHero: some View { + CardSurface(padding: 24, accent: Tint.green, elevation: .raised, material: .ultraThinMaterial) { + HStack(alignment: .center, spacing: 28) { + SuccessMedal() + .background( + GeometryReader { geo in + Color.clear.onAppear { updateBurstOrigin(medalFrame: geo.frame(in: .named(dashboardSpace))) } + } + ) + + VStack(alignment: .leading, spacing: 6) { + CountUpBytes(bytes: appState.totalFreedSpace) + .font(.system(size: 40, weight: .semibold)) + .foregroundStyle(Tint.green) + Text("freed") + .font(.system(size: 14)) + .foregroundStyle(.secondary) + Button("Done") { appState.scanState = .idle } + .buttonStyle(GlowProminentButtonStyle(tint: Tint.green, gradient: TintGradient.of(Tint.green))) + .padding(.top, 4) + } + Spacer(minLength: 0) + } + } + } + + // MARK: - Helpers + + private func sectionHeader(_ text: LocalizedStringKey) -> some View { + Text(text) + .font(.system(size: 16, weight: .bold)) + .padding(.top, 4) + } +} + +// MARK: - Components + +private struct StatCard: View { + let icon: String + let tint: Color + let label: LocalizedStringKey + let value: String + let delta: String? + /// When set, the headline renders as a rolling byte counter instead of + /// the static `value` string. + var byteValue: Int64? = nil + + var body: some View { + CardSurface(padding: 14, accent: tint) { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + IconTile(systemName: icon, tint: tint, size: 28, glow: true) + Text(label) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + .tracking(0.4) + } + Group { + if let byteValue { + CountUpBytes(bytes: byteValue) + } else { + Text(value) + .monospacedDigit() + .contentTransition(.numericText()) + } + } + .font(.system(size: 22, weight: .bold)) + .lineLimit(1) + .minimumScaleFactor(0.6) + if let delta { + Text(delta) + .font(.system(size: 11)) + .foregroundStyle(.tertiary) + } + } + } + .pressable(hoverScale: 1.02, lift: true) + } +} + +private struct Suggestion: Identifiable { + let id = UUID() + let icon: String + let tint: Color + let title: String + let subtitle: String + let pill: String? +} + +private struct SuggestionRow: View { + let suggestion: Suggestion + var body: some View { + CardSurface(padding: 14, accent: suggestion.tint) { + HStack(spacing: 14) { + IconTile(systemName: suggestion.icon, tint: suggestion.tint, + size: 38, corner: 10, glow: true) + VStack(alignment: .leading, spacing: 2) { + Text(suggestion.title) + .font(.system(size: 14, weight: .semibold)) + Text(suggestion.subtitle) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + } + Spacer() + if let pill = suggestion.pill { + StatusChip(label: pill, tint: suggestion.tint) + } + Image(systemName: "chevron.right") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.tertiary) + } + } + .pressable(hoverScale: 1.01, lift: true) + } +} + +/// Live file-path ticker. Observes the standalone `ScanProgressTicker` directly +/// so the scan engine's ~10Hz path updates re-render only this one label rather +/// than the whole AppState-observing view tree (issues #119, #120). +private struct ScanPathTicker: View { + @ObservedObject var ticker: ScanProgressTicker + + private var display: String { + guard !ticker.path.isEmpty else { return "" } + return (ticker.path as NSString).abbreviatingWithTildeInPath + } + + var body: some View { + Text(display) + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(.tertiary) + .lineLimit(1) + .truncationMode(.middle) + .frame(maxWidth: 360, alignment: .leading) + .frame(height: 14) + .opacity(display.isEmpty ? 0 : 1) + } +} + +// MARK: - Gauges + +private struct LegendDot: View { + let color: Color + let label: LocalizedStringKey + let value: String + + var body: some View { + HStack(spacing: 6) { + Circle().fill(color).frame(width: 8, height: 8) + VStack(alignment: .leading, spacing: 0) { + Text(label) + .font(.system(size: 10.5)) + .foregroundStyle(.secondary) + Text(value) + .font(.system(size: 11.5, weight: .semibold)) + .monospacedDigit() + } + } + } +} + +// ScanningGauge now lives in Components/DashboardCharts.swift (radar sweep + +// halo rings + Reduce Motion compliance). + +/// Legend chip wrapper that reports hover for donut cross-highlighting and +/// scales slightly while hovered. +private struct HoverableLegendChip: View { + let color: Color + let label: LocalizedStringKey + let value: String + let onHoverChange: (Bool) -> Void + + @State private var hovering = false + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + LegendChip(color: color, label: label, value: value) + .scaleEffect(hovering && !reduceMotion ? 1.05 : 1, anchor: .leading) + .animation(reduceMotion ? nil : MotionTokens.snappy, value: hovering) + .onHover { h in + hovering = h + onHoverChange(h) + } + } +} + +/// Barely-there radial wash that drifts behind the idle hero ring. Static at +/// rest size under Reduce Motion. +private struct HeroDrift: View { + let tint: Color + + @State private var drift = false + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + RadialGradient( + colors: [tint.opacity(0.16), .clear], + center: .center, startRadius: 10, endRadius: 130 + ) + .blur(radius: 24) + .scaleEffect(drift ? 1.12 : 0.96) + .offset(x: drift ? 8 : -8) + .onAppear { + guard !reduceMotion else { return } + withAnimation(.easeInOut(duration: 3.0).repeatForever(autoreverses: true)) { + drift = true + } + } + } +} + +/// Gradient progress capsule with a traveling shimmer band, replacing the +/// stock linear ProgressView during scans/cleans. Shimmer is masked to the +/// filled portion and removed entirely under Reduce Motion. +private struct ShimmerProgressBar: View { + let progress: Double + var tint: Color = Tint.blue + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + private var clamped: Double { max(0, min(1, progress)) } + + var body: some View { + GeometryReader { geo in + let fillWidth = geo.size.width * CGFloat(clamped) + ZStack(alignment: .leading) { + Capsule() + .fill(Color.primary.opacity(0.08)) + + Capsule() + .fill( + LinearGradient(colors: [tint, tint.opacity(0.7)], + startPoint: .leading, endPoint: .trailing) + ) + .frame(width: max(8, fillWidth)) + .animation(reduceMotion ? nil : .easeOut(duration: 0.35), value: clamped) + + if !reduceMotion { + TimelineView(.animation) { timeline in + let t = timeline.date.timeIntervalSinceReferenceDate + let cycle = (t.truncatingRemainder(dividingBy: 1.8)) / 1.8 + let bandWidth: CGFloat = 56 + LinearGradient( + colors: [.clear, .white.opacity(0.35), .clear], + startPoint: .leading, endPoint: .trailing + ) + .frame(width: bandWidth) + .offset(x: CGFloat(cycle) * (geo.size.width + bandWidth) - bandWidth) + } + .mask( + Capsule() + .frame(width: max(8, fillWidth)) + .frame(maxWidth: .infinity, alignment: .leading) + ) + } + } + } + .frame(height: 9) + } +} + +// MARK: - Toggle row + +private struct CategoryToggleRow: View { + @EnvironmentObject var appState: AppState + let result: CategoryResult + + private var isFullySelected: Bool { + appState.selectedCountInCategory(result.category) == result.itemCount + } + + var body: some View { + Toggle(isOn: Binding( + get: { isFullySelected }, + set: { newValue in + if newValue { + appState.selectAllInCategory(result.category) + } else { + appState.deselectAllInCategory(result.category) + } + } + )) { + HStack(spacing: 12) { + IconTile(systemName: result.category.icon, tint: result.category.color, size: 28) + VStack(alignment: .leading, spacing: 1) { + Text(LocalizedStringKey(result.category.rawValue)) + .font(.system(size: 13.5, weight: .semibold)) + Text(itemsCountText) + .font(.system(size: 11.5)) + .foregroundStyle(.secondary) + } + Spacer() + Text(result.formattedSize) + .font(.system(size: 13, weight: .semibold)) + .monospacedDigit() + .foregroundStyle(.secondary) + } + } + .toggleStyle(.checkbox) + .padding(.horizontal, 16) + .padding(.vertical, 10) + } + + private var itemsCountText: String { + String(format: String(localized: "%lld items"), Int64(result.itemCount)) + } +} + +/// Lays its content out horizontally at full width and vertically when the +/// container is too narrow for the row to fit, so wide hero rows reflow into a +/// stacked layout instead of overflowing. +struct AdaptiveStack: View { + let compact: Bool + var spacing: CGFloat = 28 + @ViewBuilder var content: Content + + var body: some View { + if compact { + VStack(alignment: .leading, spacing: spacing) { content } + } else { + HStack(alignment: .center, spacing: spacing) { content } + } + } +} diff --git a/PureMac/Views/MainWindow.swift b/PureMac/Views/MainWindow.swift new file mode 100644 index 0000000..670977a --- /dev/null +++ b/PureMac/Views/MainWindow.swift @@ -0,0 +1,422 @@ +import SwiftUI + +struct MainWindow: View { + @EnvironmentObject var appState: AppState + @EnvironmentObject var theme: ThemeManager + @ObservedObject private var permission = PermissionCoordinator.shared + @State private var selectedSection: AppSection? = .cleaning(.smartScan) + @State private var columnVisibility: NavigationSplitViewVisibility = .all + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + NavigationSplitView(columnVisibility: $columnVisibility) { + sidebar + } detail: { + detailContainer + } + .navigationSplitViewColumnWidth(min: 232, ideal: 244, max: 320) + .frame(minWidth: 980, minHeight: 600) + .toolbar { + ToolbarItem(placement: .navigation) { + appearancePicker + } + } + .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in + appState.checkFullDiskAccess() + permission.refreshStatus() + } + .onChange(of: appState.pendingExternalApp) { app in + // A right-clicked app arrived via Finder Services — surface the + // Installed Apps view so its related-files scan is visible. + guard app != nil else { return } + selectedSection = .apps + appState.pendingExternalApp = nil + } + .onAppear { + // Covers a request that landed before MainWindow mounted (cold + // launch, or while onboarding was still showing) — onChange alone + // fires only on subsequent changes and would miss it. + if appState.pendingExternalApp != nil { + selectedSection = .apps + appState.pendingExternalApp = nil + } + } + .onChange(of: appState.cleanErrorIsFDAFixable) { isFDAFixable in + // Auto-route FDA-fixable clean errors straight into the rich + // sheet — skip the generic alert entirely so the user gets + // 1-tap remediation instead of "Check the log for details". + guard isFDAFixable else { return } + let pending = appState.pendingPermissionRetryItems + appState.cleanError = nil + appState.cleanErrorIsFDAFixable = false + appState.requestFullDiskAccessAndRetry( + items: pending, + context: .cleanup(failedCount: pending.count) + ) + } + .alert("Couldn't clean everything", isPresented: Binding( + get: { appState.cleanError != nil && !appState.cleanErrorIsFDAFixable }, + set: { if !$0 { appState.cleanError = nil } } + )) { + Button("OK", role: .cancel) { appState.cleanError = nil } + } message: { + Text(appState.cleanError ?? "") + } + .sheet(isPresented: Binding( + get: { permission.isRequesting }, + set: { if !$0 { permission.dismiss(callRetry: false) } } + )) { + PermissionSheet() + } + } + + // MARK: - Sidebar + + private var sidebar: some View { + List(selection: $selectedSection) { + Section { + navRow(section: .cleaning(.smartScan), label: "Dashboard", + icon: "sparkles", tint: Tint.blue, + badge: dashboardBadge) + } header: { sectionLabel("Overview") } + + Section { + navRow(section: .apps, label: "Installed Apps", + icon: "square.grid.2x2.fill", tint: Tint.purple, + badge: appState.installedApps.isEmpty ? nil : "\(appState.installedApps.count)") + navRow(section: .orphans, label: "Orphaned Files", + icon: "doc.questionmark.fill", tint: Tint.pink, + badge: appState.orphanedFiles.isEmpty ? nil : "\(appState.orphanedFiles.count)") + } header: { sectionLabel("Applications") } + + Section { + ForEach(CleaningCategory.scannable) { category in + navRow(section: .cleaning(category), + label: LocalizedStringKey(category.rawValue), + icon: category.icon, + tint: category.color, + badge: sizeBadge(for: category)) + } + } header: { sectionLabel("Cleanup") } + } + .listStyle(.sidebar) + .navigationTitle("PureMac") + .safeAreaInset(edge: .bottom) { + healthFooter + } + } + + private func sectionLabel(_ text: LocalizedStringKey) -> some View { + Text(text) + .font(.system(size: 10.5, weight: .semibold)) + .tracking(0.5) + .foregroundStyle(.tertiary) + .textCase(.uppercase) + } + + private func navRow(section: AppSection, label: LocalizedStringKey, icon: String, + tint: Color, badge: String?) -> some View { + SidebarNavRow( + label: label, icon: icon, tint: tint, badge: badge, + isSelected: selectedSection == section + ) + .tag(section) + } + + private var dashboardBadge: String? { + appState.totalJunkSize > 0 + ? ByteCountFormatter.string(fromByteCount: appState.totalJunkSize, countStyle: .file) + : nil + } + + private func sizeBadge(for category: CleaningCategory) -> String? { + guard let size = appState.categoryResults[category]?.totalSize, size > 0 else { return nil } + return ByteCountFormatter.string(fromByteCount: size, countStyle: .file) + } + + private var healthFooter: some View { + let ok = appState.hasFullDiskAccess + let tint = ok ? Tint.green : Tint.orange + return HStack(spacing: 10) { + PulsingDot(tint: tint, isPulsing: !ok) + + VStack(alignment: .leading, spacing: 1) { + Text(LocalizedStringKey(ok ? "Ready to clean" : "Limited access")) + .font(.system(size: 12, weight: .semibold)) + // Explicit solid color — same vibrancy-collapse guard as the + // sidebar rows (#117); this title also inherited the default. + .foregroundStyle(colorScheme == .dark + ? Color.white.opacity(0.92) + : Color.black.opacity(0.85)) + Text(LocalizedStringKey(ok ? "Full Disk Access granted" : "Grant FDA in Settings")) + .font(.system(size: 10.5)) + .foregroundStyle(.secondary) + } + Spacer() + if !ok { + Button("Fix") { + permission.requestAccess(context: .general) { + appState.checkFullDiskAccess() + } + } + .buttonStyle(.bordered) + .controlSize(.small) + .help("Fix permission") + } + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(.bar) + } + + // MARK: - Toolbar + + private var appearancePicker: some View { + AppearancePill(selection: Binding( + get: { theme.appearance }, + set: { theme.appearance = $0 } + )) + } + + // MARK: - Detail + + @ViewBuilder + private var detailContainer: some View { + VStack(spacing: 0) { + if !appState.hasFullDiskAccess && !appState.fdaBannerDismissed { + fdaToast + .padding(.horizontal, 16) + .padding(.top, 12) + .transition( + reduceMotion + ? .opacity + : .move(edge: .top).combined(with: .opacity) + ) + } + detailView + .id(selectedSection) + .transition( + reduceMotion + ? .opacity + : .asymmetric( + insertion: .opacity.combined(with: .offset(y: 10)), + removal: .opacity + ) + ) + } + .animation(reduceMotion ? nil : .easeOut(duration: 0.22), value: selectedSection) + .animation(reduceMotion ? nil : .spring(response: 0.4, dampingFraction: 0.8), + value: appState.fdaBannerDismissed) + .animation(reduceMotion ? nil : .spring(response: 0.4, dampingFraction: 0.8), + value: appState.hasFullDiskAccess) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background( + // Quiet ambient gradient under every section. Static layers, + // opacities kept low enough to stay clean in light mode. + ZStack { + Color(nsColor: .windowBackgroundColor) + LinearGradient( + colors: [Tint.blue.opacity(0.05), .clear], + startPoint: .topLeading, endPoint: .center + ) + RadialGradient( + colors: [Tint.purple.opacity(0.03), .clear], + center: .topTrailing, startRadius: 0, endRadius: 600 + ) + } + .ignoresSafeArea() + ) + } + + @ViewBuilder + private var detailView: some View { + switch selectedSection { + case .apps: + AppListView() + case .orphans: + OrphanListView() + case .cleaning(let category): + if category == .smartScan { + DashboardView() + } else { + CategoryDetailView(category: category) + } + case nil: + EmptyStateView("PureMac", systemImage: "sparkles", + description: "Select a category from the sidebar to get started.") + } + } + + @ViewBuilder + private var pulsingLockIcon: some View { + pulsingLockIconView() + } + + // Quiet FDA bar — single tinted surface, no gradient or glow. + private var fdaToast: some View { + HStack(spacing: 12) { + IconTile(systemName: "lock.shield.fill", tint: Tint.orange, size: 32, corner: 8) + + VStack(alignment: .leading, spacing: 1) { + Text("Full Disk Access required") + .font(.system(size: 13, weight: .semibold)) + Text("1-tap setup. We'll auto-retry what failed.") + .font(.system(size: 11.5)) + .foregroundStyle(.secondary) + } + + Spacer() + + Button("Set up") { + permission.requestAccess(context: .general) { + appState.checkFullDiskAccess() + } + } + .buttonStyle(.borderedProminent) + .controlSize(.regular) + + Button { + appState.fdaBannerDismissed = true + } label: { + Image(systemName: "xmark") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.secondary) + .padding(6) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help("Dismiss") + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Tint.orange.opacity(0.08)) + ) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder(Tint.orange.opacity(0.22), lineWidth: 0.5) + ) + } +} + +@ViewBuilder +private func pulsingLockIconView() -> some View { + let base = Image(systemName: "lock.shield.fill") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(.white) + if #available(macOS 14.0, *) { + base.symbolEffect(.pulse.byLayer, options: .repeating) + } else { + base + } +} + +/// Sidebar row with a springy hover highlight. Extracted to a struct so each +/// row owns its hover state; the selected row's IconTile glows via the shared +/// glow treatment in AppTheme. +private struct SidebarNavRow: View { + let label: LocalizedStringKey + let icon: String + let tint: Color + let badge: String? + let isSelected: Bool + + @State private var hovering = false + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + HStack(spacing: 10) { + IconTile(systemName: icon, tint: tint, size: 24, glow: isSelected) + Text(label) + .font(.system(size: 13, weight: isSelected ? .semibold : .regular)) + // Force an explicit, solid foreground instead of inheriting the + // sidebar list's default. On some configs (custom accent / + // reduced transparency, seen on M1 Max — issue #117) the + // inherited emphasized/vibrant label style resolves transparent + // and the row text disappears while explicitly-colored text + // (headers, badges) stays visible. A colorScheme-driven solid + // color sidesteps that vibrancy path entirely. + .foregroundStyle(labelColor) + Spacer() + if let badge { + Text(badge) + .font(.system(size: 11, weight: .semibold)) + .monospacedDigit() + .foregroundStyle(isSelected ? tint : .secondary) + .padding(.horizontal, 7) + .padding(.vertical, 2) + .background( + Capsule().fill( + (isSelected ? tint : Color.primary).opacity(isSelected ? 0.15 : 0.06) + ) + ) + .contentTransition(.numericText()) + } + } + .padding(.vertical, 2) + // Leading anchor keeps the row from clipping against the sidebar edge. + .scaleEffect(hovering && !reduceMotion ? 1.02 : 1, anchor: .leading) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color.primary.opacity(hovering && !isSelected ? 0.05 : 0)) + .padding(.horizontal, -6) + ) + .animation(reduceMotion ? nil : MotionTokens.snappy, value: hovering) + .contentShape(Rectangle()) + .onHover { hovering = $0 } + } + + /// Solid, opaque label color that adapts to light/dark without routing + /// through the sidebar's vibrant primary style (see #117). + private var labelColor: Color { + colorScheme == .dark + ? Color.white.opacity(0.92) + : Color.black.opacity(0.85) + } +} + +/// Small reusable status dot with optional pulse. Used in the sidebar health +/// footer and other "system status" surfaces. +private struct PulsingDot: View { + let tint: Color + var isPulsing: Bool = false + @State private var pulse = false + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + ZStack { + if isPulsing && !reduceMotion { + Circle() + .stroke(tint.opacity(pulse ? 0.0 : 0.6), lineWidth: 2) + .frame(width: 18, height: 18) + .scaleEffect(pulse ? 1.6 : 0.8) + } else { + Circle() + .fill(tint.opacity(0.20)) + .frame(width: 16, height: 16) + } + Circle() + .fill(tint) + .frame(width: 8, height: 8) + .shadow(color: tint.opacity(0.6), radius: 3) + } + .frame(width: 18, height: 18) + .onAppear { syncPulse() } + // The FDA status can flip while the window stays open — onAppear + // alone latches the first value and never starts/stops the loop. + .onChange(of: isPulsing) { _ in syncPulse() } + } + + private func syncPulse() { + guard isPulsing, !reduceMotion else { + pulse = false + return + } + withAnimation(.easeOut(duration: 1.4).repeatForever(autoreverses: false)) { + pulse = true + } + } +} diff --git a/PureMac/Views/OnboardingView.swift b/PureMac/Views/OnboardingView.swift new file mode 100644 index 0000000..228a8e0 --- /dev/null +++ b/PureMac/Views/OnboardingView.swift @@ -0,0 +1,491 @@ +import SwiftUI + +/// First-launch flow. Four scenes, large typography, plenty of breathing +/// room. Sequence is welcome → mission → permission (with live demo) → +/// ready. Skip is always available — we don't want to gate adoption on a +/// reluctant user, but we do want to make the FDA step concrete enough that +/// the people who *want* to grant it understand exactly what they're doing. +struct OnboardingView: View { + @Binding var isComplete: Bool + @State private var page: Page = .welcome + @State private var hasFda = false + @State private var hasOpenedSettings = false + @State private var autoAdvanceScheduled = false + /// +1 when navigating forward, -1 going back — drives the slide direction + /// so Back doesn't slide the wrong way. + @State private var direction: Int = 1 + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + private let pollTimer = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect() + + enum Page: Int, CaseIterable { + case welcome, mission, permission, ready + + var index: Int { rawValue } + static var count: Int { allCases.count } + } + + private var pageTransition: AnyTransition { + guard !reduceMotion else { return .opacity } + return .asymmetric( + insertion: .offset(x: direction >= 0 ? 48 : -48) + .combined(with: .opacity) + .combined(with: .scale(scale: 0.98)), + removal: .offset(x: direction >= 0 ? -48 : 48) + .combined(with: .opacity) + ) + } + + var body: some View { + ZStack { + backdrop + .ignoresSafeArea() + + VStack(spacing: 0) { + content + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.horizontal, 36) + .padding(.top, 44) + .padding(.bottom, 12) + .id(page) + .transition(pageTransition) + + bottomBar + } + } + .frame(width: 680, height: 560) + .onAppear { + FullDiskAccessManager.shared.triggerRegistration() + refreshFda() + } + .onReceive(pollTimer) { _ in + if page == .permission { refreshFda() } + } + } + + @ViewBuilder + private var content: some View { + switch page { + case .welcome: WelcomeScene() + case .mission: MissionScene() + case .permission: PermissionScene( + hasFda: hasFda, + hasOpenedSettings: hasOpenedSettings, + openSettings: openSettings, + revealAppInFinder: { FullDiskAccessManager.shared.revealAppInFinder() } + ) + case .ready: ReadyScene(hasFda: hasFda) + } + } + + // MARK: - Background + + private var backdrop: some View { + ZStack { + Color(nsColor: .windowBackgroundColor) + // Radial wash that shifts hue per page and drifts opposite the + // page slide — a slow parallax layer behind the faster foreground + // spring. Static under Reduce Motion. + RadialGradient( + colors: [pageTint.opacity(0.18), .clear], + center: .topTrailing, + startRadius: 60, + endRadius: 520 + ) + .offset(x: reduceMotion ? 0 : CGFloat(page.index) * -30) + .animation(reduceMotion ? nil : .easeInOut(duration: 0.8), value: page) + } + } + + private var pageTint: Color { + switch page { + case .welcome: return Tint.blue + case .mission: return Tint.purple + case .permission: return Tint.orange + case .ready: return Tint.green + } + } + + // MARK: - Bottom bar + + private var bottomBar: some View { + HStack { + if page != .welcome { + Button("Back") { advance(by: -1) } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + } else { + Button("Skip") { isComplete = true } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + } + + Spacer() + + HStack(spacing: 6) { + ForEach(Page.allCases, id: \.self) { p in + Capsule() + .fill(p == page ? Color.primary.opacity(0.75) : Color.primary.opacity(0.15)) + .frame(width: p == page ? 18 : 6, height: 6) + .animation(.spring(response: 0.35, dampingFraction: 0.85), value: page) + } + } + + Spacer() + + if page == .ready { + Button("Start") { isComplete = true } + .buttonStyle(GlowProminentButtonStyle(breathes: true)) + } else { + Button(page == .permission ? "Continue" : "Next") { advance(by: 1) } + .buttonStyle(.borderedProminent) + .controlSize(.large) + } + } + .padding(.horizontal, 28) + .padding(.vertical, 16) + .background( + VStack(spacing: 0) { + Divider().opacity(0.5) + Color.clear + } + ) + } + + // MARK: - Actions + + private func advance(by delta: Int) { + let target = max(0, min(Page.count - 1, page.index + delta)) + guard target != page.index else { return } + Haptics.tap() + // Direction must be set BEFORE the transaction so the transition + // resolves with the correct slide edge. + direction = delta >= 0 ? 1 : -1 + withAnimation(reduceMotion ? nil : .spring(response: 0.45, dampingFraction: 0.85)) { + page = Page(rawValue: target) ?? page + } + } + + private func openSettings() { + FullDiskAccessManager.shared.openFullDiskAccessSettings() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + FullDiskAccessManager.shared.revealAppInFinder() + } + withAnimation(.easeInOut(duration: 0.25)) { + hasOpenedSettings = true + } + } + + private func refreshFda() { + let granted = FullDiskAccessManager.shared.hasFullDiskAccess + if granted != hasFda { + withAnimation(.easeInOut(duration: 0.3)) { + hasFda = granted + } + if granted { Haptics.success() } + } + // Auto-advance once they grant access while on the permission page. + // The autoAdvanceScheduled latch prevents the 1-second poll from + // queueing multiple .8s delays if grants are detected on back-to-back + // ticks before the page actually flips. + guard granted, page == .permission, !autoAdvanceScheduled else { return } + autoAdvanceScheduled = true + DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { + // Re-check page in case the user manually advanced or backed up + // during the delay window. + guard page == .permission else { + autoAdvanceScheduled = false + return + } + advance(by: 1) + autoAdvanceScheduled = false + } + } +} + +// MARK: - Scenes + +private struct WelcomeScene: View { + @State private var bob = false + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + VStack(spacing: 26) { + Spacer(minLength: 0) + + ZStack { + if let icon = NSImage(named: "AppIcon") { + Image(nsImage: icon) + .resizable() + .interpolation(.high) + .frame(width: 120, height: 120) + .shadow(color: .black.opacity(0.15), radius: 18, y: 8) + .offset(y: reduceMotion ? 0 : (bob ? -4 : 4)) + .onAppear { + guard !reduceMotion else { return } + withAnimation(.easeInOut(duration: 2.4).repeatForever(autoreverses: true)) { + bob = true + } + } + } else { + Image(systemName: "sparkles") + .font(.system(size: 88, weight: .semibold)) + .foregroundStyle(Tint.blue) + } + } + .staggered(0, baseDelay: 0.07) + + VStack(spacing: 12) { + Text("Reclaim your Mac") + .font(.system(size: 38, weight: .semibold)) + .multilineTextAlignment(.center) + Text("Apple sells you small disks. We help you keep them clean.") + .font(.system(size: 15)) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 420) + } + .staggered(1, baseDelay: 0.07) + + Text("Free. Open source. MIT licensed.") + .font(.system(size: 11.5, weight: .medium)) + .foregroundStyle(.tertiary) + .tracking(0.3) + .padding(.top, 4) + .staggered(2, baseDelay: 0.07) + + Spacer(minLength: 0) + } + } +} + +private struct MissionScene: View { + var body: some View { + VStack(spacing: 28) { + Spacer(minLength: 0) + + VStack(spacing: 12) { + Text("What's inside") + .font(.system(size: 30, weight: .semibold)) + .multilineTextAlignment(.center) + Text("Three things, done well.") + .font(.system(size: 14)) + .foregroundStyle(.secondary) + } + .staggered(0, baseDelay: 0.07) + + VStack(spacing: 12) { + FeatureRow( + systemImage: "sparkles", + tint: Tint.blue, + title: "Smart Scan", + body: "Find caches, logs, broken installs, and the AI-app history hiding in your library." + ) + .staggered(1, baseDelay: 0.07) + FeatureRow( + systemImage: "square.grid.2x2.fill", + tint: Tint.purple, + title: "App Uninstaller", + body: "Drag an app, see every file it dropped, remove all of it. No leftovers." + ) + .staggered(2, baseDelay: 0.07) + FeatureRow( + systemImage: "doc.questionmark.fill", + tint: Tint.pink, + title: "Orphan Finder", + body: "Surfaces files that outlived the apps that created them." + ) + .staggered(3, baseDelay: 0.07) + } + .frame(maxWidth: 460) + + Spacer(minLength: 0) + } + } +} + +private struct FeatureRow: View { + let systemImage: String + let tint: Color + let title: String + let body_: String + + init(systemImage: String, tint: Color, title: String, body: String) { + self.systemImage = systemImage + self.tint = tint + self.title = title + self.body_ = body + } + + var body: some View { + HStack(alignment: .top, spacing: 14) { + IconTile(systemName: systemImage, tint: tint, size: 36, corner: 9) + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.system(size: 14, weight: .semibold)) + Text(body_) + .font(.system(size: 12.5)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer() + } + .padding(14) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color.primary.opacity(0.04)) + ) + } +} + +private struct PermissionScene: View { + let hasFda: Bool + let hasOpenedSettings: Bool + let openSettings: () -> Void + let revealAppInFinder: () -> Void + + var body: some View { + VStack(spacing: 18) { + VStack(spacing: 8) { + Text(hasFda ? "Permission granted" : "One permission, then we're done") + .font(.system(size: 26, weight: .semibold)) + .multilineTextAlignment(.center) + Text(hasFda + ? "PureMac can now reach the locations macOS protects by default." + : "macOS hides certain folders from every app until you say otherwise. We need them to find caches and uninstall cleanly.") + .font(.system(size: 13)) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 460) + } + .staggered(0, baseDelay: 0.07) + + FDADemoView() + .frame(maxWidth: 420) + .staggered(1, baseDelay: 0.07) + + if hasFda { + Label("All set — moving you to the next step.", systemImage: "checkmark.circle.fill") + .font(.system(size: 12.5, weight: .medium)) + .foregroundStyle(Tint.green) + .transition(.opacity.combined(with: .scale)) + } else { + // Two equally legitimate paths: open Settings and have it + // reveal PureMac in Finder, OR grab the draggable icon on the + // left and drop it directly into the FDA list. Showing both + // side by side lets users pick the path their mental model + // prefers without a 3-step decision tree. + HStack(alignment: .top, spacing: 18) { + AppBundleDragHandle() + Divider().frame(maxHeight: 90) + VStack(spacing: 8) { + Button { + openSettings() + } label: { + Label(hasOpenedSettings ? "Reopen Settings" : "Open Settings & reveal PureMac", + systemImage: "gear") + .font(.system(size: 13, weight: .semibold)) + .frame(minWidth: 240) + .padding(.vertical, 2) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .keyboardShortcut(.defaultAction) + + if hasOpenedSettings { + HStack(spacing: 12) { + Button("Reveal app again") { revealAppInFinder() } + .buttonStyle(.link) + .font(.system(size: 11.5)) + Button("Reset permissions") { + _ = FullDiskAccessManager.shared.resetFullDiskAccess() + FullDiskAccessManager.shared.triggerRegistration() + } + .buttonStyle(.link) + .font(.system(size: 11.5)) + } + .transition(.opacity) + } else { + Text("Tip: drag the icon on the left straight into the list.") + .font(.system(size: 11)) + .foregroundStyle(.tertiary) + .multilineTextAlignment(.center) + } + } + .frame(maxWidth: 260) + } + .padding(.top, 4) + } + } + } +} + +private struct ReadyScene: View { + let hasFda: Bool + @State private var bounce = false + @State private var fireConfetti = false + @State private var confettiWork: DispatchWorkItem? + @AppStorage("PureMac.HasSeenWelcomeConfetti") private var hasSeenConfetti = false + + var body: some View { + ZStack { + // Confetti sits above the content but behind any touch targets; + // disabling hit-testing keeps the Start button clickable through + // falling particles. + if !hasSeenConfetti { + ConfettiView(trigger: fireConfetti) + .allowsHitTesting(false) + } + + VStack(spacing: 22) { + Spacer(minLength: 0) + + ZStack { + Circle() + .fill((hasFda ? Tint.green : Tint.orange).opacity(0.12)) + .frame(width: 110, height: 110) + Image(systemName: hasFda ? "checkmark" : "hand.wave.fill") + .font(.system(size: 50, weight: .semibold)) + .foregroundStyle(hasFda ? Tint.green : Tint.orange) + .scaleEffect(bounce ? 1.05 : 1.0) + } + .onAppear { + withAnimation(.spring(response: 0.5, dampingFraction: 0.55)) { + bounce = true + } + // Fire the welcome confetti exactly once per install. + // The work item is cancelled in onDisappear so a fast + // user who clicks Start within the 0.35s delay doesn't + // burn the once-per-install flag without ever seeing the + // celebration. + guard !hasSeenConfetti else { return } + let work = DispatchWorkItem { + fireConfetti = true + hasSeenConfetti = true + Haptics.success() + } + confettiWork = work + DispatchQueue.main.asyncAfter(deadline: .now() + 0.35, execute: work) + } + .onDisappear { + confettiWork?.cancel() + confettiWork = nil + } + + VStack(spacing: 10) { + Text(hasFda ? "You're ready" : "Ready when you are") + .font(.system(size: 30, weight: .semibold)) + Text(hasFda + ? "Hit Start to run your first Smart Scan." + : "Some features will be limited without Full Disk Access. You can grant it later in Settings.") + .font(.system(size: 13.5)) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 420) + } + + Spacer(minLength: 0) + } + } + } +} diff --git a/PureMac/Views/Orphans/OrphanListView.swift b/PureMac/Views/Orphans/OrphanListView.swift new file mode 100644 index 0000000..3da409e --- /dev/null +++ b/PureMac/Views/Orphans/OrphanListView.swift @@ -0,0 +1,348 @@ +import SwiftUI + +struct OrphanListView: View { + @EnvironmentObject var appState: AppState + @State private var selectedOrphans: Set = [] + @State private var isRemoving = false + @State private var removalErrorMessage: String? + /// Orphan sizes computed off the main thread. Orphans are exactly the large + /// leftovers (multi-GB Caches/Containers/Application Support), and their + /// size is a recursive directory walk — calling it from `body` would re-walk + /// every realized row on each selection toggle and beachball the UI. We walk + /// once off-main per result set and rows read the cached value. + @State private var sizeCache: [URL: Int64] = [:] + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + Group { + if appState.isSearchingOrphans { + VStack(spacing: 16) { + ProgressView(LocalizedStringKey("Scanning for orphaned files...")) + .progressViewStyle(.linear) + .frame(maxWidth: 300) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if appState.orphanedFiles.isEmpty { + EmptyStateView("No Orphaned Files", systemImage: "checkmark.circle", description: "No leftover files from uninstalled apps were found.", action: { appState.findOrphans() }, actionLabel: "Scan for Orphans", tint: Tint.green) + } else { + List { + // No .staggered(): List is lazy, so a delayed-reveal would + // blank each row as it scrolls in. The removal transition + // below still gives the sweep-out on delete. + ForEach(Array(appState.orphanedFiles.enumerated()), id: \.element) { _, fileURL in + OrphanRowView( + fileURL: fileURL, + isSelected: orphanBinding(for: fileURL), + fileSize: sizeCache[fileURL], + onReveal: { revealInFinder(fileURL) }, + onCopyPath: { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(fileURL.path, forType: .string) + }, + onIgnore: { ignoreOrphans([fileURL]) }, + onTrash: { Task { await removeSingleOrphan(fileURL) } } + ) + .transition( + reduceMotion + ? .opacity + : .asymmetric( + insertion: .opacity, + removal: .move(edge: .leading).combined(with: .opacity) + ) + ) + } + } + } + } + .navigationTitle(orphanedFilesTitle) + .task(id: appState.orphanedFiles) { + // Recompute off the main thread whenever the orphan set changes + // (new scan, removals). FileSizeCalculator is a plain static helper + // with no main-actor isolation, so it runs safely on a detached + // background task; the result is applied back on the main actor. + let urls = appState.orphanedFiles + let sizes = await Task.detached(priority: .utility) { () -> [URL: Int64] in + var out: [URL: Int64] = [:] + for url in urls { + out[url] = FileSizeCalculator.size(of: url) ?? 0 + } + return out + }.value + sizeCache = sizes + } + .toolbar { + ToolbarItemGroup { + if !appState.orphanedFiles.isEmpty { + Button(LocalizedStringKey(selectedOrphans.count == appState.orphanedFiles.count ? "Deselect All" : "Select All")) { + if selectedOrphans.count == appState.orphanedFiles.count { + selectedOrphans.removeAll() + } else { + selectedOrphans = Set(appState.orphanedFiles) + } + } + } + + Button("Scan for Orphans") { + appState.findOrphans() + } + + if !selectedOrphans.isEmpty { + Button(ignoreSelectedLabel) { + ignoreOrphans(Array(selectedOrphans)) + } + .disabled(isRemoving) + + Button(removeSelectedLabel, role: .destructive) { + Task { + await removeSelectedOrphans() + } + } + .buttonStyle(.borderedProminent) + .tint(.red) + .disabled(isRemoving) + } + } + } + .alert("Some files could not be removed", isPresented: Binding( + get: { removalErrorMessage != nil }, + set: { if !$0 { removalErrorMessage = nil } } + )) { + Button("OK", role: .cancel) {} + } message: { + Text(removalErrorMessage ?? "") + } + } + + private var orphanedFilesTitle: String { + String(format: String(localized: "Orphaned Files (%lld)"), Int64(appState.orphanedFiles.count)) + } + + private var removeSelectedLabel: String { + String(format: String(localized: "Remove Selected (%lld)"), Int64(selectedOrphans.count)) + } + + private var ignoreSelectedLabel: String { + String(format: String(localized: "Ignore Selected (%lld)"), Int64(selectedOrphans.count)) + } + + /// Persist the given URLs to the ignore list (so future scans skip them) + /// and drop them from the local selection. + private func ignoreOrphans(_ urls: [URL]) { + appState.ignoreOrphans(urls) + selectedOrphans.subtract(urls) + } + + private func orphanBinding(for url: URL) -> Binding { + Binding( + get: { selectedOrphans.contains(url) }, + set: { selected in + if selected { + selectedOrphans.insert(url) + } else { + selectedOrphans.remove(url) + } + } + ) + } + + private func removeSelectedOrphans() async { + isRemoving = true + defer { isRemoving = false } + + let urlsToRemove = selectedOrphans + var failedPaths: [String] = [] + var removedURLs: Set = [] + var needsAdminURLs: [URL] = [] + + for url in urlsToRemove { + guard OrphanSafetyPolicy.isSafeCandidate(url) else { + failedPaths.append("\(url.path) (blocked by safety policy)") + continue + } + + switch removeOrphan(url) { + case .removed: + removedURLs.insert(url) + case .needsAdmin: + needsAdminURLs.append(url) + case .failed: + failedPaths.append(url.path) + } + } + + if !needsAdminURLs.isEmpty { + if removeWithAdminPrivileges(needsAdminURLs) { + for url in needsAdminURLs { + if !FileManager.default.fileExists(atPath: url.path) { + removedURLs.insert(url) + } else { + failedPaths.append(url.path) + } + } + } else { + failedPaths.append(contentsOf: needsAdminURLs.map(\.path)) + } + } + + // Sweep removed rows out (per-row transitions are attached in the + // List above); plain assignment under Reduce Motion. + if reduceMotion { + appState.orphanedFiles.removeAll { removedURLs.contains($0) } + } else { + withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { + appState.orphanedFiles.removeAll { removedURLs.contains($0) } + } + } + selectedOrphans.subtract(removedURLs) + + if !failedPaths.isEmpty { + let preview = failedPaths.prefix(3).joined(separator: "\n") + let suffix = failedPaths.count > 3 ? "\n…" : "" + removalErrorMessage = "\(failedPaths.count) item(s) failed to delete.\n\n\(preview)\(suffix)" + } + } + + private enum OrphanRemoveOutcome { + case removed + case needsAdmin + case failed + } + + private func removeOrphan(_ url: URL) -> OrphanRemoveOutcome { + do { + try FileManager.default.removeItem(at: url) + return .removed + } catch { + let nsError = error as NSError + let permissionDeniedCodes = [ + NSFileReadNoPermissionError, + NSFileWriteNoPermissionError, + NSFileWriteUnknownError, + 257, + 513, + ] + + guard permissionDeniedCodes.contains(nsError.code) else { + return .failed + } + + return .needsAdmin + } + } + + private func revealInFinder(_ url: URL) { + // activateFileViewerSelecting handles sandbox-bookmarked paths and + // missing files better than selectFile(_:inFileViewerRootedAtPath:), + // which silently no-ops when the path is unreachable from Finder's + // current scope. If the target itself was removed since the scan, + // fall back to opening the enclosing directory so the user lands + // somewhere useful instead of nothing happening. + let fm = FileManager.default + if fm.fileExists(atPath: url.path) { + NSWorkspace.shared.activateFileViewerSelecting([url]) + return + } + let parent = url.deletingLastPathComponent() + if fm.fileExists(atPath: parent.path) { + NSWorkspace.shared.open(parent) + } + } + + private func removeSingleOrphan(_ url: URL) async { + let previous = selectedOrphans + selectedOrphans = [url] + await removeSelectedOrphans() + selectedOrphans = previous.subtracting([url]) + } + + private func removeWithAdminPrivileges(_ urls: [URL]) -> Bool { + guard !urls.isEmpty else { return true } + guard urls.allSatisfy({ OrphanSafetyPolicy.isSafeCandidate($0) }) else { return false } + + // Quote path for a POSIX shell command. + let quotedPaths = urls.map { url in + "'\(url.path.replacingOccurrences(of: "'", with: "'\\\"'\\\"'"))'" + } + let shellCommand = "rm -rf -- \(quotedPaths.joined(separator: " "))" + let appleScriptCommand = shellCommand + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + let script = "do shell script \"\(appleScriptCommand)\" with administrator privileges" + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") + process.arguments = ["-e", script] + + do { + try process.run() + process.waitUntilExit() + if process.terminationStatus != 0 { + return false + } + return true + } catch { + return false + } + } +} + +// MARK: - Row + +/// Orphan row extracted to its own struct so hover highlight and the springy +/// checkbox are per-row state. +private struct OrphanRowView: View { + let fileURL: URL + @Binding var isSelected: Bool + let fileSize: Int64? + let onReveal: () -> Void + let onCopyPath: () -> Void + let onIgnore: () -> Void + let onTrash: () -> Void + + @State private var hovering = false + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + Toggle(isOn: $isSelected) { + HStack { + Image(nsImage: NSWorkspace.shared.icon(forFile: fileURL.path)) + .resizable() + .frame(width: 16, height: 16) + + VStack(alignment: .leading, spacing: 2) { + Text(fileURL.lastPathComponent) + .lineLimit(1) + Text(fileURL.path) + .font(.caption) + .foregroundStyle(.tertiary) + .lineLimit(1) + .truncationMode(.middle) + } + + Spacer() + + if let size = fileSize { + Text(ByteCountFormatter.string(fromByteCount: size, countStyle: .file)) + .monospacedDigit() + .foregroundStyle(.secondary) + } + } + } + .toggleStyle(AnimatedCheckboxStyle(tint: Tint.pink)) + .padding(.vertical, 2) + .padding(.horizontal, 4) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(hovering ? Color.primary.opacity(0.06) : .clear) + ) + .animation(reduceMotion ? nil : MotionTokens.snappy, value: hovering) + .onHover { hovering = $0 } + .contextMenu { + Button("Reveal in Finder") { onReveal() } + Button("Copy Path") { onCopyPath() } + Divider() + Button("Always Ignore") { onIgnore() } + Button("Move to Trash", role: .destructive) { onTrash() } + } + } +} diff --git a/PureMac/Views/Settings/SettingsView.swift b/PureMac/Views/Settings/SettingsView.swift new file mode 100644 index 0000000..f288168 --- /dev/null +++ b/PureMac/Views/Settings/SettingsView.swift @@ -0,0 +1,367 @@ +import AppKit +import SwiftUI +import ServiceManagement + +struct SettingsView: View { + var body: some View { + TabView { + GeneralSettingsView() + .tabItem { Label("General", systemImage: "gear") } + CleaningSettingsView() + .tabItem { Label("Cleaning", systemImage: "trash") } + ScheduleSettingsView() + .tabItem { Label("Schedule", systemImage: "clock") } + AboutSettingsView() + .tabItem { Label("About", systemImage: "info.circle") } + } + .frame(width: 480, height: 430) + } +} + +// MARK: - General + +enum SearchSensitivity: String, CaseIterable, Identifiable, Codable { + case strict = "Strict" + case enhanced = "Enhanced" + case deep = "Deep" + + var id: String { rawValue } + + var description: String { + switch self { + case .strict: return "Exact bundle ID and name matches only. Safest option." + case .enhanced: return "Includes partial name matching and bundle ID components." + case .deep: return "Includes company name, entitlements, and team identifier matching." + } + } +} + +struct GeneralSettingsView: View { + @AppStorage("settings.general.launchAtLogin") private var launchAtLogin = false + @AppStorage("settings.general.searchSensitivity") private var sensitivity: SearchSensitivity = .enhanced + @AppStorage("settings.general.confirmBeforeDelete") private var confirmBeforeDelete = true + @AppStorage("settings.general.menuBarMonitor") private var menuBarMonitor = false + @AppStorage(Haptics.soundEffectsKey) private var soundEffects = true + @AppStorage(AppLanguage.preferenceKey) private var appLanguageRaw = AppLanguage.current.rawValue + @State private var languageNeedsRelaunch = false + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + Form { + Section("Startup") { + Toggle("Launch PureMac at login", isOn: launchAtLoginBinding) + } + + Section("App Scanning") { + Picker("Search sensitivity", selection: $sensitivity) { + ForEach(SearchSensitivity.allCases) { level in + VStack(alignment: .leading) { + Text(LocalizedStringKey(level.rawValue)) + Text(LocalizedStringKey(level.description)) + .font(.caption) + .foregroundStyle(.secondary) + } + .tag(level) + } + } + .pickerStyle(.radioGroup) + } + + Section("Language") { + Picker("Language", selection: appLanguageBinding) { + ForEach(AppLanguage.allCases) { language in + Text(LocalizedStringKey(language.displayName)).tag(language) + } + } + + if languageNeedsRelaunch { + Text("Restart PureMac to apply the selected language.") + .font(.caption) + .foregroundStyle(.secondary) + .transition(.opacity.combined(with: .move(edge: .top))) + + Button("Relaunch Now") { + relaunchApp() + } + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + + Section("System Monitor") { + Toggle("Show system monitor in menu bar", isOn: menuBarMonitorBinding) + Text("Live CPU, memory, and disk meters in the menu bar. PureMac keeps running in the background while this is on.") + .font(.caption) + .foregroundStyle(.secondary) + } + + Section("Sound") { + Toggle("Play sound effects", isOn: $soundEffects) + } + + Section("Safety") { + Toggle("Confirm before deleting files", isOn: $confirmBeforeDelete) + } + } + .formStyle(.grouped) + .animation(reduceMotion ? nil : .spring(response: 0.35, dampingFraction: 0.85), + value: languageNeedsRelaunch) + } + + private var menuBarMonitorBinding: Binding { + Binding( + get: { menuBarMonitor }, + set: { newValue in + menuBarMonitor = newValue + // Tell AppDelegate to add/remove the status item without relaunch. + NotificationCenter.default.post(name: .pureMacMenuBarMonitorChanged, object: nil) + } + ) + } + + private var launchAtLoginBinding: Binding { + Binding( + get: { launchAtLogin }, + set: { newValue in + launchAtLogin = newValue + toggleLaunchAtLogin(newValue) + } + ) + } + + private var appLanguageBinding: Binding { + Binding( + get: { AppLanguage(rawValue: appLanguageRaw) ?? .system }, + set: { newValue in + appLanguageRaw = newValue.rawValue + applyLanguage(newValue) + } + ) + } + + private func toggleLaunchAtLogin(_ enabled: Bool) { + do { + if enabled { + try SMAppService.mainApp.register() + } else { + try SMAppService.mainApp.unregister() + } + } catch { + Logger.shared.log("Failed to \(enabled ? "enable" : "disable") launch at login: \(error.localizedDescription)", level: .error) + launchAtLogin = !enabled + } + } + + private func applyLanguage(_ language: AppLanguage) { + AppLanguagePreferences.apply(language) + languageNeedsRelaunch = true + } + + private func relaunchApp() { + guard Bundle.main.bundleURL.pathExtension == "app" else { + NSApp.terminate(nil) + return + } + + let task = Process() + task.executableURL = URL(fileURLWithPath: "/usr/bin/open") + task.arguments = ["-n", Bundle.main.bundleURL.path] + + do { + try task.run() + NSApp.terminate(nil) + } catch { + Logger.shared.log("Failed to relaunch PureMac: \(error.localizedDescription)", level: .error) + } + } +} + +// MARK: - Cleaning + +struct CleaningSettingsView: View { + @EnvironmentObject var appState: AppState + @AppStorage("settings.cleaning.skipHiddenFiles") private var skipHiddenFiles = true + @AppStorage("settings.cleaning.largeFileThreshold") private var largeFileThresholdMB: Int = 100 + @AppStorage("settings.cleaning.oldFileMonths") private var oldFileMonths: Int = 12 + + private static let excludedFoldersKey = "settings.cleaning.largeFileExcludedFolders" + @State private var excludedFolders: [String] = [] + + var body: some View { + Form { + Section("File Discovery") { + Toggle("Skip hidden files during scan", isOn: $skipHiddenFiles) + } + + Section("Large Files") { + Stepper( + String(format: String(localized: "Minimum size: %lld MB"), Int64(largeFileThresholdMB)), + value: $largeFileThresholdMB, + in: 10...1000, + step: 10 + ) + Stepper( + String(format: String(localized: "Files older than: %lld months"), Int64(oldFileMonths)), + value: $oldFileMonths, + in: 1...60 + ) + } + + Section("Excluded Folders") { + if excludedFolders.isEmpty { + Text("Files inside these folders are skipped from the Large & Old Files scan (Downloads, Documents, Desktop).") + .font(.caption) + .foregroundStyle(.secondary) + } else { + ForEach(excludedFolders, id: \.self) { folder in + HStack(spacing: 8) { + Image(systemName: "folder") + .foregroundStyle(.secondary) + Text((folder as NSString).abbreviatingWithTildeInPath) + .lineLimit(1) + .truncationMode(.middle) + .help(folder) + Spacer() + Button { + removeExcludedFolder(folder) + } label: { + Image(systemName: "minus.circle.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.borderless) + .help(String(localized: "Remove from exclusions")) + } + } + } + Button("Add Folder…") { addExcludedFolder() } + } + + Section("Orphan Finder") { + HStack { + // Read the live count directly (it's a UserDefaults-backed + // computed property on AppState) so it stays correct when + // orphans are ignored from the Orphans view while this tab + // is open. AppState fires objectWillChange on both ignore + // (via @Published orphanedFiles) and clear, re-rendering this. + Text(String(format: String(localized: "Ignored orphans: %lld"), Int64(appState.ignoredOrphanCount))) + .foregroundStyle(.secondary) + Spacer() + Button("Forget Ignored") { + appState.clearIgnoredOrphans() + } + .disabled(appState.ignoredOrphanCount == 0) + } + Text("Ignored files won't appear in future orphan scans.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .formStyle(.grouped) + .onAppear { + excludedFolders = UserDefaults.standard.stringArray(forKey: Self.excludedFoldersKey) ?? [] + } + } + + private func persistExcludedFolders() { + UserDefaults.standard.set(excludedFolders, forKey: Self.excludedFoldersKey) + } + + private func addExcludedFolder() { + let panel = NSOpenPanel() + panel.canChooseDirectories = true + panel.canChooseFiles = false + panel.allowsMultipleSelection = true + panel.prompt = String(localized: "Add Folder…") + guard panel.runModal() == .OK else { return } + for url in panel.urls where !excludedFolders.contains(url.path) { + excludedFolders.append(url.path) + } + persistExcludedFolders() + } + + private func removeExcludedFolder(_ folder: String) { + excludedFolders.removeAll { $0 == folder } + persistExcludedFolders() + } +} + +// MARK: - Schedule + +struct ScheduleSettingsView: View { + @EnvironmentObject var appState: AppState + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + Form { + Section("Automatic Scanning") { + Toggle("Enable scheduled scanning", isOn: $appState.scheduler.config.isEnabled) + + if appState.scheduler.config.isEnabled { + Group { + Picker("Scan interval", selection: $appState.scheduler.config.interval) { + ForEach(ScheduleInterval.allCases) { interval in + Text(LocalizedStringKey(interval.rawValue)).tag(interval) + } + } + + Toggle("Auto-clean after scan", isOn: $appState.scheduler.config.autoClean) + Toggle("Notify on completion", isOn: $appState.scheduler.config.notifyOnCompletion) + + HStack { + Text("Last run") + Spacer() + Text(appState.scheduler.config.formattedLastRun) + .foregroundStyle(.secondary) + } + } + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + } + .formStyle(.grouped) + .animation(reduceMotion ? nil : .spring(response: 0.35, dampingFraction: 0.85), + value: appState.scheduler.config.isEnabled) + } +} + +// MARK: - About + +struct AboutSettingsView: View { + var body: some View { + Form { + Section { + HStack { + if let appIcon = NSImage(named: "AppIcon") { + Image(nsImage: appIcon) + .resizable() + .frame(width: 64, height: 64) + } + VStack(alignment: .leading, spacing: 4) { + Text("PureMac") + .font(.title2.bold()) + Text( + String( + format: String(localized: "Version %@"), + Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0" + ) + ) + .foregroundStyle(.secondary) + Text("Free, open-source macOS app manager.") + .foregroundStyle(.secondary) + .font(.caption) + } + } + } + + Section { + Link("GitHub Repository", destination: URL(string: "https://github.com/momenbasel/PureMac")!) + Link("Report an Issue", destination: URL(string: "https://github.com/momenbasel/PureMac/issues")!) + } + + Section { + Text("MIT License") + .foregroundStyle(.secondary) + } + } + .formStyle(.grouped) + } +} diff --git a/PureMac/ar.lproj/Localizable.strings b/PureMac/ar.lproj/Localizable.strings new file mode 100644 index 0000000..cc82f5c --- /dev/null +++ b/PureMac/ar.lproj/Localizable.strings @@ -0,0 +1,376 @@ +/* Sidebar (MainWindow) */ +"Overview" = "نظرة عامة"; +"Applications" = "التطبيقات"; +"Cleanup" = "التنظيف"; +"Dashboard" = "لوحة التحكم"; +"Installed Apps" = "التطبيقات المثبّتة"; +"Orphaned Files" = "الملفات اليتيمة"; +"PureMac" = "PureMac"; +"Ready to clean" = "جاهز للتنظيف"; +"Limited access" = "وصول محدود"; +"Full Disk Access granted" = "تم منح الوصول الكامل للقرص"; +"Grant FDA in Settings" = "امنح الوصول الكامل من الإعدادات"; +"Select a category from the sidebar to get started." = "اختر فئة من الشريط الجانبي للبدء."; + +/* FDA toast + clean error alert (MainWindow) */ +"Couldn't clean everything" = "تعذّر تنظيف كل شيء"; +"Open System Settings" = "افتح إعدادات النظام"; +"OK" = "موافق"; +"Full Disk Access required" = "يلزم منح الوصول الكامل إلى القرص"; +"macOS blocks PureMac from cleaning caches and uninstalling apps until you grant access." = "يمنع macOS تطبيق PureMac من تنظيف ذاكرات التخزين المؤقت وإلغاء تثبيت التطبيقات حتى تمنحه الوصول."; +"Grant Access" = "منح الوصول"; + +/* Dashboard */ +"Storage" = "التخزين"; +"Smart Scan" = "الفحص الذكي"; +"free of %@" = "متاح من %@"; +"%lld%% used" = "%lld%% مستخدمة"; +"Used" = "المستخدمة"; +"Junk" = "نفايات"; +"Purgeable" = "قابل للتحرير"; +"USED" = "المستخدم"; +"SCANNING" = "جارٍ الفحص"; +"Free Space" = "المساحة المتاحة"; +"Junk Found" = "النفايات المكتشفة"; +"Apps" = "التطبيقات"; +"of %@ · %lld%% used" = "من %@ · %lld%% مستخدمة"; +"Run a scan" = "ابدأ فحصًا"; +"across %lld categories" = "في %lld فئة"; +"installed" = "مثبّتة"; +"APFS reclaimable" = "قابلة للاسترجاع عبر APFS"; +"Suggested for you" = "اقتراحات لك"; +"Found so far" = "العثور عليه حتى الآن"; +"By category" = "حسب الفئة"; +"%@ is using %@" = "%@ يستخدم %@"; +"Grant Full Disk Access for full results" = "امنح الوصول الكامل للقرص للحصول على نتائج كاملة"; +"Without it, most caches and uninstall flows fail." = "من دونه ستفشل معظم عمليات تنظيف الذاكرة المؤقتة وإزالة التطبيقات."; +"Action" = "إجراء"; +"Scanning your Mac" = "جارٍ فحص جهاز Mac"; +"Currently in: %@" = "الفئة الحالية: %@"; +"found" = "تم العثور عليه"; +"Your Mac is clean" = "جهاز Mac نظيف"; +"Scan Again" = "إعادة الفحص"; +"Clean %@" = "تنظيف %@"; +"Clean %@?" = "تنظيف %@؟"; +"Cleaning…" = "جارٍ التنظيف…"; +"%lld%% complete" = "%lld%% مكتمل"; +"freed" = "محرّر"; +"Done" = "تم"; +"%lld items" = "%lld عنصرًا"; + +/* Common destructive dialog */ +"Clean" = "تنظيف"; +"Cancel" = "إلغاء"; +"This will permanently delete the selected files. This cannot be undone." = "سيؤدي ذلك إلى حذف الملفات المحددة نهائيًا. لا يمكن التراجع عن هذا الإجراء."; + +/* Category Detail */ +"All Clean" = "كل شيء نظيف"; +"No junk files found in this category." = "لا توجد ملفات غير مرغوبة في هذه الفئة."; +"Not Scanned" = "لم يتم الفحص"; +"Run a scan to analyze this category." = "ابدأ فحصًا لتحليل هذه الفئة."; +"Scan Now" = "ابدأ الفحص الآن"; +"Filter files" = "تصفية الملفات"; +"Scan" = "فحص"; +"Select All" = "تحديد الكل"; +"Deselect All" = "إلغاء تحديد الكل"; +"Largest First" = "الأكبر أولًا"; +"Smallest First" = "الأصغر أولًا"; +"Sorted: Largest First" = "الترتيب: الأكبر أولًا"; +"Sorted: Smallest First" = "الترتيب: الأصغر أولًا"; +"Clean %lld items" = "تنظيف %lld عنصر"; +"%lld items · %@" = "%lld عنصر · %@"; +"Scanning…" = "جارٍ الفحص…"; +"Rescan" = "إعادة الفحص"; +"%lld of %lld selected" = "%lld من %lld محدد"; +"Reveal in Finder" = "إظهار في Finder"; +"Copy Path" = "نسخ المسار"; +"Move to Trash" = "نقل إلى المهملات"; + +/* Apps (AppListView) */ +"Search apps" = "بحث في التطبيقات"; +"Refresh" = "تحديث"; +"Installed Apps (%lld)" = "التطبيقات المثبّتة (%lld)"; +"Uninstall (%lld files)" = "إزالة التثبيت (%lld ملف)"; +"Loading installed apps..." = "جارٍ تحميل التطبيقات المثبّتة..."; +"No Apps Found" = "لم يتم العثور على تطبيقات"; +"Could not find any installed applications." = "تعذّر العثور على أي تطبيقات مثبّتة."; +"Retry" = "إعادة المحاولة"; +"Application" = "التطبيق"; +"Size" = "الحجم"; +"Select an App" = "اختر تطبيقًا"; +"Select an app from the list to see all its related files across your system." = "اختر تطبيقًا من القائمة لعرض جميع الملفات المرتبطة به في النظام."; + +/* App Files (AppFilesView) */ +"%lld files" = "%lld ملف"; +"Scanning for related files..." = "جارٍ البحث عن الملفات المرتبطة..."; +"Checking %lld locations..." = "جارٍ فحص %lld موقعًا..."; +"No Related Files" = "لا توجد ملفات مرتبطة"; +"No additional files found for %@." = "لم يتم العثور على ملفات إضافية لـ %@."; +"Remove %lld files (%@)" = "إزالة %lld ملف (%@)"; +"Removal Failed" = "فشلت الإزالة"; +"Remove this file" = "إزالة هذا الملف"; +"Remove %@?" = "إزالة %@؟"; +"Remove" = "إزالة"; +"This will permanently delete this file. This action cannot be undone." = "سيؤدي ذلك إلى حذف هذا الملف نهائيًا. لا يمكن التراجع عن هذا الإجراء."; + +/* Orphans */ +"Scanning for orphaned files..." = "جارٍ البحث عن الملفات اليتيمة..."; +"No Orphaned Files" = "لا توجد ملفات يتيمة"; +"No leftover files from uninstalled apps were found." = "لم يتم العثور على ملفات متبقية من تطبيقات تمت إزالتها."; +"Scan for Orphans" = "ابحث عن الملفات اليتيمة"; +"Orphaned Files (%lld)" = "الملفات اليتيمة (%lld)"; +"Remove Selected (%lld)" = "إزالة المحدد (%lld)"; +"Some files could not be removed" = "تعذّرت إزالة بعض الملفات"; + +/* Onboarding */ +"Back" = "السابق"; +"Next" = "التالي"; +"Get Started" = "ابدأ"; +"Welcome to PureMac" = "أهلًا بك في PureMac"; +"Free, open-source macOS app manager and system cleaner." = "مدير تطبيقات وأداة تنظيف نظام مجانية ومفتوحة المصدر لـ macOS."; +"Find junk files across your system" = "اعثر على الملفات غير المرغوبة في جميع أنحاء النظام"; +"App Uninstaller" = "إلغاء تثبيت التطبيقات"; +"Remove apps and all their files" = "أزل التطبيقات وجميع ملفاتها"; +"Orphan Finder" = "البحث عن الملفات اليتيمة"; +"Find leftovers from deleted apps" = "اعثر على بقايا التطبيقات المحذوفة"; +"Full Disk Access" = "الوصول الكامل للقرص"; +"PureMac needs Full Disk Access to uninstall apps, find leftover files, and clean protected caches." = "يحتاج PureMac إلى الوصول الكامل للقرص لإزالة التطبيقات والعثور على الملفات المتبقية وتنظيف ذاكرات التخزين المؤقت المحمية."; +"We'll guide you through the next steps." = "سنرشدك إلى الخطوات التالية."; +"In System Settings, do this:" = "في إعدادات النظام، نفّذ الآتي:"; +"Privacy & Security → Full Disk Access" = "الخصوصية والأمان ← الوصول الكامل للقرص"; +"Find **PureMac** and turn the toggle on" = "ابحث عن **PureMac** وفعّل المفتاح"; +"Authenticate with Touch ID or your password" = "تحقق باستخدام Touch ID أو كلمة المرور"; +"Reopen Settings" = "إعادة فتح الإعدادات"; +"PureMac isn't in the list — reveal it" = "PureMac غير موجود في القائمة — أظهره"; +"Reset permissions and re-prompt" = "إعادة تعيين الأذونات وإعادة الطلب"; +"Hide diagnostics" = "إخفاء التشخيص"; +"Show diagnostics" = "إظهار التشخيص"; +"Trouble?" = "هل تواجه مشكلة؟"; +"Waiting for permission…" = "بانتظار الإذن…"; +"Permission granted." = "تم منح الإذن."; +"PureMac can now manage protected files." = "أصبح بإمكان PureMac إدارة الملفات المحمية."; +"You're Ready" = "أنت جاهز"; +"%lld/%lld protected locations accessible" = "%lld/%lld من المواقع المحمية متاحة"; +"Some features will be limited. You can grant Full Disk Access later in System Settings." = "ستكون بعض الميزات محدودة. يمكنك منح الوصول الكامل للقرص لاحقًا من إعدادات النظام."; +"Trash" = "سلة المهملات"; +"Mail Data" = "بيانات البريد"; +"Safari Data" = "بيانات Safari"; +"Desktop" = "سطح المكتب"; +"Documents" = "المستندات"; +"TCC Database" = "قاعدة بيانات TCC"; +"Blocked" = "محظور"; + +/* Settings */ +"General" = "عام"; +"Cleaning" = "التنظيف"; +"Schedule" = "الجدولة"; +"About" = "حول"; +"Startup" = "بدء التشغيل"; +"Launch PureMac at login" = "تشغيل PureMac عند تسجيل الدخول"; +"App Scanning" = "فحص التطبيقات"; +"Search sensitivity" = "حساسية البحث"; +"Strict" = "صارمة"; +"Enhanced" = "محسّنة"; +"Deep" = "عميقة"; +"Exact bundle ID and name matches only. Safest option." = "تطابق تام لمعرّف الحزمة والاسم فقط. الخيار الأكثر أمانًا."; +"Includes partial name matching and bundle ID components." = "يشمل التطابق الجزئي للاسم ومكوّنات معرّف الحزمة."; +"Includes company name, entitlements, and team identifier matching." = "يشمل التطابق مع اسم الشركة والاستحقاقات ومعرّف الفريق."; +"Language" = "اللغة"; +"Restart PureMac to apply the selected language." = "أعد تشغيل PureMac لتطبيق اللغة المحددة."; +"Relaunch Now" = "إعادة التشغيل الآن"; +"System Default" = "افتراضي النظام"; +"English" = "الإنجليزية"; +"Spanish" = "الإسبانية"; +"Japanese" = "اليابانية"; +"Arabic" = "العربية"; +"Portuguese (Brazil)" = "البرتغالية (البرازيل)"; +"Chinese (Simplified)" = "الصينية (المبسطة)"; +"Chinese (Traditional)" = "الصينية (التقليدية)"; +"Safety" = "الأمان"; +"Confirm before deleting files" = "التأكيد قبل حذف الملفات"; +"File Discovery" = "اكتشاف الملفات"; +"Skip hidden files during scan" = "تخطي الملفات المخفية أثناء الفحص"; +"Large Files" = "الملفات الكبيرة"; +"Minimum size: %lld MB" = "الحد الأدنى للحجم: %lld ميغابايت"; +"Files older than: %lld months" = "ملفات أقدم من: %lld شهرًا"; +"Automatic Scanning" = "الفحص التلقائي"; +"Enable scheduled scanning" = "تمكين الفحص المجدول"; +"Scan interval" = "فاصل الفحص"; +"Auto-clean after scan" = "تنظيف تلقائي بعد الفحص"; +"Auto-purge purgeable space" = "إفراغ المساحة القابلة للتحرير تلقائيًا"; +"Notify on completion" = "الإشعار عند الاكتمال"; +"Last run" = "آخر تشغيل"; +"Version %@" = "الإصدار %@"; +"Free, open-source macOS app manager." = "مدير تطبيقات macOS مجاني ومفتوح المصدر."; +"GitHub Repository" = "مستودع GitHub"; +"Report an Issue" = "الإبلاغ عن مشكلة"; +"MIT License" = "ترخيص MIT"; + +/* Cleaning categories (CleaningCategory.rawValue) */ +"System Junk" = "نفايات النظام"; +"User Cache" = "ذاكرة التخزين المؤقت للمستخدم"; +"AI Apps" = "تطبيقات الذكاء الاصطناعي"; +"Mail Files" = "ملفات البريد"; +"Trash Bins" = "سلات المهملات"; +"Large & Old Files" = "الملفات الكبيرة والقديمة"; +"Purgeable Space" = "المساحة القابلة للتحرير"; +"Xcode Junk" = "نفايات Xcode"; +"Brew Cache" = "ذاكرة Brew المؤقتة"; +"Node Cache" = "ذاكرة Node المؤقتة"; +"Docker Cache" = "ذاكرة Docker المؤقتة"; + +/* Cleaning category descriptions */ +"Scan everything at once" = "فحص كل شيء دفعة واحدة"; +"System caches, logs, and temporary files" = "ذاكرات النظام المؤقتة والسجلات والملفات المؤقتة"; +"Application caches and browser data" = "ذاكرات التطبيقات المؤقتة وبيانات المتصفح"; +"Local AI app logs, caches, and optional history" = "سجلات تطبيقات الذكاء الاصطناعي المحلية وذاكراتها المؤقتة والمحفوظات الاختيارية"; +"Downloaded mail attachments" = "مرفقات البريد التي تم تنزيلها"; +"Files in your Trash" = "الملفات الموجودة في سلة المهملات"; +"Files over 100 MB or older than 1 year" = "الملفات التي يتجاوز حجمها 100 ميغابايت أو أقدم من سنة"; +"APFS purgeable disk space" = "مساحة القرص القابلة للتحرير في APFS"; +"Derived data, archives, and simulators" = "البيانات المشتقة والأرشيفات والمحاكيات"; +"Homebrew download cache" = "ذاكرة تنزيلات Homebrew المؤقتة"; +"npm, yarn, and pnpm download caches" = "ذاكرات تنزيلات npm وyarn وpnpm المؤقتة"; +"Docker images, containers, and build cache" = "صور Docker والحاويات وذاكرة البناء المؤقتة"; + +/* Node Cache subcategories */ +"npm cache" = "ذاكرة npm المؤقتة"; +"yarn classic cache" = "ذاكرة yarn الكلاسيكية المؤقتة"; +"pnpm content-addressable store" = "مخزن pnpm القابل للعنونة بالمحتوى"; + +/* Docker Cache helper */ +"Reclaimable (run `docker system prune -af`)" = "قابلة للاسترجاع (شغّل `docker system prune -af`)"; + +/* Schedule intervals (ScheduleInterval.rawValue) */ +"Every Hour" = "كل ساعة"; +"Every 3 Hours" = "كل 3 ساعات"; +"Every 6 Hours" = "كل 6 ساعات"; +"Every 12 Hours" = "كل 12 ساعة"; +"Daily" = "يوميًا"; +"Weekly" = "أسبوعيًا"; +"Every 2 Weeks" = "كل أسبوعين"; +"Monthly" = "شهريًا"; + +/* Schedule status */ +"Never" = "أبدًا"; +"Not scheduled" = "غير مجدول"; + +/* Appearance modes (AppearanceMode.label) */ +"System" = "النظام"; +"Light" = "فاتح"; +"Dark" = "داكن"; + +/* Notifications */ +"Found %@ of junk files." = "تم العثور على %@ من الملفات غير المرغوبة."; + +/* Permission Sheet */ +"Grant Full Disk Access" = "منح الوصول الكامل إلى القرص"; +"%lld item(s) need Full Disk Access" = "%lld عنصر يحتاج إلى الوصول الكامل إلى القرص"; +"Uninstalling %@: %lld file(s) need Full Disk Access" = "إلغاء تثبيت %@: %lld ملف يحتاج إلى الوصول الكامل إلى القرص"; +"%lld item(s) need Full Disk Access to remove. Tap Grant Access to fix in one step." = "%lld عنصر يحتاج إلى الوصول الكامل لحذفه. اضغط منح الوصول لإصلاحه بخطوة واحدة."; +"Couldn't remove %@%@. They may be in use or protected by macOS." = "تعذّر حذف %@%@. قد تكون قيد الاستخدام أو محمية بواسطة macOS."; +" and %lld more" = " و%lld أخرى"; +"Access granted. Retrying…" = "تم منح الوصول. جارٍ إعادة المحاولة…"; +"1-tap setup. We'll detect the change automatically." = "إعداد بضغطة واحدة. سنكتشف التغيير تلقائيًا."; +"Open Settings & reveal PureMac" = "فتح الإعدادات وإظهار PureMac"; +"We'll open both windows side-by-side." = "سنفتح النافذتين جنبًا إلى جنب."; +"Turn on PureMac" = "تفعيل PureMac"; +"Toggle the row that appears." = "بدّل الصف الذي يظهر."; +"Authenticate" = "المصادقة"; +"Touch ID or password." = "Touch ID أو كلمة المرور."; +"We auto-retry — no need to come back." = "سنعيد المحاولة تلقائيًا - لا حاجة للعودة."; +"Watching for permission change…" = "ننتظر تغيير الإذن…"; +"PureMac not in the list?" = "PureMac غير موجود في القائمة؟"; +"Hide help" = "إخفاء التعليمات"; +"Try this if PureMac doesn't appear:" = "جرّب هذا إن لم يظهر PureMac:"; +"Reveal app" = "إظهار التطبيق"; +"Drag PureMac.app into the list" = "اسحب PureMac.app إلى القائمة"; +"Reset + reprompt" = "إعادة تعيين + إعادة المطالبة"; +"Clear stale TCC entry" = "حذف إدخال TCC القديم"; +"+ %lld more" = "+ %lld أخرى"; +"%lld blocked path(s)" = "%lld مسار محظور"; +"Access granted" = "تم منح الوصول"; +"Retrying the operation now." = "جارٍ إعادة العملية الآن."; +"Skip for now" = "تخطي الآن"; +"I granted it — retry" = "لقد منحت الإذن - أعد المحاولة"; + +/* Dashboard polish */ +"Low space" = "مساحة منخفضة"; +"CLEANING" = "جارٍ التنظيف"; +"Quick Setup" = "إعداد سريع"; +"1-tap setup. We'll auto-retry what failed." = "إعداد بضغطة واحدة. سنعيد المحاولة تلقائيًا."; +"Fix permission" = "إصلاح الإذن"; +"Dismiss" = "تجاهل"; + +/* Onboarding v2 */ +"Reclaim your Mac" = "استعد جهاز Mac الخاص بك"; +"Apple sells you small disks. We help you keep them clean." = "تبيعك Apple أقراصًا صغيرة. ونساعدك على إبقائها نظيفة."; +"Free. Open source. MIT licensed." = "مجاني. مفتوح المصدر. ترخيص MIT."; +"What's inside" = "ما الذي بالداخل"; +"Three things, done well." = "ثلاثة أشياء، تُنجز بإتقان."; +"Find caches, logs, broken installs, and the AI-app history hiding in your library." = "ابحث عن الذاكرات المؤقتة والسجلات والتنصيبات المعطوبة وسجل تطبيقات الذكاء الاصطناعي المخبأ."; +"Drag an app, see every file it dropped, remove all of it. No leftovers." = "اسحب تطبيقًا، شاهد كل ملف تركه، احذف كل شيء. بدون بقايا."; +"Surfaces files that outlived the apps that created them." = "تبرز الملفات التي عاشت بعد التطبيقات التي أنشأتها."; +"One permission, then we're done" = "إذن واحد فقط، ثم ننتهي"; +"Permission granted" = "تم منح الإذن"; +"PureMac can now reach the locations macOS protects by default." = "يستطيع PureMac الآن الوصول إلى المواقع التي يحميها macOS افتراضيًا."; +"macOS hides certain folders from every app until you say otherwise. We need them to find caches and uninstall cleanly." = "يخفي macOS بعض المجلدات عن كل تطبيق حتى تسمح بذلك. نحتاجها للعثور على الذاكرات المؤقتة وإلغاء التثبيت بنظافة."; +"All set — moving you to the next step." = "كل شيء جاهز - ننقلك إلى الخطوة التالية."; +"Reveal app again" = "إظهار التطبيق مرة أخرى"; +"Reset permissions" = "إعادة تعيين الأذونات"; +"You're ready" = "أنت جاهز"; +"Ready when you are" = "جاهز عندما تكون مستعدًا"; +"Hit Start to run your first Smart Scan." = "اضغط ابدأ لتشغيل أول فحص ذكي."; +"Skip" = "تخطي"; +"Start" = "ابدأ"; +"Continue" = "متابعة"; + +/* Drag handle */ +"Drag to the Settings list" = "اسحب إلى قائمة الإعدادات"; +"Drag this icon into the Full Disk Access list in System Settings." = "اسحب هذه الأيقونة إلى قائمة الوصول الكامل للقرص في إعدادات النظام."; +"Tip: drag the icon on the left straight into the list." = "نصيحة: اسحب الأيقونة على اليسار مباشرةً إلى القائمة."; +"Or drag the icon on the left straight into Settings." = "أو اسحب الأيقونة على اليسار مباشرةً إلى الإعدادات."; + +/* Dashboard storage composition (v2.7.0) */ +"Storage composition" = "تكوين مساحة التخزين"; +"total" = "الإجمالي"; +"Free" = "متاح"; + +/* UI polish (v2.8.0): leftover groups + sound toggle */ +"Application" = "التطبيق"; +"Caches" = "ذاكرة التخزين المؤقت"; +"Application Support" = "دعم التطبيقات"; +"Preferences" = "التفضيلات"; +"Logs" = "السجلات"; +"Containers" = "الحاويات"; +"Launch Agents" = "عناصر بدء التشغيل"; +"Other Files" = "ملفات أخرى"; +"Sound" = "الصوت"; +"Play sound effects" = "تشغيل المؤثرات الصوتية"; + +/* v2.8.1: purgeable honesty */ +"Managed by macOS" = "تُدار بواسطة macOS"; +"Reserved by macOS - freed automatically when space is needed" = "محجوزة بواسطة macOS - تُحرَّر تلقائيًا عند الحاجة إلى مساحة"; + +/* v2.8.2: large-file folder exclusions (#121) */ +"Excluded Folders" = "المجلدات المستثناة"; +"Files inside these folders are skipped from the Large & Old Files scan (Downloads, Documents, Desktop)." = "يتم تخطّي الملفات داخل هذه المجلدات في فحص الملفات الكبيرة والقديمة (التنزيلات، المستندات، سطح المكتب)."; +"Remove from exclusions" = "إزالة من المستثناة"; +"Add Folder…" = "إضافة مجلد…"; + +/* v2.8.2: orphan ignore list (#114) */ +"Always Ignore" = "تجاهل دائمًا"; +"Ignore Selected (%lld)" = "تجاهل المحدد (%lld)"; +"Ignored orphans: %lld" = "الملفات اليتيمة المتجاهَلة: %lld"; +"Forget Ignored" = "نسيان المتجاهَلة"; +"Ignored files won't appear in future orphan scans." = "لن تظهر الملفات المتجاهَلة في عمليات فحص الملفات اليتيمة المستقبلية."; + +/* v2.8.3: menu-bar system monitor */ +"System Monitor" = "مراقب النظام"; +"Show system monitor in menu bar" = "إظهار مراقب النظام في شريط القوائم"; +"Live CPU, memory, and disk meters in the menu bar. PureMac keeps running in the background while this is on." = "مقاييس مباشرة للمعالج والذاكرة والقرص في شريط القوائم. يستمر PureMac في العمل في الخلفية أثناء تفعيل هذا الخيار."; +"CPU" = "المعالج"; +"Memory" = "الذاكرة"; +"Disk" = "القرص"; +"Open PureMac" = "فتح PureMac"; +"Quit PureMac" = "إنهاء PureMac"; diff --git a/PureMac/en.lproj/Localizable.strings b/PureMac/en.lproj/Localizable.strings new file mode 100644 index 0000000..812ab62 --- /dev/null +++ b/PureMac/en.lproj/Localizable.strings @@ -0,0 +1,376 @@ +/* Sidebar (MainWindow) */ +"Overview" = "Overview"; +"Applications" = "Applications"; +"Cleanup" = "Cleanup"; +"Dashboard" = "Dashboard"; +"Installed Apps" = "Installed Apps"; +"Orphaned Files" = "Orphaned Files"; +"PureMac" = "PureMac"; +"Ready to clean" = "Ready to clean"; +"Limited access" = "Limited access"; +"Full Disk Access granted" = "Full Disk Access granted"; +"Grant FDA in Settings" = "Grant FDA in Settings"; +"Select a category from the sidebar to get started." = "Select a category from the sidebar to get started."; + +/* FDA toast + clean error alert (MainWindow) */ +"Couldn't clean everything" = "Couldn't clean everything"; +"Open System Settings" = "Open System Settings"; +"OK" = "OK"; +"Full Disk Access required" = "Full Disk Access required"; +"macOS blocks PureMac from cleaning caches and uninstalling apps until you grant access." = "macOS blocks PureMac from cleaning caches and uninstalling apps until you grant access."; +"Grant Access" = "Grant Access"; + +/* Permission Sheet */ +"Grant Full Disk Access" = "Grant Full Disk Access"; +"%lld item(s) need Full Disk Access" = "%lld item(s) need Full Disk Access"; +"Uninstalling %@: %lld file(s) need Full Disk Access" = "Uninstalling %@: %lld file(s) need Full Disk Access"; +"%lld item(s) need Full Disk Access to remove. Tap Grant Access to fix in one step." = "%lld item(s) need Full Disk Access to remove. Tap Grant Access to fix in one step."; +"Couldn't remove %@%@. They may be in use or protected by macOS." = "Couldn't remove %@%@. They may be in use or protected by macOS."; +" and %lld more" = " and %lld more"; +"Access granted. Retrying…" = "Access granted. Retrying…"; +"1-tap setup. We'll detect the change automatically." = "1-tap setup. We'll detect the change automatically."; +"Open Settings & reveal PureMac" = "Open Settings & reveal PureMac"; +"We'll open both windows side-by-side." = "We'll open both windows side-by-side."; +"Turn on PureMac" = "Turn on PureMac"; +"Toggle the row that appears." = "Toggle the row that appears."; +"Authenticate" = "Authenticate"; +"Touch ID or password." = "Touch ID or password."; +"We auto-retry — no need to come back." = "We auto-retry — no need to come back."; +"Watching for permission change…" = "Watching for permission change…"; +"PureMac not in the list?" = "PureMac not in the list?"; +"Hide help" = "Hide help"; +"Try this if PureMac doesn't appear:" = "Try this if PureMac doesn't appear:"; +"Reveal app" = "Reveal app"; +"Drag PureMac.app into the list" = "Drag PureMac.app into the list"; +"Reset + reprompt" = "Reset + reprompt"; +"Clear stale TCC entry" = "Clear stale TCC entry"; +"+ %lld more" = "+ %lld more"; +"%lld blocked path(s)" = "%lld blocked path(s)"; +"Access granted" = "Access granted"; +"Retrying the operation now." = "Retrying the operation now."; +"Skip for now" = "Skip for now"; +"I granted it — retry" = "I granted it — retry"; + +/* Dashboard polish */ +"Low space" = "Low space"; +"CLEANING" = "CLEANING"; +"Quick Setup" = "Quick Setup"; +"1-tap setup. We'll auto-retry what failed." = "1-tap setup. We'll auto-retry what failed."; +"Fix permission" = "Fix permission"; +"Dismiss" = "Dismiss"; + +/* Dashboard */ +"Storage" = "Storage"; +"Smart Scan" = "Smart Scan"; +"free of %@" = "free of %@"; +"%lld%% used" = "%lld%% used"; +"Used" = "Used"; +"Junk" = "Junk"; +"Purgeable" = "Purgeable"; +"USED" = "USED"; +"SCANNING" = "SCANNING"; +"Free Space" = "Free Space"; +"Junk Found" = "Junk Found"; +"Apps" = "Apps"; +"of %@ · %lld%% used" = "of %@ · %lld%% used"; +"Run a scan" = "Run a scan"; +"across %lld categories" = "across %lld categories"; +"installed" = "installed"; +"APFS reclaimable" = "APFS reclaimable"; +"Suggested for you" = "Suggested for you"; +"Found so far" = "Found so far"; +"By category" = "By category"; +"%@ is using %@" = "%@ is using %@"; +"Grant Full Disk Access for full results" = "Grant Full Disk Access for full results"; +"Without it, most caches and uninstall flows fail." = "Without it, most caches and uninstall flows fail."; +"Action" = "Action"; +"Scanning your Mac" = "Scanning your Mac"; +"Currently in: %@" = "Currently in: %@"; +"found" = "found"; +"Your Mac is clean" = "Your Mac is clean"; +"Scan Again" = "Scan Again"; +"Clean %@" = "Clean %@"; +"Clean %@?" = "Clean %@?"; +"Cleaning…" = "Cleaning…"; +"%lld%% complete" = "%lld%% complete"; +"freed" = "freed"; +"Done" = "Done"; +"%lld items" = "%lld items"; + +/* Common destructive dialog */ +"Clean" = "Clean"; +"Cancel" = "Cancel"; +"This will permanently delete the selected files. This cannot be undone." = "This will permanently delete the selected files. This cannot be undone."; + +/* Category Detail */ +"All Clean" = "All Clean"; +"No junk files found in this category." = "No junk files found in this category."; +"Not Scanned" = "Not Scanned"; +"Run a scan to analyze this category." = "Run a scan to analyze this category."; +"Scan Now" = "Scan Now"; +"Filter files" = "Filter files"; +"Scan" = "Scan"; +"Select All" = "Select All"; +"Deselect All" = "Deselect All"; +"Largest First" = "Largest First"; +"Smallest First" = "Smallest First"; +"Sorted: Largest First" = "Sorted: Largest First"; +"Sorted: Smallest First" = "Sorted: Smallest First"; +"Clean %lld items" = "Clean %lld items"; +"%lld items · %@" = "%lld items · %@"; +"Scanning…" = "Scanning…"; +"Rescan" = "Rescan"; +"%lld of %lld selected" = "%lld of %lld selected"; +"Reveal in Finder" = "Reveal in Finder"; +"Copy Path" = "Copy Path"; +"Move to Trash" = "Move to Trash"; + +/* Apps (AppListView) */ +"Search apps" = "Search apps"; +"Refresh" = "Refresh"; +"Installed Apps (%lld)" = "Installed Apps (%lld)"; +"Uninstall (%lld files)" = "Uninstall (%lld files)"; +"Loading installed apps..." = "Loading installed apps..."; +"No Apps Found" = "No Apps Found"; +"Could not find any installed applications." = "Could not find any installed applications."; +"Retry" = "Retry"; +"Application" = "Application"; +"Size" = "Size"; +"Select an App" = "Select an App"; +"Select an app from the list to see all its related files across your system." = "Select an app from the list to see all its related files across your system."; + +/* App Files (AppFilesView) */ +"%lld files" = "%lld files"; +"Scanning for related files..." = "Scanning for related files..."; +"Checking %lld locations..." = "Checking %lld locations..."; +"No Related Files" = "No Related Files"; +"No additional files found for %@." = "No additional files found for %@."; +"Remove %lld files (%@)" = "Remove %lld files (%@)"; +"Removal Failed" = "Removal Failed"; +"Remove this file" = "Remove this file"; +"Remove %@?" = "Remove %@?"; +"Remove" = "Remove"; +"This will permanently delete this file. This action cannot be undone." = "This will permanently delete this file. This action cannot be undone."; + +/* Orphans */ +"Scanning for orphaned files..." = "Scanning for orphaned files..."; +"No Orphaned Files" = "No Orphaned Files"; +"No leftover files from uninstalled apps were found." = "No leftover files from uninstalled apps were found."; +"Scan for Orphans" = "Scan for Orphans"; +"Orphaned Files (%lld)" = "Orphaned Files (%lld)"; +"Remove Selected (%lld)" = "Remove Selected (%lld)"; +"Some files could not be removed" = "Some files could not be removed"; + +/* Onboarding */ +"Back" = "Back"; +"Next" = "Next"; +"Get Started" = "Get Started"; +"Welcome to PureMac" = "Welcome to PureMac"; +"Free, open-source macOS app manager and system cleaner." = "Free, open-source macOS app manager and system cleaner."; +"Find junk files across your system" = "Find junk files across your system"; +"App Uninstaller" = "App Uninstaller"; +"Remove apps and all their files" = "Remove apps and all their files"; +"Orphan Finder" = "Orphan Finder"; +"Find leftovers from deleted apps" = "Find leftovers from deleted apps"; +"Full Disk Access" = "Full Disk Access"; +"PureMac needs Full Disk Access to uninstall apps, find leftover files, and clean protected caches." = "PureMac needs Full Disk Access to uninstall apps, find leftover files, and clean protected caches."; +"We'll guide you through the next steps." = "We'll guide you through the next steps."; +"In System Settings, do this:" = "In System Settings, do this:"; +"Privacy & Security → Full Disk Access" = "Privacy & Security → Full Disk Access"; +"Find **PureMac** and turn the toggle on" = "Find **PureMac** and turn the toggle on"; +"Authenticate with Touch ID or your password" = "Authenticate with Touch ID or your password"; +"Reopen Settings" = "Reopen Settings"; +"PureMac isn't in the list — reveal it" = "PureMac isn't in the list — reveal it"; +"Reset permissions and re-prompt" = "Reset permissions and re-prompt"; +"Hide diagnostics" = "Hide diagnostics"; +"Show diagnostics" = "Show diagnostics"; +"Trouble?" = "Trouble?"; +"Waiting for permission…" = "Waiting for permission…"; +"Permission granted." = "Permission granted."; +"PureMac can now manage protected files." = "PureMac can now manage protected files."; +"You're Ready" = "You're Ready"; +"%lld/%lld protected locations accessible" = "%lld/%lld protected locations accessible"; +"Some features will be limited. You can grant Full Disk Access later in System Settings." = "Some features will be limited. You can grant Full Disk Access later in System Settings."; +"Trash" = "Trash"; +"Mail Data" = "Mail Data"; +"Safari Data" = "Safari Data"; +"Desktop" = "Desktop"; +"Documents" = "Documents"; +"TCC Database" = "TCC Database"; +"Blocked" = "Blocked"; + +/* Settings */ +"General" = "General"; +"Cleaning" = "Cleaning"; +"Schedule" = "Schedule"; +"About" = "About"; +"Startup" = "Startup"; +"Launch PureMac at login" = "Launch PureMac at login"; +"App Scanning" = "App Scanning"; +"Search sensitivity" = "Search sensitivity"; +"Strict" = "Strict"; +"Enhanced" = "Enhanced"; +"Deep" = "Deep"; +"Exact bundle ID and name matches only. Safest option." = "Exact bundle ID and name matches only. Safest option."; +"Includes partial name matching and bundle ID components." = "Includes partial name matching and bundle ID components."; +"Includes company name, entitlements, and team identifier matching." = "Includes company name, entitlements, and team identifier matching."; +"Language" = "Language"; +"Restart PureMac to apply the selected language." = "Restart PureMac to apply the selected language."; +"Relaunch Now" = "Relaunch Now"; +"System Default" = "System Default"; +"English" = "English"; +"Spanish" = "Spanish"; +"Japanese" = "Japanese"; +"Arabic" = "Arabic"; +"Portuguese (Brazil)" = "Portuguese (Brazil)"; +"Chinese (Simplified)" = "Chinese (Simplified)"; +"Chinese (Traditional)" = "Chinese (Traditional)"; +"Safety" = "Safety"; +"Confirm before deleting files" = "Confirm before deleting files"; +"File Discovery" = "File Discovery"; +"Skip hidden files during scan" = "Skip hidden files during scan"; +"Large Files" = "Large Files"; +"Minimum size: %lld MB" = "Minimum size: %lld MB"; +"Files older than: %lld months" = "Files older than: %lld months"; +"Automatic Scanning" = "Automatic Scanning"; +"Enable scheduled scanning" = "Enable scheduled scanning"; +"Scan interval" = "Scan interval"; +"Auto-clean after scan" = "Auto-clean after scan"; +"Auto-purge purgeable space" = "Auto-purge purgeable space"; +"Notify on completion" = "Notify on completion"; +"Last run" = "Last run"; +"Version %@" = "Version %@"; +"Free, open-source macOS app manager." = "Free, open-source macOS app manager."; +"GitHub Repository" = "GitHub Repository"; +"Report an Issue" = "Report an Issue"; +"MIT License" = "MIT License"; + +/* Cleaning categories (CleaningCategory.rawValue) */ +"System Junk" = "System Junk"; +"User Cache" = "User Cache"; +"AI Apps" = "AI Apps"; +"Mail Files" = "Mail Files"; +"Trash Bins" = "Trash Bins"; +"Large & Old Files" = "Large & Old Files"; +"Purgeable Space" = "Purgeable Space"; +"Xcode Junk" = "Xcode Junk"; +"Brew Cache" = "Brew Cache"; +"Node Cache" = "Node Cache"; +"Docker Cache" = "Docker Cache"; + +/* Cleaning category descriptions */ +"Scan everything at once" = "Scan everything at once"; +"System caches, logs, and temporary files" = "System caches, logs, and temporary files"; +"Application caches and browser data" = "Application caches and browser data"; +"Local AI app logs, caches, and optional history" = "Local AI app logs, caches, and optional history"; +"Downloaded mail attachments" = "Downloaded mail attachments"; +"Files in your Trash" = "Files in your Trash"; +"Files over 100 MB or older than 1 year" = "Files over 100 MB or older than 1 year"; +"APFS purgeable disk space" = "APFS purgeable disk space"; +"Derived data, archives, and simulators" = "Derived data, archives, and simulators"; +"Homebrew download cache" = "Homebrew download cache"; +"npm, yarn, and pnpm download caches" = "npm, yarn, and pnpm download caches"; +"Docker images, containers, and build cache" = "Docker images, containers, and build cache"; + +/* Node Cache subcategories */ +"npm cache" = "npm cache"; +"yarn classic cache" = "yarn classic cache"; +"pnpm content-addressable store" = "pnpm content-addressable store"; + +/* Docker Cache helper */ +"Reclaimable (run `docker system prune -af`)" = "Reclaimable (run `docker system prune -af`)"; + +/* Schedule intervals (ScheduleInterval.rawValue) */ +"Every Hour" = "Every Hour"; +"Every 3 Hours" = "Every 3 Hours"; +"Every 6 Hours" = "Every 6 Hours"; +"Every 12 Hours" = "Every 12 Hours"; +"Daily" = "Daily"; +"Weekly" = "Weekly"; +"Every 2 Weeks" = "Every 2 Weeks"; +"Monthly" = "Monthly"; + +/* Schedule status */ +"Never" = "Never"; +"Not scheduled" = "Not scheduled"; + +/* Appearance modes (AppearanceMode.label) */ +"System" = "System"; +"Light" = "Light"; +"Dark" = "Dark"; + +/* Notifications */ +"Found %@ of junk files." = "Found %@ of junk files."; + +/* Onboarding v2 */ +"Reclaim your Mac" = "Reclaim your Mac"; +"Apple sells you small disks. We help you keep them clean." = "Apple sells you small disks. We help you keep them clean."; +"Free. Open source. MIT licensed." = "Free. Open source. MIT licensed."; +"What's inside" = "What's inside"; +"Three things, done well." = "Three things, done well."; +"Find caches, logs, broken installs, and the AI-app history hiding in your library." = "Find caches, logs, broken installs, and the AI-app history hiding in your library."; +"Drag an app, see every file it dropped, remove all of it. No leftovers." = "Drag an app, see every file it dropped, remove all of it. No leftovers."; +"Surfaces files that outlived the apps that created them." = "Surfaces files that outlived the apps that created them."; +"One permission, then we're done" = "One permission, then we're done"; +"Permission granted" = "Permission granted"; +"PureMac can now reach the locations macOS protects by default." = "PureMac can now reach the locations macOS protects by default."; +"macOS hides certain folders from every app until you say otherwise. We need them to find caches and uninstall cleanly." = "macOS hides certain folders from every app until you say otherwise. We need them to find caches and uninstall cleanly."; +"All set — moving you to the next step." = "All set — moving you to the next step."; +"Reveal app again" = "Reveal app again"; +"Reset permissions" = "Reset permissions"; +"You're ready" = "You're ready"; +"Ready when you are" = "Ready when you are"; +"Hit Start to run your first Smart Scan." = "Hit Start to run your first Smart Scan."; +"Skip" = "Skip"; +"Start" = "Start"; +"Continue" = "Continue"; + +/* Drag handle */ +"Drag to the Settings list" = "Drag to the Settings list"; +"Drag this icon into the Full Disk Access list in System Settings." = "Drag this icon into the Full Disk Access list in System Settings."; +"Tip: drag the icon on the left straight into the list." = "Tip: drag the icon on the left straight into the list."; +"Or drag the icon on the left straight into Settings." = "Or drag the icon on the left straight into Settings."; + +/* Dashboard storage composition (v2.7.0) */ +"Storage composition" = "Storage composition"; +"total" = "total"; +"Free" = "Free"; + +/* UI polish (v2.8.0): leftover groups + sound toggle */ +"Application" = "Application"; +"Caches" = "Caches"; +"Application Support" = "Application Support"; +"Preferences" = "Preferences"; +"Logs" = "Logs"; +"Containers" = "Containers"; +"Launch Agents" = "Launch Agents"; +"Other Files" = "Other Files"; +"Sound" = "Sound"; +"Play sound effects" = "Play sound effects"; + +/* v2.8.1: purgeable honesty */ +"Managed by macOS" = "Managed by macOS"; +"Reserved by macOS - freed automatically when space is needed" = "Reserved by macOS - freed automatically when space is needed"; + +/* v2.8.2: large-file folder exclusions (#121) */ +"Excluded Folders" = "Excluded Folders"; +"Files inside these folders are skipped from the Large & Old Files scan (Downloads, Documents, Desktop)." = "Files inside these folders are skipped from the Large & Old Files scan (Downloads, Documents, Desktop)."; +"Remove from exclusions" = "Remove from exclusions"; +"Add Folder…" = "Add Folder…"; + +/* v2.8.2: orphan ignore list (#114) */ +"Always Ignore" = "Always Ignore"; +"Ignore Selected (%lld)" = "Ignore Selected (%lld)"; +"Ignored orphans: %lld" = "Ignored orphans: %lld"; +"Forget Ignored" = "Forget Ignored"; +"Ignored files won't appear in future orphan scans." = "Ignored files won't appear in future orphan scans."; + +/* v2.8.3: menu-bar system monitor */ +"System Monitor" = "System Monitor"; +"Show system monitor in menu bar" = "Show system monitor in menu bar"; +"Live CPU, memory, and disk meters in the menu bar. PureMac keeps running in the background while this is on." = "Live CPU, memory, and disk meters in the menu bar. PureMac keeps running in the background while this is on."; +"CPU" = "CPU"; +"Memory" = "Memory"; +"Disk" = "Disk"; +"Open PureMac" = "Open PureMac"; +"Quit PureMac" = "Quit PureMac"; diff --git a/PureMac/es.lproj/Localizable.strings b/PureMac/es.lproj/Localizable.strings new file mode 100644 index 0000000..cadbf64 --- /dev/null +++ b/PureMac/es.lproj/Localizable.strings @@ -0,0 +1,376 @@ +/* Sidebar (MainWindow) */ +"Overview" = "Resumen"; +"Applications" = "Aplicaciones"; +"Cleanup" = "Limpieza"; +"Dashboard" = "Panel"; +"Installed Apps" = "Apps instaladas"; +"Orphaned Files" = "Archivos huérfanos"; +"PureMac" = "PureMac"; +"Ready to clean" = "Listo para limpiar"; +"Limited access" = "Acceso limitado"; +"Full Disk Access granted" = "Acceso total al disco concedido"; +"Grant FDA in Settings" = "Concede el acceso en Ajustes"; +"Select a category from the sidebar to get started." = "Selecciona una categoría en la barra lateral para empezar."; + +/* FDA toast + clean error alert (MainWindow) */ +"Couldn't clean everything" = "No se pudo limpiar todo"; +"Open System Settings" = "Abrir Ajustes del Sistema"; +"OK" = "OK"; +"Full Disk Access required" = "Se requiere acceso total al disco"; +"macOS blocks PureMac from cleaning caches and uninstalling apps until you grant access." = "macOS impide que PureMac limpie cachés y desinstale apps hasta que concedas el acceso."; +"Grant Access" = "Conceder acceso"; + +/* Dashboard */ +"Storage" = "Almacenamiento"; +"Smart Scan" = "Análisis inteligente"; +"free of %@" = "libres de %@"; +"%lld%% used" = "%lld%% en uso"; +"Used" = "En uso"; +"Junk" = "Basura"; +"Purgeable" = "Liberable"; +"USED" = "EN USO"; +"SCANNING" = "ANALIZANDO"; +"Free Space" = "Espacio libre"; +"Junk Found" = "Basura encontrada"; +"Apps" = "Apps"; +"of %@ · %lld%% used" = "de %@ · %lld%% en uso"; +"Run a scan" = "Realiza un análisis"; +"across %lld categories" = "en %lld categorías"; +"installed" = "instaladas"; +"APFS reclaimable" = "Recuperable por APFS"; +"Suggested for you" = "Sugerencias para ti"; +"Found so far" = "Encontrado hasta ahora"; +"By category" = "Por categoría"; +"%@ is using %@" = "%@ está usando %@"; +"Grant Full Disk Access for full results" = "Concede acceso total al disco para obtener resultados completos"; +"Without it, most caches and uninstall flows fail." = "Sin él, la mayoría de cachés y desinstalaciones fallan."; +"Action" = "Acción"; +"Scanning your Mac" = "Analizando tu Mac"; +"Currently in: %@" = "Actualmente en: %@"; +"found" = "encontrado"; +"Your Mac is clean" = "Tu Mac está limpio"; +"Scan Again" = "Volver a analizar"; +"Clean %@" = "Limpiar %@"; +"Clean %@?" = "¿Limpiar %@?"; +"Cleaning…" = "Limpiando…"; +"%lld%% complete" = "%lld%% completado"; +"freed" = "liberado"; +"Done" = "Listo"; +"%lld items" = "%lld elementos"; + +/* Common destructive dialog */ +"Clean" = "Limpiar"; +"Cancel" = "Cancelar"; +"This will permanently delete the selected files. This cannot be undone." = "Esto eliminará permanentemente los archivos seleccionados. No se puede deshacer."; + +/* Category Detail */ +"All Clean" = "Todo limpio"; +"No junk files found in this category." = "No se encontraron archivos basura en esta categoría."; +"Not Scanned" = "Sin analizar"; +"Run a scan to analyze this category." = "Realiza un análisis para evaluar esta categoría."; +"Scan Now" = "Analizar ahora"; +"Filter files" = "Filtrar archivos"; +"Scan" = "Analizar"; +"Select All" = "Seleccionar todo"; +"Deselect All" = "Deseleccionar todo"; +"Largest First" = "Más grandes primero"; +"Smallest First" = "Más pequeños primero"; +"Sorted: Largest First" = "Orden: más grandes primero"; +"Sorted: Smallest First" = "Orden: más pequeños primero"; +"Clean %lld items" = "Limpiar %lld elementos"; +"%lld items · %@" = "%lld elementos · %@"; +"Scanning…" = "Analizando…"; +"Rescan" = "Volver a analizar"; +"%lld of %lld selected" = "%lld de %lld seleccionados"; +"Reveal in Finder" = "Mostrar en el Finder"; +"Copy Path" = "Copiar ruta"; +"Move to Trash" = "Mover a la Papelera"; + +/* Apps (AppListView) */ +"Search apps" = "Buscar apps"; +"Refresh" = "Actualizar"; +"Installed Apps (%lld)" = "Apps instaladas (%lld)"; +"Uninstall (%lld files)" = "Desinstalar (%lld archivos)"; +"Loading installed apps..." = "Cargando apps instaladas..."; +"No Apps Found" = "No se encontraron apps"; +"Could not find any installed applications." = "No se pudo encontrar ninguna app instalada."; +"Retry" = "Reintentar"; +"Application" = "Aplicación"; +"Size" = "Tamaño"; +"Select an App" = "Selecciona una app"; +"Select an app from the list to see all its related files across your system." = "Selecciona una app de la lista para ver todos sus archivos relacionados en el sistema."; + +/* App Files (AppFilesView) */ +"%lld files" = "%lld archivos"; +"Scanning for related files..." = "Buscando archivos relacionados..."; +"Checking %lld locations..." = "Revisando %lld ubicaciones..."; +"No Related Files" = "Sin archivos relacionados"; +"No additional files found for %@." = "No se encontraron archivos adicionales para %@."; +"Remove %lld files (%@)" = "Eliminar %lld archivos (%@)"; +"Removal Failed" = "No se pudo eliminar"; +"Remove this file" = "Eliminar este archivo"; +"Remove %@?" = "¿Eliminar %@?"; +"Remove" = "Eliminar"; +"This will permanently delete this file. This action cannot be undone." = "Esto eliminará permanentemente este archivo. Esta acción no se puede deshacer."; + +/* Orphans */ +"Scanning for orphaned files..." = "Buscando archivos huérfanos..."; +"No Orphaned Files" = "Sin archivos huérfanos"; +"No leftover files from uninstalled apps were found." = "No se encontraron archivos sobrantes de apps desinstaladas."; +"Scan for Orphans" = "Buscar huérfanos"; +"Orphaned Files (%lld)" = "Archivos huérfanos (%lld)"; +"Remove Selected (%lld)" = "Eliminar seleccionados (%lld)"; +"Some files could not be removed" = "Algunos archivos no se pudieron eliminar"; + +/* Onboarding */ +"Back" = "Atrás"; +"Next" = "Siguiente"; +"Get Started" = "Empezar"; +"Welcome to PureMac" = "Te damos la bienvenida a PureMac"; +"Free, open-source macOS app manager and system cleaner." = "Administrador de apps y limpiador de sistema gratuito y de código abierto para macOS."; +"Find junk files across your system" = "Encuentra archivos basura en todo el sistema"; +"App Uninstaller" = "Desinstalador de apps"; +"Remove apps and all their files" = "Elimina apps y todos sus archivos"; +"Orphan Finder" = "Buscador de huérfanos"; +"Find leftovers from deleted apps" = "Encuentra restos de apps eliminadas"; +"Full Disk Access" = "Acceso total al disco"; +"PureMac needs Full Disk Access to uninstall apps, find leftover files, and clean protected caches." = "PureMac necesita acceso total al disco para desinstalar apps, encontrar archivos sobrantes y limpiar cachés protegidas."; +"We'll guide you through the next steps." = "Te guiaremos por los siguientes pasos."; +"In System Settings, do this:" = "En Ajustes del Sistema, haz lo siguiente:"; +"Privacy & Security → Full Disk Access" = "Privacidad y seguridad → Acceso total al disco"; +"Find **PureMac** and turn the toggle on" = "Encuentra **PureMac** y activa el interruptor"; +"Authenticate with Touch ID or your password" = "Autentícate con Touch ID o tu contraseña"; +"Reopen Settings" = "Reabrir Ajustes"; +"PureMac isn't in the list — reveal it" = "PureMac no aparece en la lista: mostrarlo"; +"Reset permissions and re-prompt" = "Restablecer permisos y volver a solicitar"; +"Hide diagnostics" = "Ocultar diagnóstico"; +"Show diagnostics" = "Mostrar diagnóstico"; +"Trouble?" = "¿Problemas?"; +"Waiting for permission…" = "Esperando permiso…"; +"Permission granted." = "Permiso concedido."; +"PureMac can now manage protected files." = "PureMac ahora puede gestionar archivos protegidos."; +"You're Ready" = "Todo listo"; +"%lld/%lld protected locations accessible" = "%lld/%lld ubicaciones protegidas accesibles"; +"Some features will be limited. You can grant Full Disk Access later in System Settings." = "Algunas funciones serán limitadas. Puedes conceder el acceso total al disco más tarde en Ajustes del Sistema."; +"Trash" = "Papelera"; +"Mail Data" = "Datos de Mail"; +"Safari Data" = "Datos de Safari"; +"Desktop" = "Escritorio"; +"Documents" = "Documentos"; +"TCC Database" = "Base de datos TCC"; +"Blocked" = "Bloqueado"; + +/* Settings */ +"General" = "General"; +"Cleaning" = "Limpieza"; +"Schedule" = "Programación"; +"About" = "Acerca de"; +"Startup" = "Inicio"; +"Launch PureMac at login" = "Abrir PureMac al iniciar sesión"; +"App Scanning" = "Análisis de apps"; +"Search sensitivity" = "Sensibilidad de búsqueda"; +"Strict" = "Estricta"; +"Enhanced" = "Mejorada"; +"Deep" = "Profunda"; +"Exact bundle ID and name matches only. Safest option." = "Solo coincidencias exactas de ID de paquete y nombre. La opción más segura."; +"Includes partial name matching and bundle ID components." = "Incluye coincidencias parciales de nombre y componentes del ID de paquete."; +"Includes company name, entitlements, and team identifier matching." = "Incluye coincidencias con nombre de empresa, autorizaciones e identificador de equipo."; +"Language" = "Idioma"; +"Restart PureMac to apply the selected language." = "Reinicia PureMac para aplicar el idioma seleccionado."; +"Relaunch Now" = "Reiniciar ahora"; +"System Default" = "Predeterminado del sistema"; +"English" = "Inglés"; +"Spanish" = "Español"; +"Japanese" = "Japonés"; +"Arabic" = "Árabe"; +"Portuguese (Brazil)" = "Portugués (Brasil)"; +"Chinese (Simplified)" = "Chino (simplificado)"; +"Chinese (Traditional)" = "Chino (tradicional)"; +"Safety" = "Seguridad"; +"Confirm before deleting files" = "Confirmar antes de eliminar archivos"; +"File Discovery" = "Detección de archivos"; +"Skip hidden files during scan" = "Omitir archivos ocultos al analizar"; +"Large Files" = "Archivos grandes"; +"Minimum size: %lld MB" = "Tamaño mínimo: %lld MB"; +"Files older than: %lld months" = "Archivos con más de: %lld meses"; +"Automatic Scanning" = "Análisis automático"; +"Enable scheduled scanning" = "Activar análisis programado"; +"Scan interval" = "Intervalo de análisis"; +"Auto-clean after scan" = "Limpiar automáticamente tras el análisis"; +"Auto-purge purgeable space" = "Purgar automáticamente el espacio liberable"; +"Notify on completion" = "Notificar al finalizar"; +"Last run" = "Última ejecución"; +"Version %@" = "Versión %@"; +"Free, open-source macOS app manager." = "Administrador de apps de macOS gratuito y de código abierto."; +"GitHub Repository" = "Repositorio de GitHub"; +"Report an Issue" = "Informar de un problema"; +"MIT License" = "Licencia MIT"; + +/* Cleaning categories (CleaningCategory.rawValue) */ +"System Junk" = "Basura del sistema"; +"User Cache" = "Caché de usuario"; +"AI Apps" = "Apps de IA"; +"Mail Files" = "Archivos de Mail"; +"Trash Bins" = "Papeleras"; +"Large & Old Files" = "Archivos grandes y antiguos"; +"Purgeable Space" = "Espacio liberable"; +"Xcode Junk" = "Basura de Xcode"; +"Brew Cache" = "Caché de Brew"; +"Node Cache" = "Caché de Node"; +"Docker Cache" = "Caché de Docker"; + +/* Cleaning category descriptions */ +"Scan everything at once" = "Analizar todo a la vez"; +"System caches, logs, and temporary files" = "Cachés del sistema, registros y archivos temporales"; +"Application caches and browser data" = "Cachés de aplicaciones y datos del navegador"; +"Local AI app logs, caches, and optional history" = "Registros, cachés e historial opcional de apps de IA locales"; +"Downloaded mail attachments" = "Adjuntos de correo descargados"; +"Files in your Trash" = "Archivos en la Papelera"; +"Files over 100 MB or older than 1 year" = "Archivos de más de 100 MB o con más de 1 año"; +"APFS purgeable disk space" = "Espacio liberable de disco APFS"; +"Derived data, archives, and simulators" = "Datos derivados, archivos y simuladores"; +"Homebrew download cache" = "Caché de descargas de Homebrew"; +"npm, yarn, and pnpm download caches" = "Cachés de descargas de npm, yarn y pnpm"; +"Docker images, containers, and build cache" = "Imágenes, contenedores y caché de compilación de Docker"; + +/* Node Cache subcategories */ +"npm cache" = "Caché de npm"; +"yarn classic cache" = "Caché clásica de yarn"; +"pnpm content-addressable store" = "Almacén direccionable de pnpm"; + +/* Docker Cache helper */ +"Reclaimable (run `docker system prune -af`)" = "Recuperable (ejecuta `docker system prune -af`)"; + +/* Schedule intervals (ScheduleInterval.rawValue) */ +"Every Hour" = "Cada hora"; +"Every 3 Hours" = "Cada 3 horas"; +"Every 6 Hours" = "Cada 6 horas"; +"Every 12 Hours" = "Cada 12 horas"; +"Daily" = "Diariamente"; +"Weekly" = "Semanalmente"; +"Every 2 Weeks" = "Cada 2 semanas"; +"Monthly" = "Mensualmente"; + +/* Schedule status */ +"Never" = "Nunca"; +"Not scheduled" = "Sin programar"; + +/* Appearance modes (AppearanceMode.label) */ +"System" = "Sistema"; +"Light" = "Claro"; +"Dark" = "Oscuro"; + +/* Notifications */ +"Found %@ of junk files." = "Se encontraron %@ de archivos basura."; + +/* Permission Sheet */ +"Grant Full Disk Access" = "Conceder acceso a todo el disco"; +"%lld item(s) need Full Disk Access" = "%lld elemento(s) necesitan acceso a todo el disco"; +"Uninstalling %@: %lld file(s) need Full Disk Access" = "Desinstalando %@: %lld archivo(s) necesitan acceso a todo el disco"; +"%lld item(s) need Full Disk Access to remove. Tap Grant Access to fix in one step." = "%lld elemento(s) requieren acceso a todo el disco. Toca Conceder acceso para resolverlo en un paso."; +"Couldn't remove %@%@. They may be in use or protected by macOS." = "No se pudo eliminar %@%@. Pueden estar en uso o protegidos por macOS."; +" and %lld more" = " y %lld más"; +"Access granted. Retrying…" = "Acceso concedido. Reintentando…"; +"1-tap setup. We'll detect the change automatically." = "Configuración en un toque. Detectaremos el cambio automáticamente."; +"Open Settings & reveal PureMac" = "Abrir Ajustes y mostrar PureMac"; +"We'll open both windows side-by-side." = "Abriremos ambas ventanas lado a lado."; +"Turn on PureMac" = "Activar PureMac"; +"Toggle the row that appears." = "Activa la fila que aparezca."; +"Authenticate" = "Autenticar"; +"Touch ID or password." = "Touch ID o contraseña."; +"We auto-retry — no need to come back." = "Reintentamos automáticamente, no necesitas volver."; +"Watching for permission change…" = "Esperando el cambio de permiso…"; +"PureMac not in the list?" = "¿PureMac no aparece en la lista?"; +"Hide help" = "Ocultar ayuda"; +"Try this if PureMac doesn't appear:" = "Prueba esto si PureMac no aparece:"; +"Reveal app" = "Mostrar app"; +"Drag PureMac.app into the list" = "Arrastra PureMac.app a la lista"; +"Reset + reprompt" = "Restablecer y volver a pedir"; +"Clear stale TCC entry" = "Borrar entrada antigua de TCC"; +"+ %lld more" = "+ %lld más"; +"%lld blocked path(s)" = "%lld ruta(s) bloqueada(s)"; +"Access granted" = "Acceso concedido"; +"Retrying the operation now." = "Reintentando la operación ahora."; +"Skip for now" = "Omitir por ahora"; +"I granted it — retry" = "Ya lo concedí - reintentar"; + +/* Dashboard polish */ +"Low space" = "Poco espacio"; +"CLEANING" = "LIMPIANDO"; +"Quick Setup" = "Configuración rápida"; +"1-tap setup. We'll auto-retry what failed." = "Configuración en un toque. Reintentaremos automáticamente lo que falle."; +"Fix permission" = "Arreglar permiso"; +"Dismiss" = "Descartar"; + +/* Onboarding v2 */ +"Reclaim your Mac" = "Recupera tu Mac"; +"Apple sells you small disks. We help you keep them clean." = "Apple te vende discos pequeños. Te ayudamos a mantenerlos limpios."; +"Free. Open source. MIT licensed." = "Gratis. Código abierto. Licencia MIT."; +"What's inside" = "Qué hay dentro"; +"Three things, done well." = "Tres cosas, bien hechas."; +"Find caches, logs, broken installs, and the AI-app history hiding in your library." = "Encuentra cachés, registros, instalaciones rotas y el historial de apps de IA escondido en tu biblioteca."; +"Drag an app, see every file it dropped, remove all of it. No leftovers." = "Arrastra una app, ve cada archivo que dejó, elimínalo todo. Sin sobras."; +"Surfaces files that outlived the apps that created them." = "Encuentra archivos que sobrevivieron a las apps que los crearon."; +"One permission, then we're done" = "Un permiso y listo"; +"Permission granted" = "Permiso concedido"; +"PureMac can now reach the locations macOS protects by default." = "PureMac ya puede acceder a las ubicaciones que macOS protege por defecto."; +"macOS hides certain folders from every app until you say otherwise. We need them to find caches and uninstall cleanly." = "macOS oculta ciertas carpetas hasta que tú lo decides. Las necesitamos para encontrar cachés y desinstalar limpiamente."; +"All set — moving you to the next step." = "Todo listo - pasando al siguiente paso."; +"Reveal app again" = "Mostrar app de nuevo"; +"Reset permissions" = "Restablecer permisos"; +"You're ready" = "Listo"; +"Ready when you are" = "Listos cuando lo estés"; +"Hit Start to run your first Smart Scan." = "Pulsa Empezar para tu primer Smart Scan."; +"Skip" = "Omitir"; +"Start" = "Empezar"; +"Continue" = "Continuar"; + +/* Drag handle */ +"Drag to the Settings list" = "Arrastra a la lista de Ajustes"; +"Drag this icon into the Full Disk Access list in System Settings." = "Arrastra este icono a la lista de Acceso a Todo el Disco en Ajustes del Sistema."; +"Tip: drag the icon on the left straight into the list." = "Consejo: arrastra el icono de la izquierda directamente a la lista."; +"Or drag the icon on the left straight into Settings." = "O arrastra el icono de la izquierda directamente a Ajustes."; + +/* Dashboard storage composition (v2.7.0) */ +"Storage composition" = "Composición del almacenamiento"; +"total" = "total"; +"Free" = "Libre"; + +/* UI polish (v2.8.0): leftover groups + sound toggle */ +"Application" = "Aplicación"; +"Caches" = "Cachés"; +"Application Support" = "Soporte de aplicaciones"; +"Preferences" = "Preferencias"; +"Logs" = "Registros"; +"Containers" = "Contenedores"; +"Launch Agents" = "Agentes de inicio"; +"Other Files" = "Otros archivos"; +"Sound" = "Sonido"; +"Play sound effects" = "Reproducir efectos de sonido"; + +/* v2.8.1: purgeable honesty */ +"Managed by macOS" = "Gestionado por macOS"; +"Reserved by macOS - freed automatically when space is needed" = "Reservado por macOS - se libera automáticamente cuando se necesita espacio"; + +/* v2.8.2: large-file folder exclusions (#121) */ +"Excluded Folders" = "Carpetas excluidas"; +"Files inside these folders are skipped from the Large & Old Files scan (Downloads, Documents, Desktop)." = "Los archivos dentro de estas carpetas se omiten en el análisis de archivos grandes y antiguos (Descargas, Documentos, Escritorio)."; +"Remove from exclusions" = "Quitar de las exclusiones"; +"Add Folder…" = "Añadir carpeta…"; + +/* v2.8.2: orphan ignore list (#114) */ +"Always Ignore" = "Ignorar siempre"; +"Ignore Selected (%lld)" = "Ignorar seleccionados (%lld)"; +"Ignored orphans: %lld" = "Huérfanos ignorados: %lld"; +"Forget Ignored" = "Olvidar ignorados"; +"Ignored files won't appear in future orphan scans." = "Los archivos ignorados no aparecerán en futuros análisis de huérfanos."; + +/* v2.8.3: menu-bar system monitor */ +"System Monitor" = "Monitor del sistema"; +"Show system monitor in menu bar" = "Mostrar el monitor del sistema en la barra de menús"; +"Live CPU, memory, and disk meters in the menu bar. PureMac keeps running in the background while this is on." = "Medidores de CPU, memoria y disco en vivo en la barra de menús. PureMac sigue ejecutándose en segundo plano mientras esto está activado."; +"CPU" = "CPU"; +"Memory" = "Memoria"; +"Disk" = "Disco"; +"Open PureMac" = "Abrir PureMac"; +"Quit PureMac" = "Salir de PureMac"; diff --git a/PureMac/ja.lproj/Localizable.strings b/PureMac/ja.lproj/Localizable.strings new file mode 100644 index 0000000..a26b5fc --- /dev/null +++ b/PureMac/ja.lproj/Localizable.strings @@ -0,0 +1,376 @@ +/* Sidebar (MainWindow) */ +"Overview" = "概要"; +"Applications" = "アプリケーション"; +"Cleanup" = "クリーンアップ"; +"Dashboard" = "ダッシュボード"; +"Installed Apps" = "インストール済みアプリ"; +"Orphaned Files" = "孤立ファイル"; +"PureMac" = "PureMac"; +"Ready to clean" = "クリーンアップ可能"; +"Limited access" = "アクセスが制限されています"; +"Full Disk Access granted" = "フルディスクアクセスが許可されています"; +"Grant FDA in Settings" = "設定でフルディスクアクセスを許可"; +"Select a category from the sidebar to get started." = "サイドバーからカテゴリを選択して始めてください。"; + +/* FDA toast + clean error alert (MainWindow) */ +"Couldn't clean everything" = "すべてを削除できませんでした"; +"Open System Settings" = "システム設定を開く"; +"OK" = "OK"; +"Full Disk Access required" = "フルディスクアクセスが必要です"; +"macOS blocks PureMac from cleaning caches and uninstalling apps until you grant access." = "アクセスを許可するまで、macOSはPureMacによるキャッシュの削除やアプリのアンインストールをブロックします。"; +"Grant Access" = "アクセスを許可"; + +/* Dashboard */ +"Storage" = "ストレージ"; +"Smart Scan" = "スマートスキャン"; +"free of %@" = "%@中の空き容量"; +"%lld%% used" = "%lld%% 使用中"; +"Used" = "使用中"; +"Junk" = "ジャンク"; +"Purgeable" = "削除可能"; +"USED" = "使用中"; +"SCANNING" = "スキャン中"; +"Free Space" = "空き容量"; +"Junk Found" = "見つかったジャンク"; +"Apps" = "アプリ"; +"of %@ · %lld%% used" = "%@中 · %lld%% 使用中"; +"Run a scan" = "スキャンを実行"; +"across %lld categories" = "%lld カテゴリ"; +"installed" = "インストール済み"; +"APFS reclaimable" = "APFSで再利用可能"; +"Suggested for you" = "あなたへのおすすめ"; +"Found so far" = "これまでに見つかったもの"; +"By category" = "カテゴリ別"; +"%@ is using %@" = "%@ は %@ を使用しています"; +"Grant Full Disk Access for full results" = "完全な結果を得るためにフルディスクアクセスを許可してください"; +"Without it, most caches and uninstall flows fail." = "許可しないと、ほとんどのキャッシュ削除やアンインストールに失敗します。"; +"Action" = "アクション"; +"Scanning your Mac" = "Macをスキャン中"; +"Currently in: %@" = "現在: %@"; +"found" = "見つかりました"; +"Your Mac is clean" = "Macはクリーンです"; +"Scan Again" = "再スキャン"; +"Clean %@" = "%@ をクリーンアップ"; +"Clean %@?" = "%@ をクリーンアップしますか?"; +"Cleaning…" = "クリーンアップ中…"; +"%lld%% complete" = "%lld%% 完了"; +"freed" = "解放"; +"Done" = "完了"; +"%lld items" = "%lld 個"; + +/* Common destructive dialog */ +"Clean" = "クリーンアップ"; +"Cancel" = "キャンセル"; +"This will permanently delete the selected files. This cannot be undone." = "選択したファイルが完全に削除されます。この操作は取り消せません。"; + +/* Category Detail */ +"All Clean" = "すべてクリーン"; +"No junk files found in this category." = "このカテゴリにはジャンクファイルが見つかりませんでした。"; +"Not Scanned" = "未スキャン"; +"Run a scan to analyze this category." = "スキャンを実行してこのカテゴリを分析します。"; +"Scan Now" = "今すぐスキャン"; +"Filter files" = "ファイルを絞り込む"; +"Scan" = "スキャン"; +"Select All" = "すべて選択"; +"Deselect All" = "選択解除"; +"Largest First" = "大きい順"; +"Smallest First" = "小さい順"; +"Sorted: Largest First" = "並び順: 大きい順"; +"Sorted: Smallest First" = "並び順: 小さい順"; +"Clean %lld items" = "%lld 項目をクリーンアップ"; +"%lld items · %@" = "%lld 項目 · %@"; +"Scanning…" = "スキャン中…"; +"Rescan" = "再スキャン"; +"%lld of %lld selected" = "%lld / %lld 個を選択中"; +"Reveal in Finder" = "Finderで表示"; +"Copy Path" = "パスをコピー"; +"Move to Trash" = "ゴミ箱に移動"; + +/* Apps (AppListView) */ +"Search apps" = "アプリを検索"; +"Refresh" = "更新"; +"Installed Apps (%lld)" = "インストール済みアプリ (%lld)"; +"Uninstall (%lld files)" = "アンインストール (%lld ファイル)"; +"Loading installed apps..." = "インストール済みアプリを読み込み中..."; +"No Apps Found" = "アプリが見つかりません"; +"Could not find any installed applications." = "インストール済みアプリケーションが見つかりませんでした。"; +"Retry" = "再試行"; +"Application" = "アプリケーション"; +"Size" = "サイズ"; +"Select an App" = "アプリを選択"; +"Select an app from the list to see all its related files across your system." = "リストからアプリを選択すると、システム全体の関連ファイルが表示されます。"; + +/* App Files (AppFilesView) */ +"%lld files" = "%lld ファイル"; +"Scanning for related files..." = "関連ファイルをスキャン中..."; +"Checking %lld locations..." = "%lld 個の場所を確認中..."; +"No Related Files" = "関連ファイルがありません"; +"No additional files found for %@." = "%@ の追加ファイルは見つかりませんでした。"; +"Remove %lld files (%@)" = "%lld ファイルを削除 (%@)"; +"Removal Failed" = "削除に失敗しました"; +"Remove this file" = "このファイルを削除"; +"Remove %@?" = "%@ を削除しますか?"; +"Remove" = "削除"; +"This will permanently delete this file. This action cannot be undone." = "このファイルが完全に削除されます。この操作は取り消せません。"; + +/* Orphans */ +"Scanning for orphaned files..." = "孤立ファイルをスキャン中..."; +"No Orphaned Files" = "孤立ファイルはありません"; +"No leftover files from uninstalled apps were found." = "アンインストール済みアプリの残存ファイルは見つかりませんでした。"; +"Scan for Orphans" = "孤立ファイルをスキャン"; +"Orphaned Files (%lld)" = "孤立ファイル (%lld)"; +"Remove Selected (%lld)" = "選択した項目を削除 (%lld)"; +"Some files could not be removed" = "一部のファイルを削除できませんでした"; + +/* Onboarding */ +"Back" = "戻る"; +"Next" = "次へ"; +"Get Started" = "始める"; +"Welcome to PureMac" = "PureMacへようこそ"; +"Free, open-source macOS app manager and system cleaner." = "無料・オープンソースのmacOSアプリ管理&システムクリーナー。"; +"Find junk files across your system" = "システム全体のジャンクファイルを検出"; +"App Uninstaller" = "アプリアンインストーラ"; +"Remove apps and all their files" = "アプリと関連ファイルをすべて削除"; +"Orphan Finder" = "孤立ファイル検出"; +"Find leftovers from deleted apps" = "削除済みアプリの残存物を検出"; +"Full Disk Access" = "フルディスクアクセス"; +"PureMac needs Full Disk Access to uninstall apps, find leftover files, and clean protected caches." = "アプリのアンインストール、残存ファイルの検出、保護されたキャッシュのクリーンアップにはフルディスクアクセスが必要です。"; +"We'll guide you through the next steps." = "次のステップへご案内します。"; +"In System Settings, do this:" = "システム設定で次の操作を行ってください:"; +"Privacy & Security → Full Disk Access" = "プライバシーとセキュリティ → フルディスクアクセス"; +"Find **PureMac** and turn the toggle on" = "**PureMac** を見つけてスイッチをオンにする"; +"Authenticate with Touch ID or your password" = "Touch IDまたはパスワードで認証"; +"Reopen Settings" = "設定を再度開く"; +"PureMac isn't in the list — reveal it" = "PureMacがリストにない — 表示する"; +"Reset permissions and re-prompt" = "アクセス権をリセットして再度求める"; +"Hide diagnostics" = "診断情報を隠す"; +"Show diagnostics" = "診断情報を表示"; +"Trouble?" = "問題が発生しましたか?"; +"Waiting for permission…" = "許可を待っています…"; +"Permission granted." = "アクセスが許可されました。"; +"PureMac can now manage protected files." = "PureMacは保護されたファイルを管理できるようになりました。"; +"You're Ready" = "準備完了"; +"%lld/%lld protected locations accessible" = "%lld/%lld の保護された場所にアクセス可能"; +"Some features will be limited. You can grant Full Disk Access later in System Settings." = "一部の機能は制限されます。フルディスクアクセスは後でシステム設定から許可できます。"; +"Trash" = "ゴミ箱"; +"Mail Data" = "メールデータ"; +"Safari Data" = "Safariデータ"; +"Desktop" = "デスクトップ"; +"Documents" = "書類"; +"TCC Database" = "TCCデータベース"; +"Blocked" = "ブロック"; + +/* Settings */ +"General" = "一般"; +"Cleaning" = "クリーニング"; +"Schedule" = "スケジュール"; +"About" = "PureMacについて"; +"Startup" = "起動"; +"Launch PureMac at login" = "ログイン時にPureMacを起動"; +"App Scanning" = "アプリスキャン"; +"Search sensitivity" = "検索の精度"; +"Strict" = "厳格"; +"Enhanced" = "拡張"; +"Deep" = "詳細"; +"Exact bundle ID and name matches only. Safest option." = "バンドルIDと名前が完全一致するもののみ。最も安全。"; +"Includes partial name matching and bundle ID components." = "部分的な名前一致やバンドルID要素も含めます。"; +"Includes company name, entitlements, and team identifier matching." = "会社名、エンタイトルメント、チームIDの一致を含めます。"; +"Language" = "言語"; +"Restart PureMac to apply the selected language." = "選択した言語を適用するにはPureMacを再起動してください。"; +"Relaunch Now" = "今すぐ再起動"; +"System Default" = "システムデフォルト"; +"English" = "英語"; +"Spanish" = "スペイン語"; +"Japanese" = "日本語"; +"Arabic" = "アラビア語"; +"Portuguese (Brazil)" = "ポルトガル語(ブラジル)"; +"Chinese (Simplified)" = "中国語(簡体字)"; +"Chinese (Traditional)" = "中国語(繁体字)"; +"Safety" = "安全"; +"Confirm before deleting files" = "ファイル削除前に確認"; +"File Discovery" = "ファイル検出"; +"Skip hidden files during scan" = "スキャン時に隠しファイルをスキップ"; +"Large Files" = "大きなファイル"; +"Minimum size: %lld MB" = "最小サイズ: %lld MB"; +"Files older than: %lld months" = "%lld か月以上前のファイル"; +"Automatic Scanning" = "自動スキャン"; +"Enable scheduled scanning" = "スケジュールスキャンを有効化"; +"Scan interval" = "スキャン間隔"; +"Auto-clean after scan" = "スキャン後に自動クリーンアップ"; +"Auto-purge purgeable space" = "削除可能領域を自動パージ"; +"Notify on completion" = "完了時に通知"; +"Last run" = "前回実行"; +"Version %@" = "バージョン %@"; +"Free, open-source macOS app manager." = "無料・オープンソースのmacOSアプリ管理ツール。"; +"GitHub Repository" = "GitHubリポジトリ"; +"Report an Issue" = "問題を報告"; +"MIT License" = "MITライセンス"; + +/* Cleaning categories (CleaningCategory.rawValue) */ +"System Junk" = "システムジャンク"; +"User Cache" = "ユーザーキャッシュ"; +"AI Apps" = "AIアプリ"; +"Mail Files" = "メールファイル"; +"Trash Bins" = "ゴミ箱"; +"Large & Old Files" = "大きい・古いファイル"; +"Purgeable Space" = "削除可能領域"; +"Xcode Junk" = "Xcodeジャンク"; +"Brew Cache" = "Brewキャッシュ"; +"Node Cache" = "Nodeキャッシュ"; +"Docker Cache" = "Dockerキャッシュ"; + +/* Cleaning category descriptions */ +"Scan everything at once" = "すべてを一度にスキャン"; +"System caches, logs, and temporary files" = "システムキャッシュ、ログ、一時ファイル"; +"Application caches and browser data" = "アプリケーションキャッシュとブラウザデータ"; +"Local AI app logs, caches, and optional history" = "ローカルAIアプリのログ、キャッシュ、任意の履歴"; +"Downloaded mail attachments" = "ダウンロードしたメール添付ファイル"; +"Files in your Trash" = "ゴミ箱内のファイル"; +"Files over 100 MB or older than 1 year" = "100MB以上または1年以上前のファイル"; +"APFS purgeable disk space" = "APFSの削除可能ディスク領域"; +"Derived data, archives, and simulators" = "派生データ、アーカイブ、シミュレータ"; +"Homebrew download cache" = "Homebrewのダウンロードキャッシュ"; +"npm, yarn, and pnpm download caches" = "npm、yarn、pnpm のダウンロードキャッシュ"; +"Docker images, containers, and build cache" = "Dockerイメージ、コンテナ、ビルドキャッシュ"; + +/* Node Cache subcategories */ +"npm cache" = "npmキャッシュ"; +"yarn classic cache" = "yarnクラシックキャッシュ"; +"pnpm content-addressable store" = "pnpmコンテンツアドレッサブルストア"; + +/* Docker Cache helper */ +"Reclaimable (run `docker system prune -af`)" = "再利用可能 ( `docker system prune -af` を実行)"; + +/* Schedule intervals (ScheduleInterval.rawValue) */ +"Every Hour" = "1時間ごと"; +"Every 3 Hours" = "3時間ごと"; +"Every 6 Hours" = "6時間ごと"; +"Every 12 Hours" = "12時間ごと"; +"Daily" = "毎日"; +"Weekly" = "毎週"; +"Every 2 Weeks" = "2週間ごと"; +"Monthly" = "毎月"; + +/* Schedule status */ +"Never" = "なし"; +"Not scheduled" = "未スケジュール"; + +/* Appearance modes (AppearanceMode.label) */ +"System" = "システム"; +"Light" = "ライト"; +"Dark" = "ダーク"; + +/* Notifications */ +"Found %@ of junk files." = "%@ のジャンクファイルが見つかりました。"; + +/* Permission Sheet */ +"Grant Full Disk Access" = "フルディスクアクセスを付与"; +"%lld item(s) need Full Disk Access" = "%lld 件にフルディスクアクセスが必要です"; +"Uninstalling %@: %lld file(s) need Full Disk Access" = "%@ のアンインストール: %lld 個のファイルにフルディスクアクセスが必要です"; +"%lld item(s) need Full Disk Access to remove. Tap Grant Access to fix in one step." = "%lld 件の削除にはフルディスクアクセスが必要です。「アクセスを許可」をタップすると一手で解決します。"; +"Couldn't remove %@%@. They may be in use or protected by macOS." = "%@%@ を削除できませんでした。使用中か macOS によって保護されている可能性があります。"; +" and %lld more" = " 他 %lld 件"; +"Access granted. Retrying…" = "アクセスが許可されました。再試行中…"; +"1-tap setup. We'll detect the change automatically." = "ワンタップ設定。変更は自動的に検出されます。"; +"Open Settings & reveal PureMac" = "設定を開き PureMac を表示"; +"We'll open both windows side-by-side." = "両方のウインドウを並べて開きます。"; +"Turn on PureMac" = "PureMac をオンにする"; +"Toggle the row that appears." = "表示された行をオンに切り替えてください。"; +"Authenticate" = "認証"; +"Touch ID or password." = "Touch ID またはパスワード。"; +"We auto-retry — no need to come back." = "自動的に再試行します - 戻る必要はありません。"; +"Watching for permission change…" = "権限の変更を待機中…"; +"PureMac not in the list?" = "PureMac がリストに表示されない?"; +"Hide help" = "ヘルプを隠す"; +"Try this if PureMac doesn't appear:" = "PureMac が表示されない場合はこれを試してください:"; +"Reveal app" = "アプリを表示"; +"Drag PureMac.app into the list" = "PureMac.app をリストにドラッグ"; +"Reset + reprompt" = "リセットして再表示"; +"Clear stale TCC entry" = "古い TCC エントリを削除"; +"+ %lld more" = "+ %lld 件"; +"%lld blocked path(s)" = "%lld 件のブロックされたパス"; +"Access granted" = "アクセス許可済み"; +"Retrying the operation now." = "操作を再試行しています。"; +"Skip for now" = "今はスキップ"; +"I granted it — retry" = "許可したので再試行"; + +/* Dashboard polish */ +"Low space" = "空き容量不足"; +"CLEANING" = "クリーニング中"; +"Quick Setup" = "クイックセットアップ"; +"1-tap setup. We'll auto-retry what failed." = "ワンタップ設定。失敗した処理は自動的に再試行します。"; +"Fix permission" = "権限を修正"; +"Dismiss" = "閉じる"; + +/* Onboarding v2 */ +"Reclaim your Mac" = "Mac を取り戻そう"; +"Apple sells you small disks. We help you keep them clean." = "Apple のストレージは小さい。だからこそ、きれいに保ちます。"; +"Free. Open source. MIT licensed." = "無料。オープンソース。MIT ライセンス。"; +"What's inside" = "中身を見る"; +"Three things, done well." = "3 つのことを、しっかりと。"; +"Find caches, logs, broken installs, and the AI-app history hiding in your library." = "キャッシュ、ログ、壊れたインストール、ライブラリに潜む AI アプリの履歴を見つけます。"; +"Drag an app, see every file it dropped, remove all of it. No leftovers." = "アプリをドラッグして、残したすべてのファイルを確認し、まとめて削除。残骸ゼロ。"; +"Surfaces files that outlived the apps that created them." = "アプリを削除した後も残ったファイルを表示します。"; +"One permission, then we're done" = "1 つの権限、それだけ"; +"Permission granted" = "権限を付与しました"; +"PureMac can now reach the locations macOS protects by default." = "PureMac は macOS が標準で保護している場所にアクセスできるようになりました。"; +"macOS hides certain folders from every app until you say otherwise. We need them to find caches and uninstall cleanly." = "macOS は許可するまですべてのアプリから一部のフォルダを隠します。キャッシュの発見とクリーンなアンインストールに必要です。"; +"All set — moving you to the next step." = "準備完了 - 次のステップへ進みます。"; +"Reveal app again" = "アプリをもう一度表示"; +"Reset permissions" = "権限をリセット"; +"You're ready" = "準備完了"; +"Ready when you are" = "いつでもどうぞ"; +"Hit Start to run your first Smart Scan." = "「開始」を押して最初のスマートスキャンを実行。"; +"Skip" = "スキップ"; +"Start" = "開始"; +"Continue" = "続ける"; + +/* Drag handle */ +"Drag to the Settings list" = "設定のリストにドラッグ"; +"Drag this icon into the Full Disk Access list in System Settings." = "このアイコンをシステム設定のフルディスクアクセスのリストにドラッグしてください。"; +"Tip: drag the icon on the left straight into the list." = "ヒント: 左のアイコンをそのままリストにドラッグできます。"; +"Or drag the icon on the left straight into Settings." = "または左のアイコンをそのまま設定にドラッグしてください。"; + +/* Dashboard storage composition (v2.7.0) */ +"Storage composition" = "ストレージの内訳"; +"total" = "合計"; +"Free" = "空き"; + +/* UI polish (v2.8.0): leftover groups + sound toggle */ +"Application" = "アプリケーション"; +"Caches" = "キャッシュ"; +"Application Support" = "アプリケーションサポート"; +"Preferences" = "環境設定"; +"Logs" = "ログ"; +"Containers" = "コンテナ"; +"Launch Agents" = "起動エージェント"; +"Other Files" = "その他のファイル"; +"Sound" = "サウンド"; +"Play sound effects" = "効果音を再生"; + +/* v2.8.1: purgeable honesty */ +"Managed by macOS" = "macOS が管理"; +"Reserved by macOS - freed automatically when space is needed" = "macOS が予約 - 空き容量が必要になると自動的に解放されます"; + +/* v2.8.2: large-file folder exclusions (#121) */ +"Excluded Folders" = "除外するフォルダ"; +"Files inside these folders are skipped from the Large & Old Files scan (Downloads, Documents, Desktop)." = "これらのフォルダ内のファイルは「大きい/古いファイル」スキャン(ダウンロード、書類、デスクトップ)でスキップされます。"; +"Remove from exclusions" = "除外から削除"; +"Add Folder…" = "フォルダを追加…"; + +/* v2.8.2: orphan ignore list (#114) */ +"Always Ignore" = "常に無視"; +"Ignore Selected (%lld)" = "選択した項目を無視 (%lld)"; +"Ignored orphans: %lld" = "無視した孤立ファイル: %lld"; +"Forget Ignored" = "無視リストを消去"; +"Ignored files won't appear in future orphan scans." = "無視したファイルは今後の孤立ファイルのスキャンに表示されません。"; + +/* v2.8.3: menu-bar system monitor */ +"System Monitor" = "システムモニタ"; +"Show system monitor in menu bar" = "メニューバーにシステムモニタを表示"; +"Live CPU, memory, and disk meters in the menu bar. PureMac keeps running in the background while this is on." = "CPU、メモリ、ディスクの使用状況をメニューバーにリアルタイム表示します。オンの間、PureMacはバックグラウンドで動作し続けます。"; +"CPU" = "CPU"; +"Memory" = "メモリ"; +"Disk" = "ディスク"; +"Open PureMac" = "PureMacを開く"; +"Quit PureMac" = "PureMacを終了"; diff --git a/PureMac/pt-BR.lproj/Localizable.strings b/PureMac/pt-BR.lproj/Localizable.strings new file mode 100644 index 0000000..7e1f6c0 --- /dev/null +++ b/PureMac/pt-BR.lproj/Localizable.strings @@ -0,0 +1,376 @@ +/* Sidebar (MainWindow) */ +"Overview" = "Visão geral"; +"Applications" = "Aplicativos"; +"Cleanup" = "Limpeza"; +"Dashboard" = "Painel"; +"Installed Apps" = "Apps instalados"; +"Orphaned Files" = "Arquivos órfãos"; +"PureMac" = "PureMac"; +"Ready to clean" = "Pronto para limpar"; +"Limited access" = "Acesso limitado"; +"Full Disk Access granted" = "Acesso total ao disco concedido"; +"Grant FDA in Settings" = "Conceda o acesso em Ajustes"; +"Select a category from the sidebar to get started." = "Selecione uma categoria na barra lateral para começar."; + +/* FDA toast + clean error alert (MainWindow) */ +"Couldn't clean everything" = "Não foi possível limpar tudo"; +"Open System Settings" = "Abrir Ajustes do Sistema"; +"OK" = "OK"; +"Full Disk Access required" = "Acesso total ao disco necessário"; +"macOS blocks PureMac from cleaning caches and uninstalling apps until you grant access." = "O macOS impede que o PureMac limpe caches e desinstale apps até que você conceda o acesso."; +"Grant Access" = "Conceder acesso"; + +/* Dashboard */ +"Storage" = "Armazenamento"; +"Smart Scan" = "Verificação inteligente"; +"free of %@" = "livres de %@"; +"%lld%% used" = "%lld%% em uso"; +"Used" = "Em uso"; +"Junk" = "Lixo"; +"Purgeable" = "Liberável"; +"USED" = "EM USO"; +"SCANNING" = "VERIFICANDO"; +"Free Space" = "Espaço livre"; +"Junk Found" = "Lixo encontrado"; +"Apps" = "Apps"; +"of %@ · %lld%% used" = "de %@ · %lld%% em uso"; +"Run a scan" = "Faça uma verificação"; +"across %lld categories" = "em %lld categorias"; +"installed" = "instalados"; +"APFS reclaimable" = "Recuperável pelo APFS"; +"Suggested for you" = "Sugestões para você"; +"Found so far" = "Encontrado até agora"; +"By category" = "Por categoria"; +"%@ is using %@" = "%@ está usando %@"; +"Grant Full Disk Access for full results" = "Conceda acesso total ao disco para ver resultados completos"; +"Without it, most caches and uninstall flows fail." = "Sem ele, a maioria dos caches e desinstalações falha."; +"Action" = "Ação"; +"Scanning your Mac" = "Verificando seu Mac"; +"Currently in: %@" = "No momento: %@"; +"found" = "encontrado"; +"Your Mac is clean" = "Seu Mac está limpo"; +"Scan Again" = "Verificar novamente"; +"Clean %@" = "Limpar %@"; +"Clean %@?" = "Limpar %@?"; +"Cleaning…" = "Limpando…"; +"%lld%% complete" = "%lld%% concluído"; +"freed" = "liberado"; +"Done" = "Concluído"; +"%lld items" = "%lld itens"; + +/* Common destructive dialog */ +"Clean" = "Limpar"; +"Cancel" = "Cancelar"; +"This will permanently delete the selected files. This cannot be undone." = "Isso apagará permanentemente os arquivos selecionados. Esta ação não pode ser desfeita."; + +/* Category Detail */ +"All Clean" = "Tudo limpo"; +"No junk files found in this category." = "Nenhum arquivo de lixo encontrado nesta categoria."; +"Not Scanned" = "Não verificado"; +"Run a scan to analyze this category." = "Faça uma verificação para analisar esta categoria."; +"Scan Now" = "Verificar agora"; +"Filter files" = "Filtrar arquivos"; +"Scan" = "Verificar"; +"Select All" = "Selecionar tudo"; +"Deselect All" = "Desmarcar tudo"; +"Largest First" = "Maiores primeiro"; +"Smallest First" = "Menores primeiro"; +"Sorted: Largest First" = "Ordem: maiores primeiro"; +"Sorted: Smallest First" = "Ordem: menores primeiro"; +"Clean %lld items" = "Limpar %lld itens"; +"%lld items · %@" = "%lld itens · %@"; +"Scanning…" = "Verificando…"; +"Rescan" = "Verificar novamente"; +"%lld of %lld selected" = "%lld de %lld selecionados"; +"Reveal in Finder" = "Mostrar no Finder"; +"Copy Path" = "Copiar caminho"; +"Move to Trash" = "Mover para o Lixo"; + +/* Apps (AppListView) */ +"Search apps" = "Buscar apps"; +"Refresh" = "Atualizar"; +"Installed Apps (%lld)" = "Apps instalados (%lld)"; +"Uninstall (%lld files)" = "Desinstalar (%lld arquivos)"; +"Loading installed apps..." = "Carregando apps instalados..."; +"No Apps Found" = "Nenhum app encontrado"; +"Could not find any installed applications." = "Não foi possível encontrar nenhum aplicativo instalado."; +"Retry" = "Tentar novamente"; +"Application" = "Aplicativo"; +"Size" = "Tamanho"; +"Select an App" = "Selecione um app"; +"Select an app from the list to see all its related files across your system." = "Selecione um app da lista para ver todos os arquivos relacionados no sistema."; + +/* App Files (AppFilesView) */ +"%lld files" = "%lld arquivos"; +"Scanning for related files..." = "Buscando arquivos relacionados..."; +"Checking %lld locations..." = "Verificando %lld locais..."; +"No Related Files" = "Sem arquivos relacionados"; +"No additional files found for %@." = "Nenhum arquivo adicional encontrado para %@."; +"Remove %lld files (%@)" = "Remover %lld arquivos (%@)"; +"Removal Failed" = "Falha na remoção"; +"Remove this file" = "Remover este arquivo"; +"Remove %@?" = "Remover %@?"; +"Remove" = "Remover"; +"This will permanently delete this file. This action cannot be undone." = "Isso apagará permanentemente este arquivo. Esta ação não pode ser desfeita."; + +/* Orphans */ +"Scanning for orphaned files..." = "Buscando arquivos órfãos..."; +"No Orphaned Files" = "Sem arquivos órfãos"; +"No leftover files from uninstalled apps were found." = "Nenhum arquivo restante de apps desinstalados foi encontrado."; +"Scan for Orphans" = "Buscar órfãos"; +"Orphaned Files (%lld)" = "Arquivos órfãos (%lld)"; +"Remove Selected (%lld)" = "Remover selecionados (%lld)"; +"Some files could not be removed" = "Alguns arquivos não puderam ser removidos"; + +/* Onboarding */ +"Back" = "Voltar"; +"Next" = "Próximo"; +"Get Started" = "Começar"; +"Welcome to PureMac" = "Boas-vindas ao PureMac"; +"Free, open-source macOS app manager and system cleaner." = "Gerenciador de apps e limpador de sistema gratuito e de código aberto para macOS."; +"Find junk files across your system" = "Encontre arquivos de lixo no sistema todo"; +"App Uninstaller" = "Desinstalador de apps"; +"Remove apps and all their files" = "Remova apps e todos os seus arquivos"; +"Orphan Finder" = "Localizador de órfãos"; +"Find leftovers from deleted apps" = "Encontre restos de apps apagados"; +"Full Disk Access" = "Acesso total ao disco"; +"PureMac needs Full Disk Access to uninstall apps, find leftover files, and clean protected caches." = "O PureMac precisa de acesso total ao disco para desinstalar apps, encontrar arquivos restantes e limpar caches protegidos."; +"We'll guide you through the next steps." = "Vamos guiá-lo pelos próximos passos."; +"In System Settings, do this:" = "Em Ajustes do Sistema, faça o seguinte:"; +"Privacy & Security → Full Disk Access" = "Privacidade e Segurança → Acesso total ao disco"; +"Find **PureMac** and turn the toggle on" = "Encontre o **PureMac** e ative o interruptor"; +"Authenticate with Touch ID or your password" = "Autentique-se com Touch ID ou sua senha"; +"Reopen Settings" = "Reabrir Ajustes"; +"PureMac isn't in the list — reveal it" = "O PureMac não está na lista — mostrar"; +"Reset permissions and re-prompt" = "Redefinir permissões e perguntar novamente"; +"Hide diagnostics" = "Ocultar diagnóstico"; +"Show diagnostics" = "Mostrar diagnóstico"; +"Trouble?" = "Algum problema?"; +"Waiting for permission…" = "Aguardando permissão…"; +"Permission granted." = "Permissão concedida."; +"PureMac can now manage protected files." = "O PureMac agora pode gerenciar arquivos protegidos."; +"You're Ready" = "Tudo pronto"; +"%lld/%lld protected locations accessible" = "%lld/%lld locais protegidos acessíveis"; +"Some features will be limited. You can grant Full Disk Access later in System Settings." = "Alguns recursos ficarão limitados. Você pode conceder o acesso total ao disco depois em Ajustes do Sistema."; +"Trash" = "Lixo"; +"Mail Data" = "Dados do Mail"; +"Safari Data" = "Dados do Safari"; +"Desktop" = "Mesa"; +"Documents" = "Documentos"; +"TCC Database" = "Banco de dados TCC"; +"Blocked" = "Bloqueado"; + +/* Settings */ +"General" = "Geral"; +"Cleaning" = "Limpeza"; +"Schedule" = "Agendamento"; +"About" = "Sobre"; +"Startup" = "Inicialização"; +"Launch PureMac at login" = "Abrir o PureMac ao iniciar sessão"; +"App Scanning" = "Verificação de apps"; +"Search sensitivity" = "Sensibilidade da busca"; +"Strict" = "Restrita"; +"Enhanced" = "Aprimorada"; +"Deep" = "Profunda"; +"Exact bundle ID and name matches only. Safest option." = "Apenas correspondências exatas de ID e nome do pacote. Opção mais segura."; +"Includes partial name matching and bundle ID components." = "Inclui correspondências parciais de nome e componentes do ID do pacote."; +"Includes company name, entitlements, and team identifier matching." = "Inclui correspondências com o nome da empresa, entitlements e identificador da equipe."; +"Language" = "Idioma"; +"Restart PureMac to apply the selected language." = "Reinicie o PureMac para aplicar o idioma selecionado."; +"Relaunch Now" = "Reiniciar agora"; +"System Default" = "Padrão do sistema"; +"English" = "Inglês"; +"Spanish" = "Espanhol"; +"Japanese" = "Japonês"; +"Arabic" = "Árabe"; +"Portuguese (Brazil)" = "Português (Brasil)"; +"Chinese (Simplified)" = "Chinês (simplificado)"; +"Chinese (Traditional)" = "Chinês (tradicional)"; +"Safety" = "Segurança"; +"Confirm before deleting files" = "Confirmar antes de apagar arquivos"; +"File Discovery" = "Detecção de arquivos"; +"Skip hidden files during scan" = "Ignorar arquivos ocultos durante a verificação"; +"Large Files" = "Arquivos grandes"; +"Minimum size: %lld MB" = "Tamanho mínimo: %lld MB"; +"Files older than: %lld months" = "Arquivos com mais de: %lld meses"; +"Automatic Scanning" = "Verificação automática"; +"Enable scheduled scanning" = "Ativar verificação agendada"; +"Scan interval" = "Intervalo da verificação"; +"Auto-clean after scan" = "Limpar automaticamente após a verificação"; +"Auto-purge purgeable space" = "Liberar espaço purgable automaticamente"; +"Notify on completion" = "Notificar ao concluir"; +"Last run" = "Última execução"; +"Version %@" = "Versão %@"; +"Free, open-source macOS app manager." = "Gerenciador de apps de macOS gratuito e de código aberto."; +"GitHub Repository" = "Repositório no GitHub"; +"Report an Issue" = "Relatar um problema"; +"MIT License" = "Licença MIT"; + +/* Cleaning categories (CleaningCategory.rawValue) */ +"System Junk" = "Lixo do sistema"; +"User Cache" = "Cache do usuário"; +"AI Apps" = "Apps de IA"; +"Mail Files" = "Arquivos do Mail"; +"Trash Bins" = "Lixeiras"; +"Large & Old Files" = "Arquivos grandes e antigos"; +"Purgeable Space" = "Espaço liberável"; +"Xcode Junk" = "Lixo do Xcode"; +"Brew Cache" = "Cache do Brew"; +"Node Cache" = "Cache do Node"; +"Docker Cache" = "Cache do Docker"; + +/* Cleaning category descriptions */ +"Scan everything at once" = "Verificar tudo de uma vez"; +"System caches, logs, and temporary files" = "Caches do sistema, logs e arquivos temporários"; +"Application caches and browser data" = "Caches de aplicativos e dados do navegador"; +"Local AI app logs, caches, and optional history" = "Logs, caches e histórico opcional de apps de IA locais"; +"Downloaded mail attachments" = "Anexos de e-mail baixados"; +"Files in your Trash" = "Arquivos na sua Lixeira"; +"Files over 100 MB or older than 1 year" = "Arquivos com mais de 100 MB ou mais de 1 ano"; +"APFS purgeable disk space" = "Espaço de disco liberável do APFS"; +"Derived data, archives, and simulators" = "Derived data, arquivos e simuladores"; +"Homebrew download cache" = "Cache de downloads do Homebrew"; +"npm, yarn, and pnpm download caches" = "Caches de download do npm, yarn e pnpm"; +"Docker images, containers, and build cache" = "Imagens, containers e cache de build do Docker"; + +/* Node Cache subcategories */ +"npm cache" = "Cache do npm"; +"yarn classic cache" = "Cache clássico do yarn"; +"pnpm content-addressable store" = "Repositório endereçável por conteúdo do pnpm"; + +/* Docker Cache helper */ +"Reclaimable (run `docker system prune -af`)" = "Recuperável (execute `docker system prune -af`)"; + +/* Schedule intervals (ScheduleInterval.rawValue) */ +"Every Hour" = "A cada hora"; +"Every 3 Hours" = "A cada 3 horas"; +"Every 6 Hours" = "A cada 6 horas"; +"Every 12 Hours" = "A cada 12 horas"; +"Daily" = "Diariamente"; +"Weekly" = "Semanalmente"; +"Every 2 Weeks" = "A cada 2 semanas"; +"Monthly" = "Mensalmente"; + +/* Schedule status */ +"Never" = "Nunca"; +"Not scheduled" = "Não agendado"; + +/* Appearance modes (AppearanceMode.label) */ +"System" = "Sistema"; +"Light" = "Claro"; +"Dark" = "Escuro"; + +/* Notifications */ +"Found %@ of junk files." = "Foram encontrados %@ de arquivos de lixo."; + +/* Permission Sheet */ +"Grant Full Disk Access" = "Conceder acesso total ao disco"; +"%lld item(s) need Full Disk Access" = "%lld item(ns) precisam de acesso total ao disco"; +"Uninstalling %@: %lld file(s) need Full Disk Access" = "Desinstalando %@: %lld arquivo(s) precisam de acesso total ao disco"; +"%lld item(s) need Full Disk Access to remove. Tap Grant Access to fix in one step." = "%lld item(ns) precisam de acesso total ao disco para serem removidos. Toque em Conceder Acesso para resolver em um passo."; +"Couldn't remove %@%@. They may be in use or protected by macOS." = "Não foi possível remover %@%@. Podem estar em uso ou protegidos pelo macOS."; +" and %lld more" = " e mais %lld"; +"Access granted. Retrying…" = "Acesso concedido. Tentando novamente…"; +"1-tap setup. We'll detect the change automatically." = "Configuração em 1 toque. Detectaremos a mudança automaticamente."; +"Open Settings & reveal PureMac" = "Abrir Ajustes e mostrar PureMac"; +"We'll open both windows side-by-side." = "Abriremos as duas janelas lado a lado."; +"Turn on PureMac" = "Ativar PureMac"; +"Toggle the row that appears." = "Ative a linha que aparecer."; +"Authenticate" = "Autenticar"; +"Touch ID or password." = "Touch ID ou senha."; +"We auto-retry — no need to come back." = "Tentamos de novo automaticamente - você não precisa voltar."; +"Watching for permission change…" = "Aguardando alteração de permissão…"; +"PureMac not in the list?" = "PureMac não aparece na lista?"; +"Hide help" = "Ocultar ajuda"; +"Try this if PureMac doesn't appear:" = "Tente isto se o PureMac não aparecer:"; +"Reveal app" = "Mostrar app"; +"Drag PureMac.app into the list" = "Arraste PureMac.app para a lista"; +"Reset + reprompt" = "Redefinir e pedir de novo"; +"Clear stale TCC entry" = "Limpar entrada TCC desatualizada"; +"+ %lld more" = "+ %lld mais"; +"%lld blocked path(s)" = "%lld caminho(s) bloqueado(s)"; +"Access granted" = "Acesso concedido"; +"Retrying the operation now." = "Tentando a operação novamente."; +"Skip for now" = "Pular por ora"; +"I granted it — retry" = "Já concedi - tentar de novo"; + +/* Dashboard polish */ +"Low space" = "Pouco espaço"; +"CLEANING" = "LIMPANDO"; +"Quick Setup" = "Configuração rápida"; +"1-tap setup. We'll auto-retry what failed." = "Configuração em 1 toque. Tentaremos de novo o que falhar."; +"Fix permission" = "Corrigir permissão"; +"Dismiss" = "Dispensar"; + +/* Onboarding v2 */ +"Reclaim your Mac" = "Recupere seu Mac"; +"Apple sells you small disks. We help you keep them clean." = "A Apple vende discos pequenos. Nós ajudamos a mantê-los limpos."; +"Free. Open source. MIT licensed." = "Grátis. Código aberto. Licença MIT."; +"What's inside" = "O que tem dentro"; +"Three things, done well." = "Três coisas, bem feitas."; +"Find caches, logs, broken installs, and the AI-app history hiding in your library." = "Encontre caches, logs, instalações quebradas e o histórico de apps de IA escondido na sua biblioteca."; +"Drag an app, see every file it dropped, remove all of it. No leftovers." = "Arraste um app, veja cada arquivo que ele deixou, remova tudo. Sem sobras."; +"Surfaces files that outlived the apps that created them." = "Mostra arquivos que sobreviveram aos apps que os criaram."; +"One permission, then we're done" = "Uma permissão e pronto"; +"Permission granted" = "Permissão concedida"; +"PureMac can now reach the locations macOS protects by default." = "Agora o PureMac pode acessar os locais que o macOS protege por padrão."; +"macOS hides certain folders from every app until you say otherwise. We need them to find caches and uninstall cleanly." = "O macOS esconde algumas pastas de todos os apps até você permitir. Precisamos delas para achar caches e desinstalar limpamente."; +"All set — moving you to the next step." = "Tudo certo - indo para o próximo passo."; +"Reveal app again" = "Mostrar app de novo"; +"Reset permissions" = "Redefinir permissões"; +"You're ready" = "Tudo pronto"; +"Ready when you are" = "Prontos quando você estiver"; +"Hit Start to run your first Smart Scan." = "Toque em Iniciar para rodar seu primeiro Smart Scan."; +"Skip" = "Pular"; +"Start" = "Iniciar"; +"Continue" = "Continuar"; + +/* Drag handle */ +"Drag to the Settings list" = "Arraste para a lista de Ajustes"; +"Drag this icon into the Full Disk Access list in System Settings." = "Arraste este ícone para a lista de Acesso Total ao Disco em Ajustes do Sistema."; +"Tip: drag the icon on the left straight into the list." = "Dica: arraste o ícone da esquerda diretamente para a lista."; +"Or drag the icon on the left straight into Settings." = "Ou arraste o ícone da esquerda direto para os Ajustes."; + +/* Dashboard storage composition (v2.7.0) */ +"Storage composition" = "Composição do armazenamento"; +"total" = "total"; +"Free" = "Livre"; + +/* UI polish (v2.8.0): leftover groups + sound toggle */ +"Application" = "Aplicativo"; +"Caches" = "Caches"; +"Application Support" = "Suporte de aplicativos"; +"Preferences" = "Preferências"; +"Logs" = "Registros"; +"Containers" = "Contêineres"; +"Launch Agents" = "Agentes de inicialização"; +"Other Files" = "Outros arquivos"; +"Sound" = "Som"; +"Play sound effects" = "Reproduzir efeitos sonoros"; + +/* v2.8.1: purgeable honesty */ +"Managed by macOS" = "Gerenciado pelo macOS"; +"Reserved by macOS - freed automatically when space is needed" = "Reservado pelo macOS - liberado automaticamente quando há necessidade de espaço"; + +/* v2.8.2: large-file folder exclusions (#121) */ +"Excluded Folders" = "Pastas excluídas"; +"Files inside these folders are skipped from the Large & Old Files scan (Downloads, Documents, Desktop)." = "Arquivos dentro destas pastas são ignorados na verificação de Arquivos Grandes e Antigos (Downloads, Documentos, Mesa)."; +"Remove from exclusions" = "Remover das exclusões"; +"Add Folder…" = "Adicionar pasta…"; + +/* v2.8.2: orphan ignore list (#114) */ +"Always Ignore" = "Ignorar sempre"; +"Ignore Selected (%lld)" = "Ignorar selecionados (%lld)"; +"Ignored orphans: %lld" = "Órfãos ignorados: %lld"; +"Forget Ignored" = "Esquecer ignorados"; +"Ignored files won't appear in future orphan scans." = "Arquivos ignorados não aparecerão em futuras verificações de órfãos."; + +/* v2.8.3: menu-bar system monitor */ +"System Monitor" = "Monitor do sistema"; +"Show system monitor in menu bar" = "Mostrar o monitor do sistema na barra de menus"; +"Live CPU, memory, and disk meters in the menu bar. PureMac keeps running in the background while this is on." = "Medidores de CPU, memória e disco ao vivo na barra de menus. O PureMac continua em execução em segundo plano enquanto isso estiver ativado."; +"CPU" = "CPU"; +"Memory" = "Memória"; +"Disk" = "Disco"; +"Open PureMac" = "Abrir o PureMac"; +"Quit PureMac" = "Encerrar o PureMac"; diff --git a/PureMac/zh-Hans.lproj/Localizable.strings b/PureMac/zh-Hans.lproj/Localizable.strings new file mode 100644 index 0000000..a94f908 --- /dev/null +++ b/PureMac/zh-Hans.lproj/Localizable.strings @@ -0,0 +1,376 @@ +/* Sidebar (MainWindow) */ +"Overview" = "概览"; +"Applications" = "应用程序"; +"Cleanup" = "清理"; +"Dashboard" = "仪表盘"; +"Installed Apps" = "已安装的应用"; +"Orphaned Files" = "孤立文件"; +"PureMac" = "PureMac"; +"Ready to clean" = "可以清理"; +"Limited access" = "访问受限"; +"Full Disk Access granted" = "已授予完整磁盘访问权限"; +"Grant FDA in Settings" = "在“设置”中授予完整磁盘访问"; +"Select a category from the sidebar to get started." = "从侧边栏选择一个类别开始使用。"; + +/* FDA toast + clean error alert (MainWindow) */ +"Couldn't clean everything" = "无法清理全部"; +"Open System Settings" = "打开“系统设置”"; +"OK" = "好"; +"Full Disk Access required" = "需要完整磁盘访问权限"; +"macOS blocks PureMac from cleaning caches and uninstalling apps until you grant access." = "在你授予访问权限之前,macOS 会阻止 PureMac 清理缓存和卸载应用。"; +"Grant Access" = "授予访问"; + +/* Dashboard */ +"Storage" = "存储"; +"Smart Scan" = "智能扫描"; +"free of %@" = "%@ 中的可用空间"; +"%lld%% used" = "已使用 %lld%%"; +"Used" = "已用"; +"Junk" = "垃圾"; +"Purgeable" = "可清理"; +"USED" = "已用"; +"SCANNING" = "扫描中"; +"Free Space" = "可用空间"; +"Junk Found" = "发现的垃圾"; +"Apps" = "应用"; +"of %@ · %lld%% used" = "总计 %@ · 已使用 %lld%%"; +"Run a scan" = "运行扫描"; +"across %lld categories" = "跨 %lld 个类别"; +"installed" = "已安装"; +"APFS reclaimable" = "APFS 可回收"; +"Suggested for you" = "推荐"; +"Found so far" = "目前发现"; +"By category" = "按类别"; +"%@ is using %@" = "%@ 占用 %@"; +"Grant Full Disk Access for full results" = "授予完整磁盘访问以获得完整结果"; +"Without it, most caches and uninstall flows fail." = "没有它,大多数缓存清理和卸载流程都会失败。"; +"Action" = "操作"; +"Scanning your Mac" = "正在扫描你的 Mac"; +"Currently in: %@" = "当前: %@"; +"found" = "已发现"; +"Your Mac is clean" = "你的 Mac 很干净"; +"Scan Again" = "重新扫描"; +"Clean %@" = "清理 %@"; +"Clean %@?" = "清理 %@?"; +"Cleaning…" = "正在清理…"; +"%lld%% complete" = "%lld%% 完成"; +"freed" = "已释放"; +"Done" = "完成"; +"%lld items" = "%lld 项"; + +/* Common destructive dialog */ +"Clean" = "清理"; +"Cancel" = "取消"; +"This will permanently delete the selected files. This cannot be undone." = "这将永久删除选中的文件。此操作无法撤销。"; + +/* Category Detail */ +"All Clean" = "已全部清理"; +"No junk files found in this category." = "此类别中未发现垃圾文件。"; +"Not Scanned" = "未扫描"; +"Run a scan to analyze this category." = "运行扫描以分析此类别。"; +"Scan Now" = "立即扫描"; +"Filter files" = "过滤文件"; +"Scan" = "扫描"; +"Select All" = "全选"; +"Deselect All" = "取消全选"; +"Largest First" = "最大优先"; +"Smallest First" = "最小优先"; +"Sorted: Largest First" = "排序:最大优先"; +"Sorted: Smallest First" = "排序:最小优先"; +"Clean %lld items" = "清理 %lld 项"; +"%lld items · %@" = "%lld 项 · %@"; +"Scanning…" = "扫描中…"; +"Rescan" = "重新扫描"; +"%lld of %lld selected" = "已选 %lld / %lld"; +"Reveal in Finder" = "在访达中显示"; +"Copy Path" = "复制路径"; +"Move to Trash" = "移到废纸篓"; + +/* Apps (AppListView) */ +"Search apps" = "搜索应用"; +"Refresh" = "刷新"; +"Installed Apps (%lld)" = "已安装应用 (%lld)"; +"Uninstall (%lld files)" = "卸载 (%lld 个文件)"; +"Loading installed apps..." = "正在加载已安装应用..."; +"No Apps Found" = "未找到应用"; +"Could not find any installed applications." = "未能找到任何已安装的应用程序。"; +"Retry" = "重试"; +"Application" = "应用程序"; +"Size" = "大小"; +"Select an App" = "选择一个应用"; +"Select an app from the list to see all its related files across your system." = "从列表中选择一个应用以查看系统中所有相关文件。"; + +/* App Files (AppFilesView) */ +"%lld files" = "%lld 个文件"; +"Scanning for related files..." = "正在扫描相关文件..."; +"Checking %lld locations..." = "正在检查 %lld 个位置..."; +"No Related Files" = "没有相关文件"; +"No additional files found for %@." = "未找到 %@ 的额外文件。"; +"Remove %lld files (%@)" = "删除 %lld 个文件 (%@)"; +"Removal Failed" = "删除失败"; +"Remove this file" = "删除此文件"; +"Remove %@?" = "删除 %@?"; +"Remove" = "删除"; +"This will permanently delete this file. This action cannot be undone." = "这将永久删除此文件。此操作无法撤销。"; + +/* Orphans */ +"Scanning for orphaned files..." = "正在扫描孤立文件..."; +"No Orphaned Files" = "没有孤立文件"; +"No leftover files from uninstalled apps were found." = "未发现已卸载应用的残留文件。"; +"Scan for Orphans" = "扫描孤立文件"; +"Orphaned Files (%lld)" = "孤立文件 (%lld)"; +"Remove Selected (%lld)" = "删除所选 (%lld)"; +"Some files could not be removed" = "部分文件无法删除"; + +/* Onboarding */ +"Back" = "返回"; +"Next" = "下一步"; +"Get Started" = "开始使用"; +"Welcome to PureMac" = "欢迎使用 PureMac"; +"Free, open-source macOS app manager and system cleaner." = "免费、开源的 macOS 应用管理和系统清理工具。"; +"Find junk files across your system" = "在系统中查找垃圾文件"; +"App Uninstaller" = "应用卸载器"; +"Remove apps and all their files" = "删除应用及其所有文件"; +"Orphan Finder" = "孤立文件查找"; +"Find leftovers from deleted apps" = "查找已删除应用的残留"; +"Full Disk Access" = "完整磁盘访问"; +"PureMac needs Full Disk Access to uninstall apps, find leftover files, and clean protected caches." = "PureMac 需要完整磁盘访问以卸载应用、查找残留文件并清理受保护的缓存。"; +"We'll guide you through the next steps." = "我们将引导你完成接下来的步骤。"; +"In System Settings, do this:" = "在“系统设置”中执行以下操作:"; +"Privacy & Security → Full Disk Access" = "隐私与安全性 → 完整磁盘访问"; +"Find **PureMac** and turn the toggle on" = "找到 **PureMac** 并打开开关"; +"Authenticate with Touch ID or your password" = "使用触控 ID 或密码进行验证"; +"Reopen Settings" = "重新打开“设置”"; +"PureMac isn't in the list — reveal it" = "PureMac 不在列表中 — 显示"; +"Reset permissions and re-prompt" = "重置权限并再次提示"; +"Hide diagnostics" = "隐藏诊断"; +"Show diagnostics" = "显示诊断"; +"Trouble?" = "遇到问题?"; +"Waiting for permission…" = "等待授权…"; +"Permission granted." = "已授予权限。"; +"PureMac can now manage protected files." = "PureMac 现在可以管理受保护的文件。"; +"You're Ready" = "已准备就绪"; +"%lld/%lld protected locations accessible" = "可访问的受保护位置 %lld/%lld"; +"Some features will be limited. You can grant Full Disk Access later in System Settings." = "部分功能将受限。你可以稍后在“系统设置”中授予完整磁盘访问。"; +"Trash" = "废纸篓"; +"Mail Data" = "邮件数据"; +"Safari Data" = "Safari 数据"; +"Desktop" = "桌面"; +"Documents" = "文稿"; +"TCC Database" = "TCC 数据库"; +"Blocked" = "已阻止"; + +/* Settings */ +"General" = "通用"; +"Cleaning" = "清理"; +"Schedule" = "计划"; +"About" = "关于"; +"Startup" = "启动"; +"Launch PureMac at login" = "登录时启动 PureMac"; +"App Scanning" = "应用扫描"; +"Search sensitivity" = "搜索灵敏度"; +"Strict" = "严格"; +"Enhanced" = "增强"; +"Deep" = "深度"; +"Exact bundle ID and name matches only. Safest option." = "仅精确匹配套件 ID 和名称。最安全。"; +"Includes partial name matching and bundle ID components." = "包含部分名称匹配和套件 ID 组件。"; +"Includes company name, entitlements, and team identifier matching." = "包含公司名称、授权和团队标识符匹配。"; +"Language" = "语言"; +"Restart PureMac to apply the selected language." = "请重新启动 PureMac 以应用所选语言。"; +"Relaunch Now" = "立即重新启动"; +"System Default" = "系统默认"; +"English" = "英语"; +"Spanish" = "西班牙语"; +"Japanese" = "日语"; +"Arabic" = "阿拉伯语"; +"Portuguese (Brazil)" = "葡萄牙语(巴西)"; +"Chinese (Simplified)" = "简体中文"; +"Chinese (Traditional)" = "繁体中文"; +"Safety" = "安全"; +"Confirm before deleting files" = "删除文件前确认"; +"File Discovery" = "文件发现"; +"Skip hidden files during scan" = "扫描时跳过隐藏文件"; +"Large Files" = "大文件"; +"Minimum size: %lld MB" = "最小大小: %lld MB"; +"Files older than: %lld months" = "早于: %lld 个月的文件"; +"Automatic Scanning" = "自动扫描"; +"Enable scheduled scanning" = "启用计划扫描"; +"Scan interval" = "扫描间隔"; +"Auto-clean after scan" = "扫描后自动清理"; +"Auto-purge purgeable space" = "自动清除可清理空间"; +"Notify on completion" = "完成时通知"; +"Last run" = "上次运行"; +"Version %@" = "版本 %@"; +"Free, open-source macOS app manager." = "免费、开源的 macOS 应用管理工具。"; +"GitHub Repository" = "GitHub 仓库"; +"Report an Issue" = "报告问题"; +"MIT License" = "MIT 许可"; + +/* Cleaning categories (CleaningCategory.rawValue) */ +"System Junk" = "系统垃圾"; +"User Cache" = "用户缓存"; +"AI Apps" = "AI 应用"; +"Mail Files" = "邮件文件"; +"Trash Bins" = "废纸篓"; +"Large & Old Files" = "大文件和旧文件"; +"Purgeable Space" = "可清理空间"; +"Xcode Junk" = "Xcode 垃圾"; +"Brew Cache" = "Brew 缓存"; +"Node Cache" = "Node 缓存"; +"Docker Cache" = "Docker 缓存"; + +/* Cleaning category descriptions */ +"Scan everything at once" = "一次扫描所有内容"; +"System caches, logs, and temporary files" = "系统缓存、日志和临时文件"; +"Application caches and browser data" = "应用程序缓存和浏览器数据"; +"Local AI app logs, caches, and optional history" = "本地 AI 应用的日志、缓存和可选历史"; +"Downloaded mail attachments" = "已下载的邮件附件"; +"Files in your Trash" = "废纸篓中的文件"; +"Files over 100 MB or older than 1 year" = "大于 100 MB 或超过 1 年的文件"; +"APFS purgeable disk space" = "APFS 可清理磁盘空间"; +"Derived data, archives, and simulators" = "派生数据、归档和模拟器"; +"Homebrew download cache" = "Homebrew 下载缓存"; +"npm, yarn, and pnpm download caches" = "npm、yarn 和 pnpm 下载缓存"; +"Docker images, containers, and build cache" = "Docker 镜像、容器和构建缓存"; + +/* Node Cache subcategories */ +"npm cache" = "npm 缓存"; +"yarn classic cache" = "yarn 经典缓存"; +"pnpm content-addressable store" = "pnpm 内容寻址存储"; + +/* Docker Cache helper */ +"Reclaimable (run `docker system prune -af`)" = "可回收(运行 `docker system prune -af`)"; + +/* Schedule intervals (ScheduleInterval.rawValue) */ +"Every Hour" = "每小时"; +"Every 3 Hours" = "每 3 小时"; +"Every 6 Hours" = "每 6 小时"; +"Every 12 Hours" = "每 12 小时"; +"Daily" = "每天"; +"Weekly" = "每周"; +"Every 2 Weeks" = "每 2 周"; +"Monthly" = "每月"; + +/* Schedule status */ +"Never" = "从不"; +"Not scheduled" = "未计划"; + +/* Appearance modes (AppearanceMode.label) */ +"System" = "系统"; +"Light" = "浅色"; +"Dark" = "深色"; + +/* Notifications */ +"Found %@ of junk files." = "发现 %@ 的垃圾文件。"; + +/* Permission Sheet */ +"Grant Full Disk Access" = "授予完全磁盘访问权限"; +"%lld item(s) need Full Disk Access" = "%lld 个项目需要完全磁盘访问权限"; +"Uninstalling %@: %lld file(s) need Full Disk Access" = "卸载 %@:%lld 个文件需要完全磁盘访问权限"; +"%lld item(s) need Full Disk Access to remove. Tap Grant Access to fix in one step." = "%lld 个项目需要完全磁盘访问权限才能删除。轻点「授予访问权限」一步解决。"; +"Couldn't remove %@%@. They may be in use or protected by macOS." = "无法删除 %@%@。它们可能正在使用或受 macOS 保护。"; +" and %lld more" = " 还有 %lld 个"; +"Access granted. Retrying…" = "已授予访问权限。正在重试…"; +"1-tap setup. We'll detect the change automatically." = "一键设置。我们将自动检测更改。"; +"Open Settings & reveal PureMac" = "打开“设置”并显示 PureMac"; +"We'll open both windows side-by-side." = "我们会并排打开两个窗口。"; +"Turn on PureMac" = "开启 PureMac"; +"Toggle the row that appears." = "打开出现的那一行。"; +"Authenticate" = "验证身份"; +"Touch ID or password." = "Touch ID 或密码。"; +"We auto-retry — no need to come back." = "我们会自动重试 - 无需返回。"; +"Watching for permission change…" = "正在监视权限变更…"; +"PureMac not in the list?" = "PureMac 不在列表中?"; +"Hide help" = "隐藏帮助"; +"Try this if PureMac doesn't appear:" = "如果 PureMac 没有出现,请尝试:"; +"Reveal app" = "显示 App"; +"Drag PureMac.app into the list" = "将 PureMac.app 拖入列表"; +"Reset + reprompt" = "重置并重新提示"; +"Clear stale TCC entry" = "清除过时的 TCC 条目"; +"+ %lld more" = "+ 还有 %lld 个"; +"%lld blocked path(s)" = "%lld 个被阻止的路径"; +"Access granted" = "已授予访问权限"; +"Retrying the operation now." = "正在重试此操作。"; +"Skip for now" = "暂时跳过"; +"I granted it — retry" = "我已授予 - 重试"; + +/* Dashboard polish */ +"Low space" = "空间不足"; +"CLEANING" = "正在清理"; +"Quick Setup" = "快速设置"; +"1-tap setup. We'll auto-retry what failed." = "一键设置。我们会自动重试失败的操作。"; +"Fix permission" = "修复权限"; +"Dismiss" = "忽略"; + +/* Onboarding v2 */ +"Reclaim your Mac" = "夺回你的 Mac"; +"Apple sells you small disks. We help you keep them clean." = "苹果卖给你小硬盘。我们帮你保持整洁。"; +"Free. Open source. MIT licensed." = "免费。开源。MIT 许可。"; +"What's inside" = "里面有什么"; +"Three things, done well." = "三件事,做到极致。"; +"Find caches, logs, broken installs, and the AI-app history hiding in your library." = "找出缓存、日志、损坏的安装文件,以及隐藏在资源库中的 AI 应用历史。"; +"Drag an app, see every file it dropped, remove all of it. No leftovers." = "拖入一个 App,看到它留下的每个文件,全部清除。不留残余。"; +"Surfaces files that outlived the apps that created them." = "显示比创建它们的应用更长寿的文件。"; +"One permission, then we're done" = "一个权限,就够了"; +"Permission granted" = "权限已授予"; +"PureMac can now reach the locations macOS protects by default." = "PureMac 现在可以访问 macOS 默认保护的位置。"; +"macOS hides certain folders from every app until you say otherwise. We need them to find caches and uninstall cleanly." = "在你允许之前,macOS 对所有 App 隐藏某些文件夹。我们需要它们来查找缓存并干净地卸载。"; +"All set — moving you to the next step." = "全部就绪 - 进入下一步。"; +"Reveal app again" = "再次显示 App"; +"Reset permissions" = "重置权限"; +"You're ready" = "准备就绪"; +"Ready when you are" = "随时为你准备好"; +"Hit Start to run your first Smart Scan." = "按开始运行你的第一次智能扫描。"; +"Skip" = "跳过"; +"Start" = "开始"; +"Continue" = "继续"; + +/* Drag handle */ +"Drag to the Settings list" = "拖到设置列表"; +"Drag this icon into the Full Disk Access list in System Settings." = "将此图标拖入系统设置中的完整磁盘访问列表。"; +"Tip: drag the icon on the left straight into the list." = "提示:把左侧图标直接拖入列表。"; +"Or drag the icon on the left straight into Settings." = "或者把左侧图标直接拖入设置。"; + +/* Dashboard storage composition (v2.7.0) */ +"Storage composition" = "存储构成"; +"total" = "总计"; +"Free" = "可用"; + +/* UI polish (v2.8.0): leftover groups + sound toggle */ +"Application" = "应用程序"; +"Caches" = "缓存"; +"Application Support" = "应用程序支持"; +"Preferences" = "偏好设置"; +"Logs" = "日志"; +"Containers" = "容器"; +"Launch Agents" = "启动代理"; +"Other Files" = "其他文件"; +"Sound" = "声音"; +"Play sound effects" = "播放音效"; + +/* v2.8.1: purgeable honesty */ +"Managed by macOS" = "由 macOS 管理"; +"Reserved by macOS - freed automatically when space is needed" = "由 macOS 预留 - 需要空间时自动释放"; + +/* v2.8.2: large-file folder exclusions (#121) */ +"Excluded Folders" = "排除的文件夹"; +"Files inside these folders are skipped from the Large & Old Files scan (Downloads, Documents, Desktop)." = "这些文件夹中的文件会在“大文件和旧文件”扫描(下载、文稿、桌面)中被跳过。"; +"Remove from exclusions" = "从排除项中移除"; +"Add Folder…" = "添加文件夹…"; + +/* v2.8.2: orphan ignore list (#114) */ +"Always Ignore" = "始终忽略"; +"Ignore Selected (%lld)" = "忽略所选 (%lld)"; +"Ignored orphans: %lld" = "已忽略的孤立文件:%lld"; +"Forget Ignored" = "清除已忽略"; +"Ignored files won't appear in future orphan scans." = "已忽略的文件不会出现在以后的孤立文件扫描中。"; + +/* v2.8.3: menu-bar system monitor */ +"System Monitor" = "系统监视器"; +"Show system monitor in menu bar" = "在菜单栏中显示系统监视器"; +"Live CPU, memory, and disk meters in the menu bar. PureMac keeps running in the background while this is on." = "在菜单栏中实时显示 CPU、内存和磁盘使用情况。开启时 PureMac 将在后台持续运行。"; +"CPU" = "CPU"; +"Memory" = "内存"; +"Disk" = "磁盘"; +"Open PureMac" = "打开 PureMac"; +"Quit PureMac" = "退出 PureMac"; diff --git a/PureMac/zh-Hant.lproj/Localizable.strings b/PureMac/zh-Hant.lproj/Localizable.strings new file mode 100644 index 0000000..091e012 --- /dev/null +++ b/PureMac/zh-Hant.lproj/Localizable.strings @@ -0,0 +1,376 @@ +/* Sidebar (MainWindow) */ +"Overview" = "概覽"; +"Applications" = "應用程式"; +"Cleanup" = "清理"; +"Dashboard" = "儀表板"; +"Installed Apps" = "已安裝的應用程式"; +"Orphaned Files" = "孤立檔案"; +"PureMac" = "PureMac"; +"Ready to clean" = "可以清理"; +"Limited access" = "存取受限"; +"Full Disk Access granted" = "已授予完整磁碟取用權"; +"Grant FDA in Settings" = "在「設定」中授予完整磁碟取用權"; +"Select a category from the sidebar to get started." = "從側邊欄選擇一個類別以開始使用。"; + +/* FDA toast + clean error alert (MainWindow) */ +"Couldn't clean everything" = "無法清理所有項目"; +"Open System Settings" = "打開「系統設定」"; +"OK" = "好"; +"Full Disk Access required" = "需要完整磁碟取用權"; +"macOS blocks PureMac from cleaning caches and uninstalling apps until you grant access." = "在你授予取用權之前,macOS 會阻止 PureMac 清理快取與解除安裝應用程式。"; +"Grant Access" = "授予取用"; + +/* Dashboard */ +"Storage" = "儲存空間"; +"Smart Scan" = "智慧掃描"; +"free of %@" = "%@ 中的可用空間"; +"%lld%% used" = "已使用 %lld%%"; +"Used" = "已用"; +"Junk" = "垃圾"; +"Purgeable" = "可清除"; +"USED" = "已用"; +"SCANNING" = "掃描中"; +"Free Space" = "可用空間"; +"Junk Found" = "發現的垃圾"; +"Apps" = "應用程式"; +"of %@ · %lld%% used" = "共 %@ · 已使用 %lld%%"; +"Run a scan" = "執行掃描"; +"across %lld categories" = "跨越 %lld 個類別"; +"installed" = "已安裝"; +"APFS reclaimable" = "APFS 可回收"; +"Suggested for you" = "為你建議"; +"Found so far" = "目前找到"; +"By category" = "依類別"; +"%@ is using %@" = "%@ 正在使用 %@"; +"Grant Full Disk Access for full results" = "授予完整磁碟取用權以取得完整結果"; +"Without it, most caches and uninstall flows fail." = "若沒有它,多數快取清理與解除安裝流程會失敗。"; +"Action" = "動作"; +"Scanning your Mac" = "正在掃描你的 Mac"; +"Currently in: %@" = "目前: %@"; +"found" = "已找到"; +"Your Mac is clean" = "你的 Mac 很乾淨"; +"Scan Again" = "重新掃描"; +"Clean %@" = "清理 %@"; +"Clean %@?" = "清理 %@?"; +"Cleaning…" = "正在清理…"; +"%lld%% complete" = "已完成 %lld%%"; +"freed" = "已釋出"; +"Done" = "完成"; +"%lld items" = "%lld 個項目"; + +/* Common destructive dialog */ +"Clean" = "清理"; +"Cancel" = "取消"; +"This will permanently delete the selected files. This cannot be undone." = "這會永久刪除所選檔案。此動作無法復原。"; + +/* Category Detail */ +"All Clean" = "全部乾淨"; +"No junk files found in this category." = "此類別中未發現垃圾檔案。"; +"Not Scanned" = "尚未掃描"; +"Run a scan to analyze this category." = "執行掃描以分析此類別。"; +"Scan Now" = "立即掃描"; +"Filter files" = "篩選檔案"; +"Scan" = "掃描"; +"Select All" = "全選"; +"Deselect All" = "取消全選"; +"Largest First" = "最大優先"; +"Smallest First" = "最小優先"; +"Sorted: Largest First" = "排序:最大優先"; +"Sorted: Smallest First" = "排序:最小優先"; +"Clean %lld items" = "清理 %lld 個項目"; +"%lld items · %@" = "%lld 個項目 · %@"; +"Scanning…" = "掃描中…"; +"Rescan" = "重新掃描"; +"%lld of %lld selected" = "已選 %lld / %lld"; +"Reveal in Finder" = "在 Finder 中顯示"; +"Copy Path" = "複製路徑"; +"Move to Trash" = "移到垃圾桶"; + +/* Apps (AppListView) */ +"Search apps" = "搜尋應用程式"; +"Refresh" = "重新整理"; +"Installed Apps (%lld)" = "已安裝的應用程式 (%lld)"; +"Uninstall (%lld files)" = "解除安裝 (%lld 個檔案)"; +"Loading installed apps..." = "正在載入已安裝的應用程式..."; +"No Apps Found" = "找不到應用程式"; +"Could not find any installed applications." = "找不到任何已安裝的應用程式。"; +"Retry" = "重試"; +"Application" = "應用程式"; +"Size" = "大小"; +"Select an App" = "選擇應用程式"; +"Select an app from the list to see all its related files across your system." = "從清單中選擇一個應用程式以查看系統中所有相關檔案。"; + +/* App Files (AppFilesView) */ +"%lld files" = "%lld 個檔案"; +"Scanning for related files..." = "正在掃描相關檔案..."; +"Checking %lld locations..." = "正在檢查 %lld 個位置..."; +"No Related Files" = "沒有相關檔案"; +"No additional files found for %@." = "找不到 %@ 的額外檔案。"; +"Remove %lld files (%@)" = "移除 %lld 個檔案 (%@)"; +"Removal Failed" = "移除失敗"; +"Remove this file" = "移除此檔案"; +"Remove %@?" = "移除 %@?"; +"Remove" = "移除"; +"This will permanently delete this file. This action cannot be undone." = "這會永久刪除此檔案。此動作無法復原。"; + +/* Orphans */ +"Scanning for orphaned files..." = "正在掃描孤立檔案..."; +"No Orphaned Files" = "沒有孤立檔案"; +"No leftover files from uninstalled apps were found." = "未發現已解除安裝應用程式的殘留檔案。"; +"Scan for Orphans" = "掃描孤立檔案"; +"Orphaned Files (%lld)" = "孤立檔案 (%lld)"; +"Remove Selected (%lld)" = "移除所選 (%lld)"; +"Some files could not be removed" = "部分檔案無法移除"; + +/* Onboarding */ +"Back" = "返回"; +"Next" = "下一步"; +"Get Started" = "開始使用"; +"Welcome to PureMac" = "歡迎使用 PureMac"; +"Free, open-source macOS app manager and system cleaner." = "免費、開放原始碼的 macOS 應用程式管理與系統清理工具。"; +"Find junk files across your system" = "在系統中尋找垃圾檔案"; +"App Uninstaller" = "應用程式解除安裝"; +"Remove apps and all their files" = "移除應用程式及其所有檔案"; +"Orphan Finder" = "孤立檔案搜尋"; +"Find leftovers from deleted apps" = "尋找已刪除應用程式的殘留"; +"Full Disk Access" = "完整磁碟取用權"; +"PureMac needs Full Disk Access to uninstall apps, find leftover files, and clean protected caches." = "PureMac 需要完整磁碟取用權以解除安裝應用程式、尋找殘留檔案,以及清理受保護的快取。"; +"We'll guide you through the next steps." = "我們將引導你完成接下來的步驟。"; +"In System Settings, do this:" = "在「系統設定」中執行以下步驟:"; +"Privacy & Security → Full Disk Access" = "隱私權與安全性 → 完整磁碟取用權"; +"Find **PureMac** and turn the toggle on" = "找到 **PureMac** 並開啟開關"; +"Authenticate with Touch ID or your password" = "使用觸控 ID 或密碼進行驗證"; +"Reopen Settings" = "重新打開「設定」"; +"PureMac isn't in the list — reveal it" = "PureMac 不在清單中 — 顯示它"; +"Reset permissions and re-prompt" = "重置權限並重新提示"; +"Hide diagnostics" = "隱藏診斷"; +"Show diagnostics" = "顯示診斷"; +"Trouble?" = "遇到問題?"; +"Waiting for permission…" = "等待授權…"; +"Permission granted." = "已授予權限。"; +"PureMac can now manage protected files." = "PureMac 現在可以管理受保護的檔案。"; +"You're Ready" = "已就緒"; +"%lld/%lld protected locations accessible" = "可存取的受保護位置 %lld/%lld"; +"Some features will be limited. You can grant Full Disk Access later in System Settings." = "部分功能將受到限制。你可以稍後在「系統設定」中授予完整磁碟取用權。"; +"Trash" = "垃圾桶"; +"Mail Data" = "Mail 資料"; +"Safari Data" = "Safari 資料"; +"Desktop" = "桌面"; +"Documents" = "文件"; +"TCC Database" = "TCC 資料庫"; +"Blocked" = "已阻擋"; + +/* Settings */ +"General" = "一般"; +"Cleaning" = "清理"; +"Schedule" = "排程"; +"About" = "關於"; +"Startup" = "啟動"; +"Launch PureMac at login" = "登入時啟動 PureMac"; +"App Scanning" = "應用程式掃描"; +"Search sensitivity" = "搜尋敏感度"; +"Strict" = "嚴格"; +"Enhanced" = "加強"; +"Deep" = "深度"; +"Exact bundle ID and name matches only. Safest option." = "僅精確比對套件 ID 與名稱。最安全選項。"; +"Includes partial name matching and bundle ID components." = "包含部分名稱比對與套件 ID 組件。"; +"Includes company name, entitlements, and team identifier matching." = "包含公司名稱、授權與團隊識別碼的比對。"; +"Language" = "語言"; +"Restart PureMac to apply the selected language." = "請重新啟動 PureMac 以套用所選語言。"; +"Relaunch Now" = "立即重新啟動"; +"System Default" = "系統預設"; +"English" = "英文"; +"Spanish" = "西班牙文"; +"Japanese" = "日文"; +"Arabic" = "阿拉伯文"; +"Portuguese (Brazil)" = "葡萄牙文(巴西)"; +"Chinese (Simplified)" = "簡體中文"; +"Chinese (Traditional)" = "繁體中文"; +"Safety" = "安全"; +"Confirm before deleting files" = "刪除檔案前確認"; +"File Discovery" = "檔案探索"; +"Skip hidden files during scan" = "掃描時略過隱藏檔案"; +"Large Files" = "大型檔案"; +"Minimum size: %lld MB" = "最小大小: %lld MB"; +"Files older than: %lld months" = "早於: %lld 個月的檔案"; +"Automatic Scanning" = "自動掃描"; +"Enable scheduled scanning" = "啟用排程掃描"; +"Scan interval" = "掃描間隔"; +"Auto-clean after scan" = "掃描後自動清理"; +"Auto-purge purgeable space" = "自動清除可清除空間"; +"Notify on completion" = "完成時通知"; +"Last run" = "上次執行"; +"Version %@" = "版本 %@"; +"Free, open-source macOS app manager." = "免費、開放原始碼的 macOS 應用程式管理工具。"; +"GitHub Repository" = "GitHub 儲存庫"; +"Report an Issue" = "回報問題"; +"MIT License" = "MIT 授權"; + +/* Cleaning categories (CleaningCategory.rawValue) */ +"System Junk" = "系統垃圾"; +"User Cache" = "使用者快取"; +"AI Apps" = "AI 應用程式"; +"Mail Files" = "Mail 檔案"; +"Trash Bins" = "垃圾桶"; +"Large & Old Files" = "大型與舊檔案"; +"Purgeable Space" = "可清除空間"; +"Xcode Junk" = "Xcode 垃圾"; +"Brew Cache" = "Brew 快取"; +"Node Cache" = "Node 快取"; +"Docker Cache" = "Docker 快取"; + +/* Cleaning category descriptions */ +"Scan everything at once" = "一次掃描所有項目"; +"System caches, logs, and temporary files" = "系統快取、紀錄與暫存檔案"; +"Application caches and browser data" = "應用程式快取與瀏覽器資料"; +"Local AI app logs, caches, and optional history" = "本地 AI 應用程式的紀錄、快取與選用記錄"; +"Downloaded mail attachments" = "已下載的郵件附件"; +"Files in your Trash" = "垃圾桶中的檔案"; +"Files over 100 MB or older than 1 year" = "超過 100 MB 或一年以上的檔案"; +"APFS purgeable disk space" = "APFS 可清除的磁碟空間"; +"Derived data, archives, and simulators" = "衍生資料、封存與模擬器"; +"Homebrew download cache" = "Homebrew 下載快取"; +"npm, yarn, and pnpm download caches" = "npm、yarn 與 pnpm 下載快取"; +"Docker images, containers, and build cache" = "Docker 映像檔、容器與建置快取"; + +/* Node Cache subcategories */ +"npm cache" = "npm 快取"; +"yarn classic cache" = "yarn 經典快取"; +"pnpm content-addressable store" = "pnpm 內容定址儲存庫"; + +/* Docker Cache helper */ +"Reclaimable (run `docker system prune -af`)" = "可回收(執行 `docker system prune -af`)"; + +/* Schedule intervals (ScheduleInterval.rawValue) */ +"Every Hour" = "每小時"; +"Every 3 Hours" = "每 3 小時"; +"Every 6 Hours" = "每 6 小時"; +"Every 12 Hours" = "每 12 小時"; +"Daily" = "每天"; +"Weekly" = "每週"; +"Every 2 Weeks" = "每 2 週"; +"Monthly" = "每月"; + +/* Schedule status */ +"Never" = "從未"; +"Not scheduled" = "未排程"; + +/* Appearance modes (AppearanceMode.label) */ +"System" = "系統"; +"Light" = "淺色"; +"Dark" = "深色"; + +/* Notifications */ +"Found %@ of junk files." = "找到 %@ 的垃圾檔案。"; + +/* Permission Sheet */ +"Grant Full Disk Access" = "授予完整磁碟存取權限"; +"%lld item(s) need Full Disk Access" = "%lld 個項目需要完整磁碟存取權限"; +"Uninstalling %@: %lld file(s) need Full Disk Access" = "解除安裝 %@:%lld 個檔案需要完整磁碟存取權限"; +"%lld item(s) need Full Disk Access to remove. Tap Grant Access to fix in one step." = "%lld 個項目需要完整磁碟存取權限才能移除。輕點「授予存取權限」一步解決。"; +"Couldn't remove %@%@. They may be in use or protected by macOS." = "無法移除 %@%@。它們可能正在使用或受 macOS 保護。"; +" and %lld more" = " 還有 %lld 個"; +"Access granted. Retrying…" = "已授予存取權限。正在重試…"; +"1-tap setup. We'll detect the change automatically." = "一鍵設定。我們會自動偵測變更。"; +"Open Settings & reveal PureMac" = "開啟「系統設定」並顯示 PureMac"; +"We'll open both windows side-by-side." = "我們會並排開啟兩個視窗。"; +"Turn on PureMac" = "開啟 PureMac"; +"Toggle the row that appears." = "打開出現的那一列。"; +"Authenticate" = "驗證身分"; +"Touch ID or password." = "Touch ID 或密碼。"; +"We auto-retry — no need to come back." = "我們會自動重試 - 不需返回。"; +"Watching for permission change…" = "正在等待權限變更…"; +"PureMac not in the list?" = "PureMac 不在清單裡?"; +"Hide help" = "隱藏說明"; +"Try this if PureMac doesn't appear:" = "如果 PureMac 沒有出現,請嘗試:"; +"Reveal app" = "顯示 App"; +"Drag PureMac.app into the list" = "將 PureMac.app 拖到清單"; +"Reset + reprompt" = "重設並重新提示"; +"Clear stale TCC entry" = "清除過時的 TCC 條目"; +"+ %lld more" = "+ 還有 %lld 個"; +"%lld blocked path(s)" = "%lld 個被封鎖的路徑"; +"Access granted" = "已授予存取權限"; +"Retrying the operation now." = "正在重試此操作。"; +"Skip for now" = "暫時略過"; +"I granted it — retry" = "我已授予 - 重試"; + +/* Dashboard polish */ +"Low space" = "空間不足"; +"CLEANING" = "正在清理"; +"Quick Setup" = "快速設定"; +"1-tap setup. We'll auto-retry what failed." = "一鍵設定。我們會自動重試失敗的操作。"; +"Fix permission" = "修正權限"; +"Dismiss" = "略過"; + +/* Onboarding v2 */ +"Reclaim your Mac" = "奪回你的 Mac"; +"Apple sells you small disks. We help you keep them clean." = "蘋果賣給你小硬碟。我們幫你保持整潔。"; +"Free. Open source. MIT licensed." = "免費。開放原始碼。MIT 授權。"; +"What's inside" = "裡面有什麼"; +"Three things, done well." = "三件事,做到極致。"; +"Find caches, logs, broken installs, and the AI-app history hiding in your library." = "找出快取、日誌、損壞的安裝檔,以及隱藏在資料庫中的 AI App 紀錄。"; +"Drag an app, see every file it dropped, remove all of it. No leftovers." = "拖入一個 App,看到它留下的每個檔案,全部清除。不留殘餘。"; +"Surfaces files that outlived the apps that created them." = "顯示比建立它們的 App 更長壽的檔案。"; +"One permission, then we're done" = "一個權限,就夠了"; +"Permission granted" = "已授予權限"; +"PureMac can now reach the locations macOS protects by default." = "PureMac 現在可以存取 macOS 預設保護的位置。"; +"macOS hides certain folders from every app until you say otherwise. We need them to find caches and uninstall cleanly." = "在你允許之前,macOS 對所有 App 隱藏某些檔案夾。我們需要它們才能找到快取並乾淨地解除安裝。"; +"All set — moving you to the next step." = "一切就緒 - 進入下一步。"; +"Reveal app again" = "再次顯示 App"; +"Reset permissions" = "重設權限"; +"You're ready" = "已準備就緒"; +"Ready when you are" = "隨時為你準備好"; +"Hit Start to run your first Smart Scan." = "按開始執行你的第一次智慧掃描。"; +"Skip" = "略過"; +"Start" = "開始"; +"Continue" = "繼續"; + +/* Drag handle */ +"Drag to the Settings list" = "拖到設定清單"; +"Drag this icon into the Full Disk Access list in System Settings." = "將此圖示拖入系統設定中的完整磁碟存取清單。"; +"Tip: drag the icon on the left straight into the list." = "提示:把左側圖示直接拖入清單。"; +"Or drag the icon on the left straight into Settings." = "或者把左側圖示直接拖入設定。"; + +/* Dashboard storage composition (v2.7.0) */ +"Storage composition" = "儲存空間組成"; +"total" = "總計"; +"Free" = "可用"; + +/* UI polish (v2.8.0): leftover groups + sound toggle */ +"Application" = "應用程式"; +"Caches" = "快取"; +"Application Support" = "應用程式支援"; +"Preferences" = "偏好設定"; +"Logs" = "日誌"; +"Containers" = "容器"; +"Launch Agents" = "啟動代理"; +"Other Files" = "其他檔案"; +"Sound" = "聲音"; +"Play sound effects" = "播放音效"; + +/* v2.8.1: purgeable honesty */ +"Managed by macOS" = "由 macOS 管理"; +"Reserved by macOS - freed automatically when space is needed" = "由 macOS 保留 - 需要空間時自動釋放"; + +/* v2.8.2: large-file folder exclusions (#121) */ +"Excluded Folders" = "排除的資料夾"; +"Files inside these folders are skipped from the Large & Old Files scan (Downloads, Documents, Desktop)." = "這些資料夾中的檔案會在「大型與舊檔案」掃描(下載、文件、桌面)中被略過。"; +"Remove from exclusions" = "從排除項目中移除"; +"Add Folder…" = "加入資料夾…"; + +/* v2.8.2: orphan ignore list (#114) */ +"Always Ignore" = "永遠忽略"; +"Ignore Selected (%lld)" = "忽略所選 (%lld)"; +"Ignored orphans: %lld" = "已忽略的孤立檔案:%lld"; +"Forget Ignored" = "清除已忽略"; +"Ignored files won't appear in future orphan scans." = "已忽略的檔案不會出現在日後的孤立檔案掃描中。"; + +/* v2.8.3: menu-bar system monitor */ +"System Monitor" = "系統監視器"; +"Show system monitor in menu bar" = "在選單列中顯示系統監視器"; +"Live CPU, memory, and disk meters in the menu bar. PureMac keeps running in the background while this is on." = "在選單列中即時顯示 CPU、記憶體和磁碟使用情況。開啟時 PureMac 會在背景持續執行。"; +"CPU" = "CPU"; +"Memory" = "記憶體"; +"Disk" = "磁碟"; +"Open PureMac" = "開啟 PureMac"; +"Quit PureMac" = "結束 PureMac"; diff --git a/PureMacTests/AppLanguagePreferencesTests.swift b/PureMacTests/AppLanguagePreferencesTests.swift new file mode 100644 index 0000000..1dcb320 --- /dev/null +++ b/PureMacTests/AppLanguagePreferencesTests.swift @@ -0,0 +1,34 @@ +import XCTest +@testable import PureMac + +final class AppLanguagePreferencesTests: XCTestCase { + func testApplyCustomLanguageSetsAppleLanguagesAndPreservesLocale() { + let context = makeDefaults() + let defaults = context.defaults + defaults.set("pt_BR", forKey: "AppleLocale") + + AppLanguagePreferences.apply(.english, defaults: defaults) + + XCTAssertEqual(defaults.array(forKey: "AppleLanguages") as? [String], ["en"]) + XCTAssertEqual(defaults.string(forKey: "AppleLocale"), "pt_BR") + } + + func testApplySystemLanguageRemovesAppleLanguagesAndPreservesLocale() { + let context = makeDefaults() + let defaults = context.defaults + defaults.set(["en"], forKey: "AppleLanguages") + defaults.set("pt_BR", forKey: "AppleLocale") + + AppLanguagePreferences.apply(.system, defaults: defaults) + + XCTAssertNil(defaults.persistentDomain(forName: context.suiteName)?["AppleLanguages"]) + XCTAssertEqual(defaults.string(forKey: "AppleLocale"), "pt_BR") + } + + private func makeDefaults() -> (defaults: UserDefaults, suiteName: String) { + let suiteName = "PureMacTests.AppLanguagePreferences.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + return (defaults, suiteName) + } +} diff --git a/PureMacTests/AppStateTests.swift b/PureMacTests/AppStateTests.swift new file mode 100644 index 0000000..f87c6c4 --- /dev/null +++ b/PureMacTests/AppStateTests.swift @@ -0,0 +1,61 @@ +import AppKit +import XCTest +@testable import PureMac + +@MainActor +final class AppStateTests: XCTestCase { + func testScanForAppFilesTracksLocationsWhileResultsArePending() throws { + var completion: ((Set) -> Void)? + let expectedLocations = ["/one", "/two", "/three"] + let appState = AppState( + performStartupTasks: false, + locationsProvider: { + StubLocations(paths: expectedLocations) + }, + appFileScanner: { _, locations, pendingCompletion in + XCTAssertEqual(locations.appSearch.paths, expectedLocations) + completion = pendingCompletion + } + ) + + appState.scanForAppFiles(makeApp()) + + XCTAssertTrue(appState.isScanningAppFiles) + XCTAssertTrue(appState.discoveredFiles.isEmpty) + XCTAssertEqual(appState.currentAppFileSearchLocationCount, expectedLocations.count) + + let pendingCompletion = try XCTUnwrap(completion) + let urls: Set = [ + URL(fileURLWithPath: "/tmp/B"), + URL(fileURLWithPath: "/tmp/A") + ] + + pendingCompletion(urls) + + XCTAssertFalse(appState.isScanningAppFiles) + XCTAssertEqual( + appState.discoveredFiles, + urls.sorted { $0.path < $1.path } + ) + XCTAssertEqual(appState.selectedFiles, urls) + XCTAssertEqual(appState.currentAppFileSearchLocationCount, urls.count) + } + + private func makeApp() -> InstalledApp { + InstalledApp( + id: UUID(), + appName: "PureMac", + bundleIdentifier: "com.puremac.app", + path: URL(fileURLWithPath: "/Applications/PureMac.app"), + icon: NSImage(size: NSSize(width: 32, height: 32)), + size: 1 + ) + } +} + +private final class StubLocations: Locations { + init(paths: [String]) { + super.init() + appSearch = SearchCategory(name: "Apps", paths: paths) + } +} diff --git a/PureMacTests/LocalizationFilesTests.swift b/PureMacTests/LocalizationFilesTests.swift new file mode 100644 index 0000000..949abbb --- /dev/null +++ b/PureMacTests/LocalizationFilesTests.swift @@ -0,0 +1,60 @@ +import XCTest + +final class LocalizationFilesTests: XCTestCase { + func testAllLocalizableStringsFilesHaveEnglishKeyParity() throws { + let localizationFiles = try localizableStringsFiles() + let englishURL = try XCTUnwrap( + localizationFiles["en"], + "Expected en.lproj/Localizable.strings to exist" + ) + let englishKeys = try localizedKeys(in: englishURL) + + for (language, fileURL) in localizationFiles where language != "en" { + let languageKeys = try localizedKeys(in: fileURL) + let missingKeys = englishKeys.subtracting(languageKeys).sorted() + let extraKeys = languageKeys.subtracting(englishKeys).sorted() + + XCTAssertTrue( + missingKeys.isEmpty, + "\(language).lproj/Localizable.strings is missing keys:\n\(missingKeys.joined(separator: "\n"))" + ) + XCTAssertTrue( + extraKeys.isEmpty, + "\(language).lproj/Localizable.strings has extra keys:\n\(extraKeys.joined(separator: "\n"))" + ) + } + } + + private func localizableStringsFiles() throws -> [String: URL] { + let sourceRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + let appSourceDirectory = sourceRoot.appendingPathComponent("PureMac") + let contents = try FileManager.default.contentsOfDirectory( + at: appSourceDirectory, + includingPropertiesForKeys: [.isDirectoryKey] + ) + + return contents.reduce(into: [String: URL]()) { result, url in + guard url.pathExtension == "lproj", + FileManager.default.fileExists(atPath: url.appendingPathComponent("Localizable.strings").path) + else { + return + } + + result[url.deletingPathExtension().lastPathComponent] = url.appendingPathComponent("Localizable.strings") + } + } + + private func localizedKeys(in fileURL: URL) throws -> Set { + let data = try Data(contentsOf: fileURL) + let plist = try PropertyListSerialization.propertyList(from: data, options: [], format: nil) + + guard let strings = plist as? [String: String] else { + XCTFail("\(fileURL.path) is not a valid Localizable.strings dictionary") + return [] + } + + return Set(strings.keys) + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..52fa704 --- /dev/null +++ b/README.md @@ -0,0 +1,254 @@ +

+ PureMac dashboard — animated storage health ring and live composition donut +

+ +

+ PureMac scan results — by-category breakdown chart +

+ +

+ English | + العربية | + Español | + 日本語 | + 简体中文 | + 繁體中文 +

+ +

PureMac

+ +

+ Reclaim your Mac.
+ Free, open-source uninstaller and cleaner for macOS. No subscription, no telemetry, no upsell. +

+ +

+ Latest Release + macOS 13.0+ + Signed & Notarized by Apple + No telemetry + MIT License + Stars + Downloads +

+ +

+ Install - + Why this exists - + How it compares - + Our promise - + What it does - + Permissions - + Contributing +

+ +

+ Want more open source? Try Pesty - a free, native clipboard manager for macOS. +

+ +--- + +## Install + +```bash +brew install --cask puremac +``` + +Or download the signed, notarized `.dmg` from [Releases](https://github.com/momenbasel/PureMac/releases/latest) and drag PureMac into `/Applications`. No Gatekeeper warnings, no quarantine workaround. + +### Build from source + +```bash +brew install xcodegen +git clone https://github.com/momenbasel/PureMac.git +cd PureMac +xcodegen generate +xcodebuild -project PureMac.xcodeproj -scheme PureMac -configuration Release \ + -derivedDataPath build build +open build/Build/Products/Release/PureMac.app +``` + +## How it compares + +| | **PureMac** | CleanMyMac | Pearcleaner | Mole | OnyX | +|---|:---:|:---:|:---:|:---:|:---:| +| Price | **Free** | $40+/yr | Free | CLI free / GUI paid | Free | +| Open source | **Yes (MIT)** | No | Source-available¹ | CLI only | No | +| Native Mac GUI | **Yes** | Yes | Yes | Terminal-first | Yes | +| No telemetry | **Yes** | No | Yes | Yes | Yes | +| No subscription | **Yes** | No | Yes | — | Yes | +| Signed + notarized | **Yes** | Yes | Yes | — | Yes | +| App uninstaller + orphans | **Yes** | Yes | Yes | Partial | No | +| Trash-only (recoverable) | **Yes** | Partial | Yes | Partial | No | +| Honest about purgeable space | **Yes** | No | n/a | n/a | n/a | + +¹ Pearcleaner is Apache 2.0 **+ Commons Clause** - source-available but not OSI-approved (you may not sell it). PureMac is true MIT. Comparison reflects publicly documented features as of 2026; corrections welcome via PR. + +## Our promise + +A Mac cleaner asks for the deepest permission macOS grants - Full Disk Access - and then deletes your files. That demands a level of trust the category has spent twenty years burning. Here's the contract PureMac holds itself to, and you can verify every line of it in the source: + +- **Trash, never `rm`.** Everything PureMac removes goes to the Trash via `FileManager.trashItem`. If it was wrong, you drag it back. Nothing is shredded or unlinked. +- **No telemetry, ever.** No analytics, no crash reporting, no "anonymous usage stats," no network calls to us. The app doesn't know you exist. +- **No fake urgency.** No dramatized "47 GB of junk detected!" badge, no red alarm counters, no "your Mac is at risk." We show you neutral facts and let you decide. +- **No overpromising.** We don't claim to "reclaim purgeable space," "boost RAM," or "speed up your Mac" - things no app can reliably do. See the purgeable-space note below. +- **You review before anything is removed.** Nothing is auto-deleted. Every item shows its real path with Reveal-in-Finder, and high-risk system paths are hard-excluded in code. +- **Auditable.** It's MIT. The exact code that decides what gets removed is in [`PureMac/Services`](PureMac/Services) and [`PureMac/Logic/Scanning`](PureMac/Logic/Scanning). Read it. Fork it. Ship your own. + +If PureMac ever adds telemetry, a paywall on core features, or a fear-based scan, it will have become the thing it was built to replace. Hold us to this. + +## Why this exists + +Apple sells base-model Macs with 256 GB SSDs that you can't upgrade. The Mac mini, the Air, every entry-level MacBook Pro - the drive is soldered down. The next storage tier costs more than a midrange Windows laptop. Once you've paid it, every gigabyte you've already bought matters. + +Most Mac cleaners are subscription apps that hide their disk usage behind a paywall, ship telemetry by default, and trade on FUD ("47 GB of junk detected!"). PureMac is the opposite: + +- **One-time install.** No subscription, no trial, no account. +- **No telemetry.** It never phones home. It doesn't even know you exist. +- **Open source under MIT.** Read the code, fork it, audit it. +- **Honest scans.** "Junk" means actually-junk: cache directories the OS itself would purge, orphaned files left by apps you've already deleted, broken installer receipts, that 4 GB Xcode DerivedData blob from 2023. +- **Real uninstalls.** Drag an app, see every preference plist, cache folder, container, launch agent and log file it dropped across your library, remove all of it at once. + +## What it does + +### App Uninstaller +Discovers everything in `/Applications` and `~/Applications`, then uses a 10-level matching engine (bundle ID, team identifier, entitlements, Spotlight metadata, container discovery, company-name heuristics, partial path matches) to find every file the app dropped on your disk. Three sensitivity tiers - Strict, Enhanced, Deep - let you choose how aggressive that match is. Apple system apps are excluded from the uninstall list automatically. You can also right-click any app in Finder and choose **Services → Uninstall with PureMac** to jump straight into its related-files scan. + +### Orphan Finder +Walks `~/Library` and surfaces files left behind by apps that no longer exist on disk. The matcher compares against bundle identifiers and normalized names of every installed app, so a leftover `~/Library/Containers/com.foo.bar` from an app you deleted in 2022 shows up clearly. + +### System Cleaner +Smart Scan runs every category in parallel. Each category is its own deliberate scanner: + +- **System Junk** - system caches, logs, temp files +- **User Cache** - dynamically discovered, no hardcoded app list +- **AI Apps** - Ollama and LM Studio logs, caches, opt-in history cleanup +- **Mail Files** - downloaded mail attachments +- **Trash Bins** - empties all bins, including external volumes +- **Large & Old Files** - >100 MB or older than 1 year (never auto-selected) +- **Xcode Junk** - DerivedData, Archives, simulator caches +- **Brew Cache** - respects custom `HOMEBREW_CACHE` +- **Node Cache** - npm, yarn classic, pnpm content-addressable store +- **Docker Cache** - images, containers, build cache + +> **On "purgeable space":** PureMac shows your APFS purgeable space in the storage breakdown for transparency, but it deliberately does **not** list it as junk to delete. Purgeable space is reserved and reclaimed by macOS itself - no third-party app can reliably free it, and even the Finder's purgeable figure is known to be inaccurate. Cleaners that claim to "reclaim purgeable space" are overpromising. We'd rather be honest than impressive. + +### Scheduled Cleaning +Optional. Configurable interval (hourly to monthly), with auto-clean threshold so background runs only fire when there's something meaningful to remove. + +## Permissions + +PureMac needs **Full Disk Access** to read the locations macOS hides from every app by default - Mail downloads, Safari data, the TCC database, protected app containers. Without it, the cleanup categories miss roughly 70% of what they could find and app uninstalls leave behind everything in `~/Library/Containers`. + +The first-launch onboarding walks you through granting it with an animated preview of the exact toggle you need to flip. If you skip it, the dashboard surfaces a single-click "Set up" pill. If a cleanup fails because of a permission issue, PureMac opens System Settings, reveals its bundle in Finder so you can drag it into the FDA list, polls the permission state every second, and auto-retries the failed batch the moment you grant access. You never have to re-select anything. + +What PureMac does *not* do: +- It does not collect telemetry, crash reports, or usage analytics. +- It does not require a network connection to operate. +- It does not move data anywhere except the Trash. + +## Troubleshooting + +### Launchpad / Dock shows a stale or dull PureMac icon + +macOS aggressively caches app icons in LaunchServices. After a Homebrew **reinstall or upgrade** the Dock and Launchpad can keep showing the old cached icon. PureMac's cask now runs `lsregister -f` on install to refresh it automatically, but if a stale icon persists, reset the cache manually: + +```bash +# Clear the icon caches and rebuild the LaunchServices database +sudo rm -rfv /Library/Caches/com.apple.iconservices.store +sudo find /private/var/folders/ \( -name com.apple.dock.iconcache -or -name com.apple.iconservices \) -exec rm -rfv {} \; 2>/dev/null +/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister \ + -kill -r -domain local -domain user -domain system +killall Dock; killall Finder +``` + +Give it a minute to re-seed, then open PureMac once. If it still sticks, a restart (or Safe Mode boot) forces a full rebuild. + +## Screenshots + +| Onboarding | App Uninstaller | +|---|---| +| ![Onboarding](screenshots/onboarding.png) | ![App Uninstaller](screenshots/app-uninstaller.png) | + +| System Junk | Xcode Junk | +|---|---| +| ![System Junk](screenshots/system-junk.png) | ![Xcode Junk](screenshots/xcode-junk.png) | + +| User Cache | +|---| +| ![User Cache](screenshots/user-cache.png) | + +## Architecture + +``` +PureMac/ + Logic/Scanning/ - Heuristic scan engine, locations database, conditions + Logic/Utilities/ - Structured logging + Models/ - Data models, typed errors + Services/ - Scan engine, cleaning engine, permission coordinator, scheduler + ViewModels/ - Centralized app state + Views/ - Native SwiftUI views + Apps/ - App uninstaller views + Components/ - Shared components (FDA demo, permission sheet, theme) + Orphans/ - Orphan finder + Settings/ - Native Form-based settings +``` + +Key components: +- **AppPathFinder** - 10-level heuristic matching engine for discovering app-related files +- **Locations** - 120+ macOS filesystem search paths +- **Conditions** - 25 per-app matching rules for edge cases (Xcode, Chrome, VS Code, etc.) +- **PermissionCoordinator** - Single source of truth for FDA prompts, polling, and post-grant retries +- **FullDiskAccessManager** - TCC probe + registration; broad probe paths (Mail, Safari, Messages, AddressBook, Calendars) so macOS catalogs the bundle reliably +- **CleaningEngine** - Symlink-resistant deletion with an allow-list, NSAppleScript-based admin escalation for root-owned items, NUL-separated path staging for xargs + +## Security + +- Symlink attack prevention: paths are resolved before validation, re-resolved immediately before unlink to close TOCTOU windows. +- Allow-list cleaning: a path that doesn't sit inside an explicit safe-root is refused, even for the user-level pass. +- Admin escalation is gated by a *narrower* allow-list (app bundles, package receipts, launch plists) than the normal cleaner — root-owned items can only be removed inside those roots. +- System app protection: Apple's bundles cannot be uninstalled, regardless of selection. +- All destructive operations require explicit confirmation by default. The toggle that disables that confirmation is buried in Settings. + +If you find a vulnerability, please open a private security advisory rather than a public issue. + +## Contributing + +Pull requests welcome. See [CONTRIBUTING.md](CONTRIBUTING.md). + +Especially welcome: +- Per-category size and date filter presets +- Wider XCTest coverage for `AppState` and the scan engine +- Translations beyond the current set (en, ar, es, ja, pt-BR, zh-Hans, zh-Hant) +- App icon design + +## Acknowledgments + +- **[@nguyenhuy158](https://github.com/nguyenhuy158)** - Search and filter feature ([#18](https://github.com/momenbasel/PureMac/issues/18), [#29](https://github.com/momenbasel/PureMac/pull/29)) +- **[@edufalcao](https://github.com/edufalcao)** - Cleaning safety guards and confirmation dialogs ([#30](https://github.com/momenbasel/PureMac/pull/30)) +- **[@zeck00](https://github.com/zeck00)** - UI overhaul ([#31](https://github.com/momenbasel/PureMac/pull/31)), app uninstaller with system app protection ([#32](https://github.com/momenbasel/PureMac/pull/32)), onboarding experience ([#33](https://github.com/momenbasel/PureMac/pull/33)) +- **[@0x-man](https://github.com/0x-man)** - Symlink security vulnerability report ([#25](https://github.com/momenbasel/PureMac/issues/25)) +- **[@ansidev](https://github.com/ansidev)** - Checkbox interaction bug report ([#34](https://github.com/momenbasel/PureMac/issues/34)) +- **[@fengcheng01](https://github.com/fengcheng01)** - App uninstaller feature request ([#28](https://github.com/momenbasel/PureMac/issues/28)) +- **[@scholzfuni](https://github.com/scholzfuni)** - Modularization proposal ([#23](https://github.com/momenbasel/PureMac/issues/23)) +- **[@Zonharo](https://github.com/Zonharo)** - In-app auto-update request ([#94](https://github.com/momenbasel/PureMac/issues/94)) + +## Star History + +If PureMac saved you some disk space, a star helps other people find it. + + + + + + PureMac star history chart + + + +## More open source + +- **[Pesty](https://github.com/momenbasel/pesty)** - a free, open-source clipboard manager for macOS. Color-coded history, pinboards, instant search, keyboard-fast paste. Signed, notarized, `brew install --cask momenbasel/pesty/pesty`. + +## License + +MIT. See [LICENSE](LICENSE). Use it, fork it, ship it under your own name if you want - the only thing the license asks is that the notice stays. diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..2075c13 --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub 来源说明 + +- 原始项目:`momenbasel/PureMac` +- 原始仓库:https://github.com/momenbasel/PureMac +- 导入方式:上游默认分支的最新快照 +- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准 +- 本文件仅用于记录来源,不代表 WeHub 是原项目作者 diff --git a/docs/IMPROVEMENT-RESEARCH.md b/docs/IMPROVEMENT-RESEARCH.md new file mode 100644 index 0000000..6ba4f55 --- /dev/null +++ b/docs/IMPROVEMENT-RESEARCH.md @@ -0,0 +1,154 @@ +# PureMac Improvement Research + +> Multi-agent deep research (CleanMyMac UX, SwiftUI motion, feature gaps, icon/Launchpad cache). Generated for the v2.7.0 polish pass. + +Deployment target is macOS 13.0 (constrains some APIs). I have everything needed to write the report. + +# PureMac -> CleanMyMac-Grade: Engineering Report + +**Constraint baseline:** Deployment target is `MACOSX_DEPLOYMENT_TARGET = 13.0` (confirmed in `.pbxproj`). This gates several premium APIs. `PhaseAnimator`/`KeyframeAnimator`/`.symbolEffect`/`.contentTransition(.numericText)`/`SectorMark` selection require macOS 14+; `.scrollTransition`/`.visualEffect` are macOS 14+; `TextRenderer`/zoom transitions are macOS 15+. **Recommendation: raise the floor to macOS 14.0** — it unlocks ~80% of the premium toolkit (Swift Charts `SectorMark`, symbol effects, numeric text, phase/keyframe animators) at minimal user-base cost given macOS 14 shipped 2023. Where I list a 14+ API, a macOS 13 fallback is noted. Existing confetti, dashboard, and onboarding files are at `PureMac/Views/{DashboardView.swift, OnboardingView.swift, Components/ConfettiView.swift}`. + +--- + +## 1. UI/UX Direction — SwiftUI Changes for CleanMyMac-Grade Polish + +CleanMyMac's premium feel is not one trick; it is five stacked systems — organic geometry, a warm gradient color language, sensory layering (parallax + translucency + sound), tight micro-interaction timing, and glassmorphic depth [cleanmymac-ux ^1][^3]. Translate each into the existing SwiftUI surfaces. + +### 1.1 Dashboard — tile-based information architecture +Replace any list-first dashboard with a `LazyVGrid` of 2-3 module tiles (Cleanup, Apps, Orphans, Storage, Large/Old, Schedule), each a summary stat + "Review"/"Clean" actions — progressive disclosure removes the wall-of-text intimidation factor [cleanmymac-ux ^10]. Edit `PureMac/Views/DashboardView.swift`. + +- Each tile gets its **own gradient pair** for wayfinding without labels (Cleanup pink→blue, Storage blue→purple, Apps pink→lavender) [cleanmymac-ux ^1]. +- Tile container: `.background(.ultraThinMaterial)` + `.overlay(RoundedRectangle(cornerRadius: 16).stroke(.white.opacity(0.18), lineWidth: 1))`, placed above a `LinearGradient` window backdrop. Glassmorphism only shines over vibrant saturated backgrounds — not flat gray [cleanmymac-ux ^7][^8]. +- Hover elevation per tile: `.scaleEffect(isHovered ? 1.03 : 1.0)` + `.shadow(radius: isHovered ? 30 : 10)` + `.animation(.smooth(duration: 0.22), value: isHovered)`. Tap feedback `.scaleEffect(0.92)` with `.snappy(duration: 0.18)` [cleanmymac-ux ^4][^6]. + +### 1.2 Geometry — organic, not mechanical +Standardize on 16pt corner radius minimum; avoid perfect circles and hard corners. CleanMyMac deliberately uses superellipse/squircle shapes because "sterile, perfect objects repel people" [cleanmymac-ux ^2][^3]. SwiftUI: `RoundedRectangle(cornerRadius: 16, style: .continuous)` (the `.continuous` style is the system squircle — use it everywhere, it is the single cheapest organic-feel win). + +### 1.3 Scan reveal — sequential, masked, motion-designed +The existing radial gauges are the right foundation. Upgrade the reveal: +- **Gauge fill**: keep `Circle().trim(from:0,to:progress).stroke(gradient, lineWidth:)` with `.rotationEffect(.degrees(-90))` and `.animation(.linear, value: progress)` for the live sweep [swiftui-motion]. +- **Results stagger**: as categories resolve, reveal tiles sequentially (not simultaneously) — `.transition(.scale.combined(with:.opacity))` with per-tile `.animation(.spring(duration:0.5, bounce:0.2).delay(index * 0.06))`. Sequential timing is what reads as "designed" vs. a dump [cleanmymac-ux ^11]. +- **Freed-space counter**: animate the GB number with `.contentTransition(.numericText(value:))` (macOS 14+; on 13 fall back to a `TimelineView`-driven interpolated `Text`) [swiftui-motion]. +- **Completion**: spring settle on the gauge + `withAnimation(...) { } completion: { Haptics.success(); confetti }` using the completion-handler API so haptic/confetti fire exactly when the animation settles, not on a guessed timer [swiftui-motion]. + +### 1.4 Motion timing table (apply globally) +| Interaction | Duration | Curve | Effect | +|---|---|---|---| +| Tap | 180ms | `.snappy` | scale 1.0→0.92 | +| Hover | 220ms | `.smooth` | scale 1.0→1.03, shadow 10→30 | +| Reveal/context change | 400-600ms | spring bounce 0.2 | tile/scale-in | +| Idle pulse (CTA) | 1200ms loop | `.repeatForever(autoreverses:)` | scale 1.0→1.08 | + +Keep bounce ≤ 0.30 for UI; never exceed 0.40 [cleanmymac-ux ^4][^6][swiftui-motion]. CTAs and category icons get SF Symbol feedback via `.symbolEffect(.bounce, value:)` on completion and `.symbolEffect(.pulse)` while scanning (macOS 14+; free + accessible) [swiftui-motion]. + +### 1.5 Graphs / charts — the biggest visible gap +Adopt **Swift Charts** (macOS 14+) for storage breakdown — this is the single feature that most reads as "premium graphical tool": +- **Donut** via `SectorMark(angle:.value(...), innerRadius:.ratio(0.618), angularInset:1.5).cornerRadius(5).foregroundStyle(by:)`, with `.chartBackground` centering total-storage text in the hole [swiftui-motion]. +- **Segmented usage bar** for the dashboard header via `BarMark` stacked by file-type category. +- **Interactivity**: `.chartAngleSelection` to dim non-selected sectors to 0.3 opacity on hover/click [swiftui-motion]. +- macOS 13 fallback: `Canvas` arc drawing (hardware-accelerated) for the donut — pattern in [swiftui-motion]. Existing gauges already prove the team can do `Canvas`/`trim`. + +### 1.6 Color & depth +Define 3 module gradient pairs as `Theme` tokens (extend `PureMac/Extensions/Theme.swift`); test all over light/dark via `NSColor` dynamic providers. Use `.ultraThinMaterial` for floating panels, vibrant gradient for window background — layered planes are what flat design cannot reproduce [cleanmymac-ux ^1][^7][^8]. Add a subtle parallax: a low-opacity (0.6-0.8) gradient layer with a 2-3s offset loop behind the hero gauge for atmospheric depth [cleanmymac-ux ^1]. + +### 1.7 Sound + accessibility (mandatory) +- Success chime `NSSound(named: "Glass")?.play()`, error `NSSound(named: "Basso")` — fire in sync with the visual, not after [cleanmymac-ux ^3][^9]. macOS has no Taptic Engine; the existing `Haptics.swift` likely uses `NSHapticFeedbackManager` for trackpad — keep that paired with sound. +- **Gate every animation** on `@Environment(\.accessibilityReduceMotion)`: `.animation(reduceMotion ? nil : .spring, value:)`. When motion conveys status, swap for a dissolve/color-fade, don't just drop it. App Store reduced-motion criteria require this [swiftui-motion]. + +--- + +## 2. Confetti Redesign + +Current `ConfettiView.swift` is a `CAEmitterLayer` line emitter dropping 6 rounded-rect colors straight down with `yAcceleration: 240`, `spin: 4`, `scale: 0.35`. It reads "horrible" because: (a) gravity-only fall with no upward burst looks like falling debris, not celebration; (b) flat 2D rectangles with no 3D tumble; (c) saturated primary palette clashes with a premium muted aesthetic [cleanmymac-ux ^3 — "muted, timeless palette, not neon acid tones"]; (d) uniform scale and a single rectangle shape. + +**Tasteful replacement — two viable paths:** + +**Option A (recommended, on-brand, low risk): refine the existing emitter.** Keep `CAEmitterLayer` (it correctly stays off the SwiftUI render thread — that part is right) but redesign the physics and look: +- **Center-burst, not top-drop**: `emitterShape = .point`, `emitterPosition` at the gauge center, `emissionRange = .pi * 2` (full radial), high initial `velocity` (~300) with negative-then-positive arc via `yAcceleration` — particles shoot up/out then gently fall. This is celebratory, not raining. +- **3D tumble**: drive `emissionLongitude` variation + higher `spinRange`, and ship 2-3 particle shapes (thin rectangle, small circle, tiny streamer) instead of one rounded rect. +- **Muted premium palette**: desaturate the current colors ~20-30% and add the module gradient hues (soft pink, periwinkle, mint, peach) — drop the pure-orange/pure-yellow neon [cleanmymac-ux ^3][^5]. +- **Density & decay**: fewer particles (lower `birthRate`), faster `alphaSpeed` fade, total life ≤ 2s. Restraint reads premium. + +**Option B (most premium, macOS 14+): native SwiftUI particle burst** using `KeyframeAnimator` over a `Canvas`. Spawn ~40 particles each with `(position, rotation3D, scale, opacity)` as an `Animatable`/`VectorArithmetic` struct; keyframe an arc (SpringKeyframe up, CubicKeyframe down) with independent rotation tracks for tumble [swiftui-motion]. Fully deterministic, GPU-cheap via `Canvas`, and matches the rest of the motion system. More work than A; do it as a follow-up. + +Either way: fire on the animation **completion handler** (§1.3), respect `reduceMotion` (suppress confetti entirely, keep the sound), and trigger only on a genuine cleanup-complete with non-trivial space freed — celebration inflation cheapens it. + +--- + +## 3. Missing Features — Prioritized + +| Feature | User value | Effort | How | +|---|---|---|---| +| **Menu bar widget** | HIGH — discoverability, always-visible CTA, 1-click scan; no OSS cleaner has it [feature-gaps] | LOW (3-4d) | `NSStatusBar` item + `NSPopover` (or `MenuBarExtra` scene, macOS 13+). Show storage used/free %, "Run Scan", last-cleanup date. | +| **Disk-space visualization (sunburst/treemap)** | HIGH — the DaisyDisk/Space-Lens insight users pay $30 for; "see what eats space" without a list [feature-gaps] | MED-HIGH (2-3w) | `Canvas`/Core Graphics sunburst, click-to-drill-down, color by file type, exclude system by default. macOS 13-safe via Canvas. | +| **Duplicate file finder** | HIGH — 2-10 GB typical recovery [feature-gaps] | MED (2-3w) | Size-bucket → partial hash → full SHA fingerprint; xattr-cache hashes; filetype-aware (EXIF/MP3 tags); reference-dir safety (keep originals, delete only from chosen dirs). | +| **Large/Old files filter presets** | MED-HIGH — reuses existing scanner; power-user control [feature-gaps] | LOW (3-5d) | Add size slider + date picker + location scope to existing "Large & Old"; ship presets (Old Backups >5y, Bulky Media >1GB, Installers >500MB matching `.dmg/.pkg`); persist custom presets to `UserDefaults`. | +| **Login items / launch agents manager** | MED — boot-time bloat, no good OSS tool [feature-gaps] | MED-HIGH (1-2w) | Parse `~/Library/LaunchAgents`, `/Library/LaunchAgents`, daemons; PropertyList parse via Foundation; table (App/Label/Executable/toggle/delete); warn on Apple system agents; cross-ref `launchctl list`. | +| **Finder right-click "Uninstall with PureMac"** | MED-HIGH — removes drag/launch friction [feature-gaps] | MED (5-7d) | App Extension (Finder/Action extension) registered for `.app` bundles; launch host with bundle URL arg → pre-populate the existing uninstaller (`AppPathFinder`). | +| **Real-time monitoring (CPU/RAM/disk)** | LOW-MED — Sensei-style, mostly background value [feature-gaps] | MED (1-2w) | Defer to Phase 3. | +| **Malware/security scan** | MED (FUD-driven) [feature-gaps] | LOW value/HIGH cost (2-4w) | Defer; signature DB upkeep is a long-term liability. | + +Engine is already ahead of AppCleaner/Pearcleaner; the entire gap to CleanMyMac is UX + visualization + these secondary features, not core capability [feature-gaps]. + +--- + +## 4. App Icon + Launchpad Cache Fix + +### 4.1 Why the icon renders "abnormal" / dull +A near-white icon (≥ `#F5F5F5`) has insufficient contrast for macOS's Liquid-Glass specular/translucency layer, rendering dull/blank — worst in the Dock when the app is running [icon-launchpad]. Fix the asset, not just the cache. + +### 4.2 Design guidance (asset fixes) +Edit the source art behind `PureMac/Assets.xcassets/AppIcon.appiconset`: +- **Do NOT bake rounded corners** — the OS applies the squircle/superellipse mask (~22.37% width radius, ~60% smoothing). Provide square layers [icon-launchpad]. +- **512×512 canvas, 50px transparent padding all sides; primary content within the centered 412×412** — edge-to-edge fill makes the icon look 20-30% oversized in Dock/Launchpad [icon-launchpad]. +- **Contrast ≥ 3:1** vs. background (prefer 4.5:1+ in grids); avoid solid light grays ≥ `#D0D0D0` as the primary color; **test light + dark mode** [icon-launchpad]. Give the near-white mark a saturated/dark backplate or gradient so Liquid Glass has something to catch. +- Vector (SVG/PDF) foreground; PNG only for raster/gradient layers [icon-launchpad]. + +### 4.3 Exact cache remediation (post-Homebrew-cask reinstall) +```bash +# 1. Kill UI processes +killall Dock +killall Finder + +# 2. Clear icon caches +sudo rm -rfv /Library/Caches/com.apple.iconservices.store +sudo find /private/var/folders/ \( -name com.apple.dock.iconcache -or -name com.apple.iconservices \) -exec rm -rfv {} \; + +# 3. Rebuild LaunchServices (maps app bundles -> icons; critical after a cask relocation) +/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister -kill -r -domain local -domain user -domain system -v + +# 4. Restart Dock/Finder +sleep 3 +killall Dock +killall Finder +``` +Allow 2-5 min for the LaunchServices DB to re-seed (system runs slower during rebuild); open PureMac once to force re-registration. If still stale, Safe Mode restart (Shut down → power on holding **Shift** through login → wait 5 min → restart normally) forces a rebuild with no third-party interference [icon-launchpad]. + +Faster user-domain-only LaunchServices variant for dev iteration: append `-apps u` instead of the `-domain` flags [icon-launchpad]. + +--- + +## 5. Ship THIS Release vs. Follow-Ups + +### Ship now (low effort, high visible impact, macOS 13-safe) +1. **Confetti Option A** — refine the existing emitter (center burst, 3D tumble, muted palette, completion-handler trigger, reduce-motion gate). Hours, not days; directly fixes the "looks horrible" complaint. §2. +2. **`.continuous` corner radius + glass + hover/tap micro-interactions** across dashboard tiles — the cheapest premium-feel uplift, no new APIs. §1.1-1.4. +3. **Sound + `accessibilityReduceMotion` gating** on all existing animations — mandatory polish + App Store compliance, ~1 day. §1.7. +4. **Sequential staggered scan-reveal** using existing transitions/springs. §1.3. +5. **Large/Old files filter presets** (3-5d, reuses scanner). §3. +6. **App icon contrast/padding fix + documented cache-reset script** shipped in the README/troubleshooting. §4. + +### Follow-up release (raise floor to macOS 14 first) +7. **Menu bar widget** (`MenuBarExtra`) — highest-ROI new feature, 3-4d. §3. +8. **Swift Charts donut + segmented storage bar** with selection. §1.5. +9. **Numeric-text freed-space counter** + `.symbolEffect` icon feedback. §1.3-1.4. +10. **Confetti Option B** (native Canvas/KeyframeAnimator particles). §2. + +### Later phases +11. Disk-space sunburst visualization (2-3w). 12. Duplicate finder (2-3w). 13. Login-items manager (1-2w). 14. Finder context-menu extension (5-7d). Defer real-time monitoring + malware scan. §3. + +--- + +**Files to touch:** `PureMac/Views/DashboardView.swift` (tiles, charts, reveal), `PureMac/Views/Components/ConfettiView.swift` (redesign), `PureMac/Extensions/Theme.swift` (gradient/color tokens), `PureMac/Services/Haptics.swift` (sound+haptic pairing), `PureMac/Assets.xcassets/AppIcon.appiconset` (icon art), and the `.pbxproj` `MACOSX_DEPLOYMENT_TARGET` (13.0 → 14.0 for the follow-up wave). New: a `MenuBarExtra` scene in `PureMac/PureMacApp.swift`, a `StorageChartView`, and an `AppExtension` target for Finder integration. + +**Citation key:** [cleanmymac-ux] = CleanMyMac UX brief (Behance/Novikov ^1-3, micro-interactions ^4/^6, color ^5, glass ^7/^8, audio-haptic ^9, Smart Care ^10, reveal ^11); [swiftui-motion] = SwiftUI/macOS 2026 motion brief (Apple WWDC23-25 docs); [feature-gaps] = competitive feature brief (CleanMyMac X, DaisyDisk, Sensei, dupeGuru, Pearcleaner); [icon-launchpad] = icon/cache brief (Apple HIG, Eclectic Light, squircle.js.org, ithy.com Sequoia guide). \ No newline at end of file diff --git a/docs/README.ar.md b/docs/README.ar.md new file mode 100644 index 0000000..e9dff95 --- /dev/null +++ b/docs/README.ar.md @@ -0,0 +1,166 @@ +

+ PureMac +

+ +

+ English | + العربية | + Español | + 日本語 | + 简体中文 | + 繁體中文 +

+ +

PureMac

+ +

+ أداة مجانية ومفتوحة المصدر لإدارة التطبيقات وتنظيف النظام على macOS.
+ إزالة التطبيقات بالكامل. العثور على الملفات اليتيمة. تنظيف ملفات النظام غير المرغوبة.
+ بلا اشتراكات. بلا تتبع. بلا جمع بيانات. +

+ +

+ أحدث إصدار + حالة البناء + macOS 13.0+ + Swift 5.9 + رخصة MIT + النجوم + التنزيلات +

+ +

+ التثبيت - + الميزات - + لقطات الشاشة - + المساهمة +

+ +--- + +
+ +## التثبيت + +### Homebrew (موصى به) + +```bash +brew update +brew install --cask puremac +``` + +### التنزيل المباشر + +نزّل أحدث ملف `.dmg` من صفحة [Releases](https://github.com/momenbasel/PureMac/releases/latest)، ثم افتحه واسحب PureMac إلى `/Applications`. + +> موقّع وموثّق باستخدام Apple Developer ID — يُثبَّت دون ظهور تحذيرات Gatekeeper. + +### البناء من الكود المصدري + +```bash +brew install xcodegen +git clone https://github.com/momenbasel/PureMac.git +cd PureMac +xcodegen generate +xcodebuild -project PureMac.xcodeproj -scheme PureMac -configuration Release -derivedDataPath build build +open build/Build/Products/Release/PureMac.app +``` + +## الميزات + +### أداة إزالة التطبيقات +- اكتشاف جميع التطبيقات المثبّتة في `/Applications` و `~/Applications` +- محرّك اكتشاف ملفات اعتمادي يقوم على **المطابقة بعشرة مستويات** (مُعرِّف الحزمة، اسم الشركة، الـ entitlements، مُعرِّف الفريق، بيانات Spotlight الوصفية، اكتشاف الحاويات) +- **ثلاث مستويات حساسية**: Strict (آمن) و Enhanced (متوازن) و Deep (شامل) +- عرض جميع الملفات المرتبطة: ذاكرة التخزين المؤقت، والتفضيلات، والحاويات، والسجلات، وملفات الدعم، ووكلاء التشغيل +- حماية تطبيقات النظام — يُستبعَد 27 تطبيقًا من Apple من قائمة الإزالة +- عرض رئيسي/تفصيلي: جدول التطبيقات على اليمين، والملفات المكتشفة على اليسار + +### العثور على الملفات اليتيمة +- اكتشاف الملفات المتبقية في `~/Library` من تطبيقات سبق إزالتها +- مقارنة محتوى الـ Library بمُعرِّفات جميع التطبيقات المثبّتة +- تنظيف الملفات اليتيمة بنقرة واحدة + +### تنظيف النظام +- **الفحص الذكي** — فحص بنقرة واحدة لجميع الفئات +- **ملفات النظام غير المرغوبة** — ذاكرة التخزين المؤقت للنظام والسجلات والملفات المؤقتة +- **ذاكرة التخزين المؤقت للمستخدم** — اكتشاف ديناميكي لذاكرة التخزين المؤقت لكل التطبيقات (دون قوائم مكتوبة مسبقًا) +- **مرفقات البريد** — مرفقات البريد التي تم تنزيلها +- **سلة المهملات** — تفريغ كل سلال المهملات +- **الملفات الكبيرة والقديمة** — ملفات يزيد حجمها عن 100 ميغابايت أو أقدم من سنة +- **المساحة القابلة للإزالة** — اكتشاف المساحة القابلة للإزالة في APFS +- **ملفات Xcode غير المرغوبة** — DerivedData والأرشيفات وذاكرة التخزين المؤقت للمحاكيات +- **ذاكرة التخزين المؤقت لـ Brew** — ذاكرة تنزيلات Homebrew (مع دعم HOMEBREW_CACHE مخصص) +- **التنظيف المجدوَل** — فحص تلقائي وفق فترات قابلة للتخصيص + +### تجربة أصيلة في macOS +- مبنيّ على SwiftUI باستخدام مكوّنات macOS الأصلية +- `NavigationSplitView` و `Toggle` و `ProgressView` و `Form` و `GroupBox` و `Table` +- يتبع المظهر الفاتح/الداكن للنظام تلقائيًا +- دون تدرّجات لونية مخصصة أو أساليب ويب دخيلة +- إعداد تمهيدي عند أول تشغيل يشمل ضبط الوصول الكامل إلى القرص + +### الأمان +- تأكيد قبل كل عملية حذف حاسمة +- حماية من هجمات الروابط الرمزية — يتم حل المسارات والتحقق منها قبل الحذف +- حماية تطبيقات النظام — لا يمكن إزالة تطبيقات Apple +- لا يتم تحديد الملفات الكبيرة والقديمة تلقائيًا +- تسجيل منظّم عبر `os.log` (يظهر في تطبيق "وحدة التحكم") + +
+ +## لقطات الشاشة + +| الإعداد التمهيدي | أداة إزالة التطبيقات | +|---|---| +| ![الإعداد التمهيدي](../screenshots/onboarding.png) | ![أداة إزالة التطبيقات](../screenshots/app-uninstaller.png) | + +| ملفات النظام غير المرغوبة | ملفات Xcode غير المرغوبة | +|---|---| +| ![ملفات النظام غير المرغوبة](../screenshots/system-junk.png) | ![ملفات Xcode غير المرغوبة](../screenshots/xcode-junk.png) | + +| ذاكرة التخزين المؤقت للمستخدم | +|---| +| ![ذاكرة التخزين المؤقت للمستخدم](../screenshots/user-cache.png) | + +
+ +## البنية المعمارية + +``` +PureMac/ + Logic/Scanning/ - محرّك الفحص الاعتمادي وقاعدة المواقع والشروط + Logic/Utilities/ - التسجيل المنظّم + Models/ - نماذج البيانات والأخطاء المُنمّطة + Services/ - محرّك الفحص ومحرّك التنظيف والمجدوِل + ViewModels/ - حالة التطبيق المركزية + Views/ - واجهات SwiftUI الأصلية + Apps/ - واجهات أداة إزالة التطبيقات + Cleaning/ - الفحص الذكي وواجهات الفئات + Orphans/ - أداة الملفات اليتيمة + Settings/ - إعدادات قائمة على Form الأصلي + Components/ - مكوّنات مشتركة +``` + +المكوّنات الأساسية: +- **AppPathFinder** — محرّك مطابقة اعتمادي بعشرة مستويات لاكتشاف ملفات التطبيقات +- **Locations** — أكثر من 120 مسارًا للبحث في نظام ملفات macOS +- **Conditions** — 25 قاعدة مطابقة خاصة بتطبيقات بعينها للحالات الاستثنائية (Xcode وChrome وVS Code وغيرها) +- **AppInfoFetcher** — بيانات Spotlight الوصفية مع اعتماد Info.plist بديلًا لاكتشاف التطبيقات +- **Logger** — تسجيل موحّد عبر `os.log` من Apple + +## المساهمة + +المساهمات مرحّب بها. راجع [CONTRIBUTING.md](../CONTRIBUTING.md) للاطلاع على الإرشادات. + +المجالات التي تحظى بترحيب خاص: +- إعدادات جاهزة لفلاتر الحجم والتاريخ في واجهات الفئات +- تغطية باستخدام XCTest لـ AppState ومحرّك الفحص +- الترجمة إلى لغات إضافية +- تصميم أيقونة التطبيق + +## الرخصة + +رخصة MIT. راجع [LICENSE](../LICENSE) للتفاصيل. + +
diff --git a/docs/README.es.md b/docs/README.es.md new file mode 100644 index 0000000..d3b1906 --- /dev/null +++ b/docs/README.es.md @@ -0,0 +1,160 @@ +

+ PureMac +

+ +

+ English | + العربية | + Español | + 日本語 | + 简体中文 | + 繁體中文 +

+ +

PureMac

+ +

+ Gestor de aplicaciones y limpiador de sistema para macOS, gratuito y de código abierto.
+ Desinstala apps por completo. Encuentra archivos huérfanos. Limpia la basura del sistema.
+ Sin suscripciones. Sin telemetría. Sin recolección de datos. +

+ +

+ Última versión + Estado de build + macOS 13.0+ + Swift 5.9 + Licencia MIT + Estrellas + Descargas +

+ +

+ Instalación - + Características - + Capturas - + Contribuir +

+ +--- + +## Instalación + +### Homebrew (recomendado) + +```bash +brew update +brew install --cask puremac +``` + +### Descarga directa + +Descarga el `.dmg` más reciente desde [Releases](https://github.com/momenbasel/PureMac/releases/latest), ábrelo y arrastra PureMac a `/Applications`. + +> Firmado y notarizado con Apple Developer ID — se instala sin advertencias de Gatekeeper. + +### Compilar desde el código fuente + +```bash +brew install xcodegen +git clone https://github.com/momenbasel/PureMac.git +cd PureMac +xcodegen generate +xcodebuild -project PureMac.xcodeproj -scheme PureMac -configuration Release -derivedDataPath build build +open build/Build/Products/Release/PureMac.app +``` + +## Características + +### Desinstalador de apps +- Descubre todas las apps instaladas desde `/Applications` y `~/Applications` +- Motor heurístico de búsqueda de archivos con **10 niveles de coincidencia** (bundle ID, nombre de la empresa, entitlements, team identifier, metadatos de Spotlight, descubrimiento de contenedores) +- **3 niveles de sensibilidad**: Estricto (seguro), Mejorado (equilibrado), Profundo (exhaustivo) +- Muestra todos los archivos relacionados: cachés, preferencias, contenedores, registros, archivos de soporte, launch agents +- Protección de apps del sistema: 27 apps de Apple están excluidas de la lista +- Vista maestro-detalle: tabla de apps a la izquierda, archivos descubiertos a la derecha + +### Buscador de archivos huérfanos +- Detecta archivos sobrantes en `~/Library` de apps ya desinstaladas +- Compara el contenido de la Biblioteca con los identificadores de todas las apps instaladas +- Limpieza de archivos huérfanos con un clic + +### Limpiador del sistema +- **Análisis inteligente** — análisis de un clic en todas las categorías +- **Basura del sistema** — cachés del sistema, registros y archivos temporales +- **Caché de usuario** — descubre dinámicamente todos los cachés de apps (sin lista predefinida) +- **Apps de IA** — registros, cachés y limpieza opcional del historial local de Ollama y LM Studio +- **Adjuntos de correo** — adjuntos de correo descargados +- **Papeleras** — vacía todas las papeleras +- **Archivos grandes y antiguos** — archivos de más de 100 MB o con más de 1 año +- **Espacio purgable** — detección de espacio purgable APFS +- **Basura de Xcode** — DerivedData, Archives, cachés de simuladores +- **Caché de Brew** — caché de descargas de Homebrew (detecta HOMEBREW_CACHE personalizado) +- **Limpieza programada** — análisis automático en intervalos configurables + +### Experiencia nativa de macOS +- Desarrollado con SwiftUI usando componentes nativos de macOS +- `NavigationSplitView`, `Toggle`, `ProgressView`, `Form`, `GroupBox`, `Table` +- Respeta el modo claro/oscuro del sistema automáticamente +- Sin gradientes personalizados, resplandores ni estilos de app web +- Onboarding de primer arranque con configuración de acceso total al disco + +### Seguridad +- Diálogos de confirmación antes de cualquier operación destructiva +- Prevención de ataques por enlaces simbólicos — resuelve y valida rutas antes de eliminar +- Protección de apps del sistema — las apps de Apple no se pueden desinstalar +- Los archivos grandes y antiguos nunca se seleccionan automáticamente +- El historial de prompts y conversaciones de IA se muestra para revisión, pero nunca se selecciona automáticamente +- Registro estructurado con `os.log` (visible en Consola.app) + +## Capturas + +| Onboarding | Desinstalador de apps | +|---|---| +| ![Onboarding](../screenshots/onboarding.png) | ![Desinstalador de apps](../screenshots/app-uninstaller.png) | + +| Basura del sistema | Basura de Xcode | +|---|---| +| ![Basura del sistema](../screenshots/system-junk.png) | ![Basura de Xcode](../screenshots/xcode-junk.png) | + +| Caché de usuario | +|---| +| ![Caché de usuario](../screenshots/user-cache.png) | + +## Arquitectura + +``` +PureMac/ + Logic/Scanning/ - Motor heurístico de escaneo, base de ubicaciones, condiciones + Logic/Utilities/ - Registro estructurado + Models/ - Modelos de datos, errores tipados + Services/ - Motor de escaneo, motor de limpieza, programador + ViewModels/ - Estado centralizado de la app + Views/ - Vistas nativas de SwiftUI + Apps/ - Vistas del desinstalador + Cleaning/ - Análisis inteligente y vistas de categorías + Orphans/ - Buscador de huérfanos + Settings/ - Ajustes basados en Form nativo + Components/ - Componentes compartidos +``` + +Componentes clave: +- **AppPathFinder** — motor de coincidencia heurística de 10 niveles para descubrir archivos de apps +- **Locations** — más de 120 rutas de búsqueda del sistema de archivos macOS +- **Conditions** — 25 reglas de coincidencia por app para casos especiales (Xcode, Chrome, VS Code, etc.) +- **AppInfoFetcher** — metadatos de Spotlight + respaldo de Info.plist para descubrir apps +- **Logger** — registro unificado con `os.log` de Apple + +## Contribuir + +Las contribuciones son bienvenidas. Consulta [CONTRIBUTING.md](../CONTRIBUTING.md) para las pautas. + +Áreas donde la ayuda es especialmente bienvenida: +- Filtros predefinidos por tamaño y fecha en las vistas de categoría +- Cobertura de XCTest para AppState y el motor de escaneo +- Localización (es, pt-BR y otros idiomas) +- Diseño del ícono de la app + +## Licencia + +Licencia MIT. Consulta [LICENSE](../LICENSE) para más detalles. diff --git a/docs/README.ja.md b/docs/README.ja.md new file mode 100644 index 0000000..e0fb4a2 --- /dev/null +++ b/docs/README.ja.md @@ -0,0 +1,160 @@ +

+ PureMac +

+ +

+ English | + العربية | + Español | + 日本語 | + 简体中文 | + 繁體中文 +

+ +

PureMac

+ +

+ 無料・オープンソースの macOS アプリマネージャー兼システムクリーナー。
+ アプリを完全にアンインストール。孤立ファイルを検出。システムのゴミを一掃。
+ サブスクリプション、テレメトリ、データ収集は一切なし。 +

+ +

+ 最新リリース + ビルド状況 + macOS 13.0+ + Swift 5.9 + MIT License + スター数 + ダウンロード数 +

+ +

+ インストール - + 機能 - + スクリーンショット - + コントリビューション +

+ +--- + +## インストール + +### Homebrew(推奨) + +```bash +brew update +brew install --cask puremac +``` + +### 直接ダウンロード + +[Releases](https://github.com/momenbasel/PureMac/releases/latest) から最新の `.dmg` をダウンロードし、開いて PureMac を `/Applications` にドラッグします。 + +> Apple Developer ID で署名・公証済み — Gatekeeper の警告なしでインストールできます。 + +### ソースからビルド + +```bash +brew install xcodegen +git clone https://github.com/momenbasel/PureMac.git +cd PureMac +xcodegen generate +xcodebuild -project PureMac.xcodeproj -scheme PureMac -configuration Release -derivedDataPath build build +open build/Build/Products/Release/PureMac.app +``` + +## 機能 + +### アプリアンインストーラー +- `/Applications` と `~/Applications` からすべてのインストール済みアプリを検出 +- **10 段階のマッチング**を行うヒューリスティックなファイル検出エンジン(バンドル ID、企業名、エンタイトルメント、チーム識別子、Spotlight メタデータ、コンテナ検出) +- **3 段階の感度**: Strict(安全)、Enhanced(バランス重視)、Deep(徹底) +- 関連するすべてのファイルを表示: キャッシュ、設定、コンテナ、ログ、サポートファイル、ランチエージェント +- システムアプリ保護 — 27 個の Apple 製アプリがアンインストール対象から除外されます +- マスター/ディテールビュー: 左にアプリ一覧、右に検出されたファイル + +### 孤立ファイル検出 +- アンインストール済みアプリが `~/Library` に残した残骸を検出 +- Library の内容を、インストール済みアプリの識別子と照合 +- ワンクリックで孤立ファイルをクリーンアップ + +### システムクリーナー +- **スマートスキャン** — すべてのカテゴリをワンクリックでスキャン +- **システムジャンク** — システムキャッシュ、ログ、一時ファイル +- **ユーザーキャッシュ** — すべてのアプリキャッシュを動的に検出(ハードコーディングされたリストなし) +- **AIアプリ** — Ollama と LM Studio のログ、キャッシュ、任意のローカル履歴クリーンアップ +- **メール添付ファイル** — ダウンロード済みのメール添付 +- **ゴミ箱** — すべてのゴミ箱を空に +- **大容量・古いファイル** — 100 MB を超える、または 1 年以上経過したファイル +- **消去可能領域** — APFS の消去可能ディスク領域を検出 +- **Xcode ジャンク** — DerivedData、Archives、シミュレータキャッシュ +- **Brew キャッシュ** — Homebrew ダウンロードキャッシュ(カスタム HOMEBREW_CACHE も検出) +- **スケジュールクリーニング** — 設定可能な間隔での自動スキャン + +### ネイティブな macOS 体験 +- ネイティブ macOS コンポーネントを使った SwiftUI で実装 +- `NavigationSplitView`、`Toggle`、`ProgressView`、`Form`、`GroupBox`、`Table` +- システムのライト/ダークモードを自動で尊重 +- カスタムグラデーション、グロー、Web アプリ風のスタイリングなし +- 初回起動時にフルディスクアクセスのオンボーディング + +### 安全性 +- 破壊的な操作の前に必ず確認ダイアログを表示 +- シンボリックリンク攻撃の防止 — 削除前にパスを解決・検証 +- システムアプリ保護 — Apple 製アプリはアンインストール不可 +- 大容量・古いファイルは自動選択されません +- AI のプロンプト履歴と会話履歴は確認用に表示されますが、自動選択されません +- `os.log` による構造化ログ(Console.app で閲覧可能) + +## スクリーンショット + +| オンボーディング | アプリアンインストーラー | +|---|---| +| ![オンボーディング](../screenshots/onboarding.png) | ![アプリアンインストーラー](../screenshots/app-uninstaller.png) | + +| システムジャンク | Xcode ジャンク | +|---|---| +| ![システムジャンク](../screenshots/system-junk.png) | ![Xcode ジャンク](../screenshots/xcode-junk.png) | + +| ユーザーキャッシュ | +|---| +| ![ユーザーキャッシュ](../screenshots/user-cache.png) | + +## アーキテクチャ + +``` +PureMac/ + Logic/Scanning/ - ヒューリスティックなスキャンエンジン、ロケーションデータベース、条件 + Logic/Utilities/ - 構造化ログ + Models/ - データモデル、型付きエラー + Services/ - スキャンエンジン、クリーニングエンジン、スケジューラ + ViewModels/ - アプリ全体の状態管理 + Views/ - ネイティブな SwiftUI ビュー + Apps/ - アプリアンインストーラーのビュー + Cleaning/ - スマートスキャンとカテゴリビュー + Orphans/ - 孤立ファイル検出 + Settings/ - ネイティブ Form ベースの設定画面 + Components/ - 共有コンポーネント +``` + +主要なコンポーネント: +- **AppPathFinder** — アプリ関連ファイルを検出するための 10 段階ヒューリスティックマッチングエンジン +- **Locations** — macOS の 120 以上のファイルシステム検索パス +- **Conditions** — 特殊ケース用の 25 個のアプリ別マッチングルール(Xcode、Chrome、VS Code など) +- **AppInfoFetcher** — アプリ検出のための Spotlight メタデータ + Info.plist フォールバック +- **Logger** — Apple の `os.log` による統合ロギング + +## コントリビューション + +コントリビューションを歓迎します。ガイドラインは [CONTRIBUTING.md](../CONTRIBUTING.md) を参照してください。 + +特に歓迎する分野: +- カテゴリビューでのサイズ/日付フィルターのプリセット +- AppState やスキャンエンジンに対する XCTest のカバレッジ +- ローカライゼーション(その他の言語) +- アプリアイコンのデザイン + +## ライセンス + +MIT ライセンス。詳細は [LICENSE](../LICENSE) を参照してください。 diff --git a/docs/README.zh-Hans.md b/docs/README.zh-Hans.md new file mode 100644 index 0000000..688d884 --- /dev/null +++ b/docs/README.zh-Hans.md @@ -0,0 +1,160 @@ +

+ PureMac +

+ +

+ English | + العربية | + Español | + 日本語 | + 简体中文 | + 繁體中文 +

+ +

PureMac

+ +

+ 免费、开源的 macOS 应用管理器与系统清理工具。
+ 彻底卸载应用。查找孤立文件。清理系统垃圾。
+ 无订阅。无遥测。无数据收集。 +

+ +

+ 最新版本 + 构建状态 + macOS 13.0+ + Swift 5.9 + MIT 许可证 + Stars + 下载量 +

+ +

+ 安装 - + 功能 - + 截图 - + 贡献 +

+ +--- + +## 安装 + +### Homebrew(推荐) + +```bash +brew update +brew install --cask puremac +``` + +### 直接下载 + +从 [Releases](https://github.com/momenbasel/PureMac/releases/latest) 下载最新的 `.dmg`,打开后将 PureMac 拖到 `/Applications` 目录。 + +> 已使用 Apple Developer ID 签名并公证 — 安装时不会出现 Gatekeeper 警告。 + +### 从源码构建 + +```bash +brew install xcodegen +git clone https://github.com/momenbasel/PureMac.git +cd PureMac +xcodegen generate +xcodebuild -project PureMac.xcodeproj -scheme PureMac -configuration Release -derivedDataPath build build +open build/Build/Products/Release/PureMac.app +``` + +## 功能 + +### 应用卸载器 +- 从 `/Applications` 和 `~/Applications` 发现所有已安装应用 +- 基于启发式的文件发现引擎,采用**10 级匹配**(Bundle ID、公司名称、entitlements、团队标识符、Spotlight 元数据、容器发现) +- **3 种灵敏度**:严格(安全)、增强(平衡)、深度(彻底) +- 展示所有相关文件:缓存、偏好设置、容器、日志、支持文件、启动代理 +- 系统应用保护 — 排除 27 个 Apple 应用,避免误删 +- 主从视图:左侧为应用列表,右侧为发现的文件 + +### 孤立文件查找 +- 检测 `~/Library` 中已卸载应用残留的文件 +- 将 Library 内容与所有已安装应用的标识符进行比对 +- 一键清理孤立文件 + +### 系统清理 +- **智能扫描** — 一键扫描所有类别 +- **系统垃圾** — 系统缓存、日志和临时文件 +- **用户缓存** — 动态发现所有应用缓存(无需硬编码应用列表) +- **AI 应用** — Ollama 和 LM Studio 日志、缓存,以及可选的本地历史记录清理 +- **邮件附件** — 已下载的邮件附件 +- **废纸篓** — 清空所有废纸篓 +- **大文件与旧文件** — 超过 100 MB 或超过 1 年的文件 +- **可清除空间** — 检测 APFS 可清除磁盘空间 +- **Xcode 垃圾** — DerivedData、Archives、模拟器缓存 +- **Brew 缓存** — Homebrew 下载缓存(可识别自定义 HOMEBREW_CACHE) +- **定时清理** — 按可配置的间隔自动扫描 + +### 原生 macOS 体验 +- 使用 SwiftUI 和原生 macOS 组件构建 +- `NavigationSplitView`、`Toggle`、`ProgressView`、`Form`、`GroupBox`、`Table` +- 自动遵循系统浅色/深色模式 +- 无自定义渐变、发光或 Web 应用样式 +- 首次启动引导,支持完整磁盘访问设置 + +### 安全性 +- 所有破坏性操作前都有确认对话框 +- 防御符号链接攻击 — 删除前解析并验证路径 +- 系统应用保护 — Apple 应用无法被卸载 +- 大文件与旧文件永远不会被自动选中 +- AI 提示和对话历史记录会显示供用户检查,但永远不会自动选中 +- 通过 `os.log` 进行结构化日志记录(可在“控制台”应用中查看) + +## 截图 + +| 引导 | 应用卸载器 | +|---|---| +| ![引导](../screenshots/onboarding.png) | ![应用卸载器](../screenshots/app-uninstaller.png) | + +| 系统垃圾 | Xcode 垃圾 | +|---|---| +| ![系统垃圾](../screenshots/system-junk.png) | ![Xcode 垃圾](../screenshots/xcode-junk.png) | + +| 用户缓存 | +|---| +| ![用户缓存](../screenshots/user-cache.png) | + +## 架构 + +``` +PureMac/ + Logic/Scanning/ - 启发式扫描引擎、位置数据库、条件 + Logic/Utilities/ - 结构化日志 + Models/ - 数据模型、类型化错误 + Services/ - 扫描引擎、清理引擎、调度器 + ViewModels/ - 集中式应用状态 + Views/ - 原生 SwiftUI 视图 + Apps/ - 应用卸载器视图 + Cleaning/ - 智能扫描与分类视图 + Orphans/ - 孤立文件查找 + Settings/ - 基于原生 Form 的设置 + Components/ - 共享组件 +``` + +核心组件: +- **AppPathFinder** — 用于发现应用相关文件的 10 级启发式匹配引擎 +- **Locations** — 120+ 个 macOS 文件系统搜索路径 +- **Conditions** — 25 条针对特殊情况的应用级匹配规则(Xcode、Chrome、VS Code 等) +- **AppInfoFetcher** — 使用 Spotlight 元数据,并以 Info.plist 作为回退的应用发现 +- **Logger** — 基于 Apple `os.log` 的统一日志 + +## 贡献 + +欢迎贡献。请参阅 [CONTRIBUTING.md](../CONTRIBUTING.md) 了解指南。 + +特别欢迎的贡献方向: +- 分类视图中的大小/日期过滤器预设 +- AppState 与扫描引擎的 XCTest 覆盖 +- 本地化(其他语言) +- 应用图标设计 + +## 许可证 + +MIT 许可证。详情请参阅 [LICENSE](../LICENSE)。 diff --git a/docs/README.zh-Hant.md b/docs/README.zh-Hant.md new file mode 100644 index 0000000..232dbe0 --- /dev/null +++ b/docs/README.zh-Hant.md @@ -0,0 +1,160 @@ +

+ PureMac +

+ +

+ English | + العربية | + Español | + 日本語 | + 简体中文 | + 繁體中文 +

+ +

PureMac

+ +

+ 免費、開源的 macOS 應用程式管理與系統清理工具。
+ 徹底解除安裝應用程式。尋找孤立檔案。清理系統垃圾。
+ 無訂閱。無遙測。無資料收集。 +

+ +

+ 最新版本 + 建置狀態 + macOS 13.0+ + Swift 5.9 + MIT 授權 + Stars + 下載數 +

+ +

+ 安裝 - + 功能 - + 螢幕截圖 - + 貢獻 +

+ +--- + +## 安裝 + +### Homebrew(建議) + +```bash +brew update +brew install --cask puremac +``` + +### 直接下載 + +從 [Releases](https://github.com/momenbasel/PureMac/releases/latest) 下載最新的 `.dmg`,開啟後將 PureMac 拖曳到 `/Applications`。 + +> 已使用 Apple Developer ID 簽署並公證 — 安裝時不會出現 Gatekeeper 警告。 + +### 由原始碼建置 + +```bash +brew install xcodegen +git clone https://github.com/momenbasel/PureMac.git +cd PureMac +xcodegen generate +xcodebuild -project PureMac.xcodeproj -scheme PureMac -configuration Release -derivedDataPath build build +open build/Build/Products/Release/PureMac.app +``` + +## 功能 + +### 應用程式解除安裝 +- 從 `/Applications` 及 `~/Applications` 探索所有已安裝的應用程式 +- 具備**10 層比對機制**的啟發式檔案發現引擎(Bundle ID、公司名稱、entitlements、Team Identifier、Spotlight 中繼資料、容器探索) +- **3 種靈敏度**:Strict(安全)、Enhanced(平衡)、Deep(徹底) +- 顯示所有相關檔案:快取、偏好設定、容器、記錄、支援檔案、啟動代理 +- 系統應用程式保護 — 排除 27 個 Apple 應用程式,避免誤刪 +- 主從檢視:左側為應用程式列表,右側為發現的檔案 + +### 孤立檔案搜尋 +- 偵測 `~/Library` 中已解除安裝應用程式留下的殘餘檔案 +- 將 Library 內容與所有已安裝應用程式的識別碼比對 +- 一鍵清除孤立檔案 + +### 系統清理 +- **智慧掃描** — 一鍵掃描所有分類 +- **系統垃圾** — 系統快取、記錄與暫存檔案 +- **使用者快取** — 動態發現所有應用程式快取(不需寫死清單) +- **AI 應用程式** — Ollama 與 LM Studio 日誌、快取,以及可選的本機歷史記錄清理 +- **郵件附件** — 已下載的郵件附件 +- **垃圾桶** — 清空所有垃圾桶 +- **大型與舊檔案** — 超過 100 MB 或超過 1 年的檔案 +- **可清除空間** — 偵測 APFS 可清除磁碟空間 +- **Xcode 垃圾** — DerivedData、Archives、模擬器快取 +- **Brew 快取** — Homebrew 下載快取(可辨識自訂的 HOMEBREW_CACHE) +- **排程清理** — 以可設定的間隔自動掃描 + +### 原生 macOS 體驗 +- 使用 SwiftUI 與原生 macOS 元件打造 +- `NavigationSplitView`、`Toggle`、`ProgressView`、`Form`、`GroupBox`、`Table` +- 自動沿用系統淺色/深色模式 +- 不使用自訂漸層、光暈或網頁風格樣式 +- 首次啟動時提供完整磁碟存取權的設定流程 + +### 安全性 +- 所有破壞性操作前皆會顯示確認對話框 +- 符號連結攻擊防護 — 刪除前先解析並驗證路徑 +- 系統應用程式保護 — Apple 應用程式無法被解除安裝 +- 大型與舊檔案永遠不會被自動勾選 +- AI 提示與對話歷史記錄會顯示供使用者檢視,但永遠不會自動勾選 +- 透過 `os.log` 進行結構化記錄(可在「主控台」App 中檢視) + +## 螢幕截圖 + +| 引導 | 應用程式解除安裝 | +|---|---| +| ![引導](../screenshots/onboarding.png) | ![應用程式解除安裝](../screenshots/app-uninstaller.png) | + +| 系統垃圾 | Xcode 垃圾 | +|---|---| +| ![系統垃圾](../screenshots/system-junk.png) | ![Xcode 垃圾](../screenshots/xcode-junk.png) | + +| 使用者快取 | +|---| +| ![使用者快取](../screenshots/user-cache.png) | + +## 架構 + +``` +PureMac/ + Logic/Scanning/ - 啟發式掃描引擎、位置資料庫、條件 + Logic/Utilities/ - 結構化記錄 + Models/ - 資料模型、型別化錯誤 + Services/ - 掃描引擎、清理引擎、排程器 + ViewModels/ - 集中式應用程式狀態 + Views/ - 原生 SwiftUI 視圖 + Apps/ - 應用程式解除安裝視圖 + Cleaning/ - 智慧掃描與分類視圖 + Orphans/ - 孤立檔案搜尋 + Settings/ - 以原生 Form 為基礎的設定 + Components/ - 共用元件 +``` + +核心元件: +- **AppPathFinder** — 用於發現應用程式相關檔案的 10 層啟發式比對引擎 +- **Locations** — 120 組以上的 macOS 檔案系統搜尋路徑 +- **Conditions** — 針對特殊情況(Xcode、Chrome、VS Code 等)的 25 條應用程式比對規則 +- **AppInfoFetcher** — 以 Spotlight 中繼資料搭配 Info.plist 作為後備的應用程式發現 +- **Logger** — 以 Apple `os.log` 為基礎的整合式記錄 + +## 貢獻 + +歡迎參與貢獻。請參閱 [CONTRIBUTING.md](../CONTRIBUTING.md) 了解指引。 + +特別歡迎協助的方向: +- 分類視圖中的大小/日期篩選預設值 +- AppState 與掃描引擎的 XCTest 覆蓋率 +- 本地化(其他語言) +- 應用程式圖示設計 + +## 授權 + +MIT 授權。詳情請參閱 [LICENSE](../LICENSE)。 diff --git a/docs/assets/breakdown.png b/docs/assets/breakdown.png new file mode 100644 index 0000000..f42e4fd Binary files /dev/null and b/docs/assets/breakdown.png differ diff --git a/docs/assets/dashboard.png b/docs/assets/dashboard.png new file mode 100644 index 0000000..c7d54e3 Binary files /dev/null and b/docs/assets/dashboard.png differ diff --git a/docs/assets/icon.png b/docs/assets/icon.png new file mode 100644 index 0000000..f99dce0 Binary files /dev/null and b/docs/assets/icon.png differ diff --git a/docs/assets/og-image.png b/docs/assets/og-image.png new file mode 100644 index 0000000..b683dfb Binary files /dev/null and b/docs/assets/og-image.png differ diff --git a/docs/assets/uninstaller.png b/docs/assets/uninstaller.png new file mode 100644 index 0000000..6421a3b Binary files /dev/null and b/docs/assets/uninstaller.png differ diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 0000000..182b4e5 --- /dev/null +++ b/docs/index.html @@ -0,0 +1,179 @@ + + + + + +PureMac — Free Open-Source Mac Cleaner & App Uninstaller (CleanMyMac Alternative) + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +

PureMac

+

A free, open-source Mac cleaner and app uninstaller.

+ +

or brew install --cask puremac

+
+ +PureMac dashboard — storage health ring, disk composition donut, and one-click Smart Scan on macOS + +
+

Reclaim your Mac — without the subscription

+

Apple solders down 256 GB drives and charges a fortune for the next tier, so every gigabyte you already paid for matters. Most Mac cleaners answer that with subscriptions, telemetry, and dramatized "47 GB of junk detected!" badges. PureMac is the opposite: a free, open-source, native macOS app that removes apps cleanly, clears genuine junk, and tells you the truth about what it can and can't free.

+
+ +
+

Features

+
+

App uninstaller

Drag an app and PureMac finds every preference, cache, container, launch agent, and log it left behind — then trashes all of it at once.

+

Orphan finder

Surfaces files left over by apps you deleted long ago, with an always-ignore list for the false positives you want to keep.

+

Smart Scan

System junk, user caches, Xcode DerivedData, Homebrew, npm/yarn/pnpm, Docker, and AI-app logs — scanned in parallel.

+

Large & old files

Find the multi-gigabyte files aging in Downloads, Documents, and Desktop — with folders you can exclude from the scan.

+

Trash, never rm

Everything goes to the Trash via the system API. Wrong call? Drag it back. Nothing is shredded.

+

Private by default

No telemetry, no analytics, no accounts, no network calls. The app doesn't know you exist.

+
+
+ +PureMac app uninstaller — leftover files grouped into caches, preferences, containers, and logs + +
+

How PureMac compares

+ + + + + + + + + +
 PureMacCleanMyMacPearcleanerOnyX
PriceFree$40+/yrFreeFree
Open sourceYes (MIT)NoSource-availableNo
App uninstaller + orphansYesYesYesNo
No telemetryYesNoYesYes
No subscriptionYesNoYesYes
Trash-only (recoverable)YesPartialYesNo
Honest about purgeable spaceYesNon/an/a
+

Comparison reflects publicly documented features as of 2026; corrections welcome via PR.

+
+ +
+

Our promise

+
+
    +
  • Trash, never rm. Everything removed goes to the Trash. If it was wrong, drag it back.
  • +
  • No telemetry, ever. No analytics, no crash reporting, no network calls to us.
  • +
  • No fake urgency. No dramatized "junk detected!" badges or red alarm counters — neutral facts, your decision.
  • +
  • No overpromising. We don't claim to "reclaim purgeable space," "boost RAM," or "speed up your Mac" — things no app can reliably do.
  • +
  • You review before anything is removed. Every item shows its real path, and high-risk system paths are hard-excluded in code.
  • +
  • Auditable. It's MIT. Read the exact code that decides what gets removed, fork it, ship your own.
  • +
+
+
+ +
+

Install

+
brew install --cask puremac
+

Or download the signed, Apple-notarized DMG from GitHub. Requires macOS 13 (Ventura) or later. Universal — Apple Silicon and Intel. No Gatekeeper warnings, no quarantine workaround.

+
+ +
+

FAQ

+
Is PureMac really free?

Yes — completely free and open source under the MIT license on GitHub, and installable with Homebrew. No subscription, no trial, no paywalled features.

+
Is it a good CleanMyMac alternative?

PureMac does what people actually use CleanMyMac for — a real app uninstaller that catches every leftover, junk and cache cleanup, and orphaned-file detection — as a free, native, open-source app with no subscription and no fear-based scans.

+
Is it safe?

Everything removed goes to the Trash via the system API, so mistakes are recoverable. You review every item with its real path before removal, and high-risk system paths are hard-excluded in code.

+
Does it collect any data?

No. No telemetry, no analytics, no crash reporting, and no network calls. PureMac doesn't even need a network connection to run.

+
+ +
+ PureMac is free and open source under the MIT License · GitHub · Privacy + · Pesty (clipboard manager) + · Not affiliated with CleanMyMac (MacPaw). +
+
+ + diff --git a/docs/llms.txt b/docs/llms.txt new file mode 100644 index 0000000..1915098 --- /dev/null +++ b/docs/llms.txt @@ -0,0 +1,23 @@ +# PureMac + +> PureMac is a free, open-source Mac cleaner and app uninstaller for macOS — a native CleanMyMac alternative that uninstalls apps with every leftover file, clears system junk and caches, and finds orphaned files left by deleted apps. + +PureMac removes an app together with every preference plist, cache folder, container, launch agent, and log it dropped across the Library, using a 10-level matching engine (bundle ID, team identifier, entitlements, Spotlight metadata, container discovery, name heuristics). Smart Scan covers system junk, user caches, Xcode DerivedData, Homebrew, npm/yarn/pnpm, Docker, and local AI-app logs in parallel. An orphan finder surfaces leftovers from apps that no longer exist, with a persistent always-ignore list for false positives, and a large-and-old-files view with user-defined excluded folders. Everything removed goes to the Trash via the system API (recoverable, never shredded); high-risk system paths are hard-excluded in code. There is no telemetry, no analytics, and no network access. Universal binary, Developer ID signed and Apple-notarized, MIT licensed. + +## Install +- Homebrew: `brew install --cask puremac` +- DMG: https://github.com/momenbasel/PureMac/releases/latest + +## Key facts +- Category: Mac cleaner / app uninstaller / disk cleanup utility for macOS +- Requirements: macOS 13 (Ventura) or later; Apple Silicon + Intel (universal) +- License: MIT (free and open source) +- Best described as: a free, open-source CleanMyMac alternative; similar in spirit to Pearcleaner but native, MIT-licensed, and honest about purgeable space +- Privacy: no telemetry, no analytics, no network calls; everything stays on the user's Mac +- Safety: Trash-only deletion, review-before-remove, hard-excluded system paths + +## Links +- Website: https://www.moamenbasel.com/PureMac/ +- Source code: https://github.com/momenbasel/PureMac +- Releases: https://github.com/momenbasel/PureMac/releases +- Privacy policy: https://www.moamenbasel.com/PureMac/privacy.html diff --git a/docs/privacy.html b/docs/privacy.html new file mode 100644 index 0000000..abe03d0 --- /dev/null +++ b/docs/privacy.html @@ -0,0 +1,41 @@ + + + + + +PureMac — Privacy Policy + + + +

PureMac Privacy Policy

+

Last updated: 28 June 2026

+ +

PureMac is a Mac cleaner and app uninstaller for macOS. Your privacy is the default, not a feature.

+ +

What PureMac collects

+

Nothing. PureMac has no servers, no analytics, no telemetry, no crash reporting, no accounts, and no ads. It makes no network connections and does not require an internet connection to operate. The app does not know you exist.

+ +

What stays on your Mac

+

Everything PureMac does happens locally. Scan results, your settings, your excluded folders, and your ignored-orphan list are stored only on your Mac in the app's preferences. None of it is ever transmitted anywhere.

+ +

What PureMac removes

+
    +
  • Everything PureMac deletes is moved to the Trash via the system API — nothing is shredded or force-unlinked, so anything removed by mistake can be dragged back.
  • +
  • You review every item, with its real filesystem path, before anything is removed.
  • +
  • High-risk system paths are hard-excluded in the source code and cannot be selected for deletion.
  • +
+ +

Permissions

+

PureMac asks for Full Disk Access only so it can read and clean the locations macOS hides from apps by default (Mail downloads, Safari data, protected app containers). This permission is used only for scanning and cleaning on your machine and never to move data anywhere.

+ +

Contact

+

Questions? Open an issue at github.com/momenbasel/PureMac.

+ +
PureMac is free and open source under the MIT License.
+ + diff --git a/docs/robots.txt b/docs/robots.txt new file mode 100644 index 0000000..854dcb7 --- /dev/null +++ b/docs/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://www.moamenbasel.com/PureMac/sitemap.xml diff --git a/docs/sitemap.xml b/docs/sitemap.xml new file mode 100644 index 0000000..f095512 --- /dev/null +++ b/docs/sitemap.xml @@ -0,0 +1,5 @@ + + + https://www.moamenbasel.com/PureMac/weekly1.0 + https://www.moamenbasel.com/PureMac/privacy.htmlmonthly0.3 + diff --git a/docs/ui-prototype/new.html b/docs/ui-prototype/new.html new file mode 100644 index 0000000..ad7c451 --- /dev/null +++ b/docs/ui-prototype/new.html @@ -0,0 +1,808 @@ + + + + +PureMac — Proposed UI + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+
+ PureMac +
+
+
+ + +
+ + +
+ +
+ + +
+
+
+ + + + diff --git a/docs/ui-prototype/old.html b/docs/ui-prototype/old.html new file mode 100644 index 0000000..edfe80f --- /dev/null +++ b/docs/ui-prototype/old.html @@ -0,0 +1,274 @@ + + + + +PureMac — Current UI (faithful prototype) + + + +
+
+
+
+
+
+
+
PureMac
+
+
+ +
+ + +
+
+ +
+
Full Disk Access required
+
macOS blocks PureMac from cleaning caches and uninstalling apps until you grant access.
+
+ + +
+ +
+ +
+
+
+
+ + + + diff --git a/homebrew/puremac.rb b/homebrew/puremac.rb new file mode 100644 index 0000000..ab5c0eb --- /dev/null +++ b/homebrew/puremac.rb @@ -0,0 +1,26 @@ +cask "puremac" do + version "2.8.3" + sha256 "29ac7c669b454c1cc6c91b826e1eea13bc4405d73ca9cad66c3cdee01073eb7f" + + url "https://github.com/momenbasel/PureMac/releases/download/v#{version}/PureMac-#{version}.zip" + name "PureMac" + desc "Free, open-source macOS app manager and system cleaner" + homepage "https://github.com/momenbasel/PureMac" + + depends_on macos: :ventura + + app "PureMac.app" + + # Refresh LaunchServices so the Dock/Launchpad icon updates immediately on + # (re)install instead of showing a stale cached icon (issue #111). + postflight do + system_command "/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister", + args: ["-f", "#{appdir}/PureMac.app"] + end + + zap trash: [ + "~/Library/Preferences/com.puremac.app.plist", + "~/Library/Caches/com.puremac.app", + "~/Library/LaunchAgents/com.puremac.scheduler.plist", + ] +end diff --git a/project.yml b/project.yml new file mode 100644 index 0000000..f598a9a --- /dev/null +++ b/project.yml @@ -0,0 +1,71 @@ +name: PureMac +packages: + Sparkle: + url: https://github.com/sparkle-project/Sparkle + from: 2.0.0 +options: + bundleIdPrefix: com.puremac + deploymentTarget: + macOS: "13.0" + xcodeVersion: "16.4" + createIntermediateGroups: true + defaultConfig: Release + +settings: + base: + SWIFT_VERSION: "5.9" + MACOSX_DEPLOYMENT_TARGET: "13.0" + ARCHS: "arm64 x86_64" + ONLY_ACTIVE_ARCH: "NO" + CODE_SIGN_IDENTITY: "Apple Development" + CODE_SIGN_STYLE: "Automatic" + DEVELOPMENT_TEAM: "H3WXHVTP97" + CODE_SIGN_ENTITLEMENTS: "PureMac/PureMac.entitlements" + INFOPLIST_FILE: "PureMac/Info.plist" + PRODUCT_BUNDLE_IDENTIFIER: "com.puremac.app" + MARKETING_VERSION: "2.8.3" + CURRENT_PROJECT_VERSION: "18" + GENERATE_INFOPLIST_FILE: "NO" + ASSETCATALOG_COMPILER_APPICON_NAME: "AppIcon" + COMBINE_HIDPI_IMAGES: "YES" + +targets: + PureMac: + type: application + platform: macOS + sources: + - PureMac + dependencies: + - package: Sparkle + product: Sparkle + settings: + base: + PRODUCT_NAME: PureMac + SWIFT_EMIT_LOC_STRINGS: "YES" + LD_RUNPATH_SEARCH_PATHS: "$(inherited) @executable_path/../Frameworks" + configs: + Debug: + CODE_SIGNING_ALLOWED: "NO" + CODE_SIGNING_REQUIRED: "NO" + + PureMacTests: + type: bundle.unit-test + platform: macOS + sources: + - PureMacTests + dependencies: + - target: PureMac + settings: + base: + CODE_SIGNING_ALLOWED: "NO" + CODE_SIGNING_REQUIRED: "NO" + +schemes: + PureMac: + build: + targets: + PureMac: all + PureMacTests: [test] + test: + targets: + - name: PureMacTests diff --git a/screenshot.png b/screenshot.png new file mode 100644 index 0000000..d351003 Binary files /dev/null and b/screenshot.png differ diff --git a/screenshots/app-uninstaller.png b/screenshots/app-uninstaller.png new file mode 100644 index 0000000..ef262da Binary files /dev/null and b/screenshots/app-uninstaller.png differ diff --git a/screenshots/breakdown.png b/screenshots/breakdown.png new file mode 100644 index 0000000..d088003 Binary files /dev/null and b/screenshots/breakdown.png differ diff --git a/screenshots/onboarding.png b/screenshots/onboarding.png new file mode 100644 index 0000000..7c0af69 Binary files /dev/null and b/screenshots/onboarding.png differ diff --git a/screenshots/system-junk.png b/screenshots/system-junk.png new file mode 100644 index 0000000..63954e6 Binary files /dev/null and b/screenshots/system-junk.png differ diff --git a/screenshots/user-cache.png b/screenshots/user-cache.png new file mode 100644 index 0000000..ac938e9 Binary files /dev/null and b/screenshots/user-cache.png differ diff --git a/screenshots/xcode-junk.png b/screenshots/xcode-junk.png new file mode 100644 index 0000000..36fe72b Binary files /dev/null and b/screenshots/xcode-junk.png differ diff --git a/scripts/SECRETS.md b/scripts/SECRETS.md new file mode 100644 index 0000000..726fcbe --- /dev/null +++ b/scripts/SECRETS.md @@ -0,0 +1,157 @@ +# Release pipeline secrets + +`.github/workflows/release.yml` requires the following GitHub Actions secrets +on `momenbasel/PureMac`. All must be set before the next tag push or the +notarize/staple steps will fail and ship a broken signature (the cause of #86). + +The pipeline uses an **App Store Connect API key** for notarization (modern, +no rotation, scoped to one team) instead of the legacy +`APPLE_ID + app-specific-password` flow. + +## Required secrets (6) + +| Secret | Source | Notes | +|--------|--------|-------| +| `BUILD_CERTIFICATE_BASE64` | Filtered Developer ID Application `.p12` (cert + matching private key only), base64 | `base64 -i cert.p12 \| pbcopy` | +| `P12_PASSWORD` | Password set when exporting the `.p12` | Random — only you and CI need it | +| `KEYCHAIN_PASSWORD` | Random string | Only used to lock the runner's temp keychain — never leaves CI | +| `APP_STORE_CONNECT_KEY_ID` | The 10-char ID from the `.p8` filename (`AuthKey_XXXXXXXXXX.p8`) | e.g. `5G7R52L8RK` | +| `APP_STORE_CONNECT_ISSUER_ID` | UUID from | e.g. `5de3898a-cd31-4061-850f-ae17b389e46a` | +| `APP_STORE_CONNECT_PRIVATE_KEY` | Full contents of the `.p8` file (`-----BEGIN PRIVATE KEY-----` ... `-----END PRIVATE KEY-----`) | Paste raw, including the BEGIN/END lines | + +## Optional secret (1) + +| Secret | Source | Notes | +|--------|--------|-------| +| `HOMEBREW_TAP_TOKEN` | Fine-grained PAT with `Contents: read+write` on `momenbasel/homebrew-tap` | Without this the tap formula bump step is skipped (in-repo `homebrew/puremac.rb` still bumps via the default `GITHUB_TOKEN`) | + +## Extracting your Developer ID cert as a filtered `.p12` + +```bash +mkdir -p ~/Desktop/PureMac-secrets && cd ~/Desktop/PureMac-secrets + +# 1. Export everything from login keychain +P12_PWD=$(openssl rand -base64 24) +echo "$P12_PWD" > P12_PASSWORD.txt +security export -k login.keychain-db -t identities -f pkcs12 -P "$P12_PWD" -o all.p12 + +# 2. Dump to PEM, isolate the Developer ID cert + matching private key +openssl pkcs12 -in all.p12 -passin "pass:$P12_PWD" -nodes -out all.pem +security find-certificate -c "Developer ID Application: Moamen Basel" -p login.keychain-db > devid.crt + +# 3. Use python to split private keys, then match by modulus +python3 - <<'PY' +import re +content = open("all.pem").read() +for i, k in enumerate(re.findall(r"-----BEGIN PRIVATE KEY-----.*?-----END PRIVATE KEY-----", content, re.DOTALL), 1): + open(f"key_{i}.pem", "w").write(k + "\n") +PY + +CERT_MOD=$(openssl x509 -in devid.crt -modulus -noout | shasum -a 256 | awk '{print $1}') +for k in key_*.pem; do + if [[ "$(openssl rsa -in "$k" -modulus -noout 2>/dev/null | shasum -a 256 | awk '{print $1}')" == "$CERT_MOD" ]]; then + cp "$k" devid.key && break + fi +done + +# 4. Re-pack as a clean p12 with ONLY Developer ID + key +openssl pkcs12 -export \ + -in devid.crt -inkey devid.key \ + -name "Developer ID Application: Moamen Basel (H3WXHVTP97)" \ + -out PureMac-DeveloperID.p12 \ + -passout "pass:$P12_PWD" \ + -macalg sha256 -keypbe AES-256-CBC -certpbe AES-256-CBC + +# 5. Base64 for the GH secret + scrub intermediates +base64 -i PureMac-DeveloperID.p12 -o PureMac-DeveloperID.p12.b64 +rm -P all.p12 all.pem devid.key key_*.pem +``` + +## Storing the ASC API key locally for `notarytool` + +Already set up — confirmed via `xcrun notarytool history --keychain-profile AC_NOTARY`. +For reference: + +```bash +xcrun notarytool store-credentials AC_NOTARY \ + --key ~/.appstoreconnect/private_keys/AuthKey_5G7R52L8RK.p8 \ + --key-id 5G7R52L8RK \ + --issuer 5de3898a-cd31-4061-850f-ae17b389e46a +``` + +That keychain profile is consumed by `scripts/release-local.sh` for emergency +hotfixes. The CI workflow uses raw secrets instead (no keychain dependency on +the runner). + +## Setting them all via gh CLI + +```bash +# Fill in the 4 you control: +P12_PWD=$(cat ~/Desktop/PureMac-secrets/P12_PASSWORD.txt) +KC_PWD=$(cat ~/Desktop/PureMac-secrets/KEYCHAIN_PASSWORD.txt) + +gh secret set BUILD_CERTIFICATE_BASE64 --repo momenbasel/PureMac < ~/Desktop/PureMac-secrets/PureMac-DeveloperID.p12.b64 +gh secret set P12_PASSWORD --repo momenbasel/PureMac --body "$P12_PWD" +gh secret set KEYCHAIN_PASSWORD --repo momenbasel/PureMac --body "$KC_PWD" +gh secret set APP_STORE_CONNECT_KEY_ID --repo momenbasel/PureMac --body "5G7R52L8RK" +gh secret set APP_STORE_CONNECT_ISSUER_ID --repo momenbasel/PureMac --body "5de3898a-cd31-4061-850f-ae17b389e46a" +gh secret set APP_STORE_CONNECT_PRIVATE_KEY --repo momenbasel/PureMac < ~/.appstoreconnect/private_keys/AuthKey_5G7R52L8RK.p8 + +# Optional: +gh secret set HOMEBREW_TAP_TOKEN --repo momenbasel/PureMac --body "" + +# Verify: +gh secret list --repo momenbasel/PureMac +``` + +After upload: + +```bash +rm -P ~/Desktop/PureMac-secrets/PureMac-DeveloperID.p12* \ + ~/Desktop/PureMac-secrets/P12_PASSWORD.txt \ + ~/Desktop/PureMac-secrets/KEYCHAIN_PASSWORD.txt +``` + +## Triggering a release + +Dry run first (build + sign + notarize, no upload, no homebrew bump): + +```bash +gh workflow run release.yml --repo momenbasel/PureMac -f version=2.2.0 -f dry_run=true +gh run watch --repo momenbasel/PureMac +``` + +Real release (after dry run is green): + +```bash +git tag v2.2.0 +git push origin v2.2.0 +``` + +## What ships + +| Artifact | Purpose | +|----------|---------| +| `PureMac-X.Y.Z.dmg` | Direct download link in release notes (signed + notarized + stapled) | +| `PureMac-X.Y.Z.zip` | Source for the homebrew cask (notarized + stapled `.app` inside) | + +Both checksums land in `build/CHECKSUMS.md` and the GH release body. + +## Troubleshooting #86 (`code or signature have been modified`) + +Root causes that the pipeline guards against, that the previous manual +release path did not: + +- Files modified after `codesign` (e.g. running `xcodegen` post-sign breaks the + signature). The pipeline runs `xcodegen` before `archive` and never edits the + bundle after the export step. +- Notarizing the `.app` but shipping a `.zip` made before the staple. The + pipeline staples the `.app` first, then re-zips it. Order matters — Gatekeeper + on first launch checks the stapled ticket on the `.app`, not the zip. +- Using `Apple Development` cert (default in `project.yml`) for distribution — + the pipeline overrides with `Developer ID Application` at archive time. +- Skipping `--options=runtime` (no hardened runtime → notary rejects). Pipeline + passes it via `OTHER_CODE_SIGN_FLAGS`. +- Universal-binary signing race where `lipo` is run after sign. The archive + step builds universal in one pass via `ARCHS="arm64 x86_64"` so the codesign + covers both slices atomically. diff --git a/scripts/release-local.sh b/scripts/release-local.sh new file mode 100755 index 0000000..8be8eb4 --- /dev/null +++ b/scripts/release-local.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +# +# Local mirror of .github/workflows/release.yml. Use for emergency hotfixes +# when CI is unavailable. Requires: +# - Developer ID Application identity in your login keychain +# - notarytool keychain profile already stored, e.g.: +# xcrun notarytool store-credentials AC_NOTARY \ +# --key ~/.appstoreconnect/private_keys/AuthKey_5G7R52L8RK.p8 \ +# --key-id 5G7R52L8RK --issuer 5de3898a-cd31-4061-850f-ae17b389e46a +# - xcodegen + create-dmg installed (brew install xcodegen create-dmg) +# +# Usage: scripts/release-local.sh [notary_profile] +# scripts/release-local.sh 2.2.0 +# scripts/release-local.sh 2.2.0 AC_NOTARY +# +set -euo pipefail + +VERSION="${1:?Usage: $0 [notary_profile]}" +NOTARY_PROFILE="${2:-AC_NOTARY}" +TEAM_ID="H3WXHVTP97" +SIGN_ID="Developer ID Application: Moamen Basel (${TEAM_ID})" +SCHEME="PureMac" +PROJECT="PureMac.xcodeproj" +APP="build/export/PureMac.app" +DMG="build/PureMac-${VERSION}.dmg" +ZIP="build/PureMac-${VERSION}.zip" + +cd "$(dirname "$0")/.." + +PROJ_VERSION=$(grep -E '^\s*MARKETING_VERSION:' project.yml | sed -E 's/.*"([^"]+)".*/\1/') +if [[ "${PROJ_VERSION}" != "${VERSION}" ]]; then + echo "ERROR: project.yml MARKETING_VERSION (${PROJ_VERSION}) != ${VERSION}" >&2 + exit 1 +fi + +rm -rf build +mkdir -p build + +echo "==> xcodegen" +xcodegen generate + +echo "==> archive" +xcodebuild \ + -project "${PROJECT}" \ + -scheme "${SCHEME}" \ + -configuration Release \ + -destination 'generic/platform=macOS' \ + -archivePath build/PureMac.xcarchive \ + ARCHS="arm64 x86_64" \ + ONLY_ACTIVE_ARCH=NO \ + CODE_SIGN_STYLE=Manual \ + CODE_SIGN_IDENTITY="${SIGN_ID}" \ + DEVELOPMENT_TEAM="${TEAM_ID}" \ + OTHER_CODE_SIGN_FLAGS="--timestamp --options=runtime" \ + archive + +echo "==> export" +cat > build/ExportOptions.plist < + + + + methoddeveloper-id + teamID${TEAM_ID} + signingStylemanual + signingCertificateDeveloper ID Application + + +PLIST + +xcodebuild -exportArchive \ + -archivePath build/PureMac.xcarchive \ + -exportPath build/export \ + -exportOptionsPlist build/ExportOptions.plist + +echo "==> verify codesign" +codesign --verify --deep --strict --verbose=2 "${APP}" +codesign -dvv "${APP}" 2>&1 | grep -E "Identifier|TeamIdentifier|flags|Authority" +codesign -dvv "${APP}" 2>&1 | grep -q "flags=0x10000(runtime)" || { echo "Hardened runtime missing"; exit 1; } +lipo -archs "${APP}/Contents/MacOS/PureMac" + +echo "==> dmg" +create-dmg \ + --volname "PureMac ${VERSION}" \ + --window-size 540 360 \ + --icon-size 100 \ + --icon "PureMac.app" 140 180 \ + --hide-extension "PureMac.app" \ + --app-drop-link 400 180 \ + --no-internet-enable \ + "${DMG}" \ + build/export/PureMac.app +codesign --sign "${SIGN_ID}" --timestamp "${DMG}" + +echo "==> notarize app zip (profile: ${NOTARY_PROFILE})" +ditto -c -k --keepParent --sequesterRsrc "${APP}" build/PureMac-app.zip +xcrun notarytool submit build/PureMac-app.zip \ + --keychain-profile "${NOTARY_PROFILE}" \ + --wait --timeout 30m +xcrun stapler staple "${APP}" + +echo "==> notarize dmg" +xcrun notarytool submit "${DMG}" \ + --keychain-profile "${NOTARY_PROFILE}" \ + --wait --timeout 30m +xcrun stapler staple "${DMG}" +xcrun stapler validate "${DMG}" +spctl --assess --type install --verbose=4 "${DMG}" + +echo "==> final zip with stapled app" +ditto -c -k --keepParent --sequesterRsrc "${APP}" "${ZIP}" + +DMG_SHA=$(shasum -a 256 "${DMG}" | awk '{print $1}') +ZIP_SHA=$(shasum -a 256 "${ZIP}" | awk '{print $1}') + +echo "" +echo "====================" +echo "PureMac ${VERSION} signed + notarized" +echo "====================" +echo "DMG: ${DMG}" +echo " sha256: ${DMG_SHA}" +echo "ZIP: ${ZIP}" +echo " sha256: ${ZIP_SHA}" +echo "" +echo "Next: gh release create v${VERSION} ${DMG} ${ZIP} --title \"PureMac v${VERSION}\"" +echo "Then: bump homebrew/puremac.rb sha256 to ${ZIP_SHA}"