diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bf81c606..1502d9f4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -49,6 +49,9 @@ jobs: - name: Generate Xcode project run: make generate + - name: Run script tests + run: /usr/bin/python3 -m unittest discover -s scripts/tests -p 'test_*.py' + - name: Build and run tests run: | xcodebuild \ diff --git a/Configs/MacTools-Info.plist b/Configs/MacTools-Info.plist index 3108d07b..4c60ec12 100644 --- a/Configs/MacTools-Info.plist +++ b/Configs/MacTools-Info.plist @@ -45,6 +45,8 @@ $(RIGHT_CLICK_CONFIGURATION_HOME_RELATIVE_PATH) NSAppleEventsUsageDescription 点击日历日期时需要控制系统日历应用并定位到对应日期。 + NSAudioCaptureUsageDescription + 仅在调节应用独立音量时于本机实时处理系统音频,不会录制、保存或上传声音。 NSBluetoothAlwaysUsageDescription 读取蓝牙外设电量,用于在设备电量组件中显示鼠标、键盘和耳机状态。 NSCalendarsUsageDescription diff --git a/Plugins/AppVolume/Bundle/AppVolumePluginBundleEntrypoint.swift b/Plugins/AppVolume/Bundle/AppVolumePluginBundleEntrypoint.swift new file mode 100644 index 00000000..b5f3e0d3 --- /dev/null +++ b/Plugins/AppVolume/Bundle/AppVolumePluginBundleEntrypoint.swift @@ -0,0 +1,3 @@ +import AppVolumePlugin + +private let appVolumePluginFactoryAnchor: Any.Type = AppVolumePluginFactory.self diff --git a/Plugins/AppVolume/Resources/Localizable.xcstrings b/Plugins/AppVolume/Resources/Localizable.xcstrings new file mode 100644 index 00000000..0e282c88 --- /dev/null +++ b/Plugins/AppVolume/Resources/Localizable.xcstrings @@ -0,0 +1,262 @@ +{ + "sourceLanguage": "zh-Hans", + "strings": { + "error.permissionDenied": { + "extractionState": "manual", + "localizations": { + "ar": { "stringUnit": { "state": "translated", "value": "مطلوب إذن تسجيل صوت النظام" } }, + "de": { "stringUnit": { "state": "translated", "value": "Die Berechtigung für Systemaudioaufnahmen ist erforderlich" } }, + "en": { "stringUnit": { "state": "translated", "value": "System Audio Recording permission is required" } }, + "es": { "stringUnit": { "state": "translated", "value": "Se requiere permiso para grabar el audio del sistema" } }, + "fr": { "stringUnit": { "state": "translated", "value": "L’autorisation d’enregistrement de l’audio du système est requise" } }, + "ja": { "stringUnit": { "state": "translated", "value": "システムオーディオ録音の許可が必要です" } }, + "ko": { "stringUnit": { "state": "translated", "value": "시스템 오디오 녹음 권한이 필요합니다" } }, + "pt": { "stringUnit": { "state": "translated", "value": "É necessária permissão para gravar o áudio do sistema" } }, + "ru": { "stringUnit": { "state": "translated", "value": "Требуется разрешение на запись системного аудио" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "需要系统音频录制权限" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "需要系統音訊錄製權限" } } + } + }, + "error.unsupported": { + "extractionState": "manual", + "localizations": { + "ar": { "stringUnit": { "state": "translated", "value": "يتطلب التحكم المستقل في صوت التطبيقات macOS 15 أو أحدث" } }, + "de": { "stringUnit": { "state": "translated", "value": "Die Lautstärkeregelung pro App erfordert macOS 15 oder neuer" } }, + "en": { "stringUnit": { "state": "translated", "value": "Per-app volume requires macOS 15 or later" } }, + "es": { "stringUnit": { "state": "translated", "value": "El volumen por aplicación requiere macOS 15 o posterior" } }, + "fr": { "stringUnit": { "state": "translated", "value": "Le volume par app nécessite macOS 15 ou version ultérieure" } }, + "ja": { "stringUnit": { "state": "translated", "value": "アプリごとの音量調整には macOS 15 以降が必要です" } }, + "ko": { "stringUnit": { "state": "translated", "value": "앱별 음량은 macOS 15 이상이 필요합니다" } }, + "pt": { "stringUnit": { "state": "translated", "value": "O volume por app requer o macOS 15 ou posterior" } }, + "ru": { "stringUnit": { "state": "translated", "value": "Для регулировки громкости приложений требуется macOS 15 или новее" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "应用独立音量需要 macOS 15 或更高版本" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "應用程式獨立音量需要 macOS 15 或以上版本" } } + } + }, + "metadata.description": { + "extractionState": "manual", + "localizations": { + "ar": { "stringUnit": { "state": "translated", "value": "اضبط مستوى صوت كل تطبيق يشغّل الصوت على حدة" } }, + "de": { "stringUnit": { "state": "translated", "value": "Lautstärke aktiver Audio-Apps einzeln anpassen" } }, + "en": { "stringUnit": { "state": "translated", "value": "Adjust the volume of each app currently playing audio" } }, + "es": { "stringUnit": { "state": "translated", "value": "Ajusta por separado el volumen de cada aplicación con audio" } }, + "fr": { "stringUnit": { "state": "translated", "value": "Réglez séparément le volume de chaque app qui diffuse du son" } }, + "ja": { "stringUnit": { "state": "translated", "value": "再生中のアプリごとに音量を調整します" } }, + "ko": { "stringUnit": { "state": "translated", "value": "오디오를 재생 중인 앱별로 음량을 조절합니다" } }, + "pt": { "stringUnit": { "state": "translated", "value": "Ajuste separadamente o volume de cada app que reproduz áudio" } }, + "ru": { "stringUnit": { "state": "translated", "value": "Настраивайте громкость каждого приложения с активным звуком" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "分别调节正在播放音频的应用音量" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "分別調整正在播放音訊的應用程式音量" } } + } + }, + "metadata.title": { + "extractionState": "manual", + "localizations": { + "ar": { "stringUnit": { "state": "translated", "value": "مستوى صوت التطبيقات" } }, + "de": { "stringUnit": { "state": "translated", "value": "App-Lautstärke" } }, + "en": { "stringUnit": { "state": "translated", "value": "App Volume" } }, + "es": { "stringUnit": { "state": "translated", "value": "Volumen por aplicación" } }, + "fr": { "stringUnit": { "state": "translated", "value": "Volume des apps" } }, + "ja": { "stringUnit": { "state": "translated", "value": "アプリ音量" } }, + "ko": { "stringUnit": { "state": "translated", "value": "앱 음량" } }, + "pt": { "stringUnit": { "state": "translated", "value": "Volume por app" } }, + "ru": { "stringUnit": { "state": "translated", "value": "Громкость приложений" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "应用音量" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "應用程式音量" } } + } + }, + "panel.permission.description": { + "extractionState": "manual", + "localizations": { + "ar": { "stringUnit": { "state": "translated", "value": "اسمح بمعالجة صوت التطبيقات الأخرى في الوقت الفعلي." } }, + "de": { "stringUnit": { "state": "translated", "value": "Erlauben Sie die Echtzeitverarbeitung von Audio anderer Apps." } }, + "en": { "stringUnit": { "state": "translated", "value": "Allow access to process audio from other apps in real time." } }, + "es": { "stringUnit": { "state": "translated", "value": "Permite procesar en tiempo real el audio de otras aplicaciones." } }, + "fr": { "stringUnit": { "state": "translated", "value": "Autorisez le traitement en temps réel de l’audio des autres apps." } }, + "ja": { "stringUnit": { "state": "translated", "value": "ほかのアプリの音声をリアルタイムで処理することを許可します。" } }, + "ko": { "stringUnit": { "state": "translated", "value": "다른 앱의 오디오를 실시간으로 처리하도록 허용합니다." } }, + "pt": { "stringUnit": { "state": "translated", "value": "Permita processar em tempo real o áudio de outros apps." } }, + "ru": { "stringUnit": { "state": "translated", "value": "Разрешите обработку звука других приложений в реальном времени." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "允许后才能实时处理其他应用的音频。" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "允許後才能即時處理其他應用程式的音訊。" } } + } + }, + "panel.permission.openSettings": { + "extractionState": "manual", + "localizations": { + "ar": { "stringUnit": { "state": "translated", "value": "فتح إعدادات الخصوصية" } }, + "de": { "stringUnit": { "state": "translated", "value": "Datenschutzeinstellungen öffnen" } }, + "en": { "stringUnit": { "state": "translated", "value": "Open Privacy Settings" } }, + "es": { "stringUnit": { "state": "translated", "value": "Abrir ajustes de privacidad" } }, + "fr": { "stringUnit": { "state": "translated", "value": "Ouvrir les réglages de confidentialité" } }, + "ja": { "stringUnit": { "state": "translated", "value": "プライバシー設定を開く" } }, + "ko": { "stringUnit": { "state": "translated", "value": "개인정보 보호 설정 열기" } }, + "pt": { "stringUnit": { "state": "translated", "value": "Abrir ajustes de privacidade" } }, + "ru": { "stringUnit": { "state": "translated", "value": "Открыть настройки конфиденциальности" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "打开隐私设置" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "開啟私隱設定" } } + } + }, + "panel.subtitle.countFormat": { + "extractionState": "manual", + "localizations": { + "ar": { "stringUnit": { "state": "translated", "value": "%d تطبيقات تشغّل الصوت" } }, + "de": { "stringUnit": { "state": "translated", "value": "%d Apps geben Audio wieder" } }, + "en": { "stringUnit": { "state": "translated", "value": "%d apps playing audio" } }, + "es": { "stringUnit": { "state": "translated", "value": "%d aplicaciones reproduciendo audio" } }, + "fr": { "stringUnit": { "state": "translated", "value": "%d apps diffusent du son" } }, + "ja": { "stringUnit": { "state": "translated", "value": "%d 個のアプリが再生中" } }, + "ko": { "stringUnit": { "state": "translated", "value": "%d개 앱에서 오디오 재생 중" } }, + "pt": { "stringUnit": { "state": "translated", "value": "%d apps reproduzindo áudio" } }, + "ru": { "stringUnit": { "state": "translated", "value": "%d приложений воспроизводят звук" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "%d 个应用正在播放" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "%d 個應用程式正在播放" } } + } + }, + "panel.subtitle.empty": { + "extractionState": "manual", + "localizations": { + "ar": { "stringUnit": { "state": "translated", "value": "لا توجد تطبيقات تشغّل الصوت حاليًا" } }, + "de": { "stringUnit": { "state": "translated", "value": "Derzeit gibt keine App Audio wieder" } }, + "en": { "stringUnit": { "state": "translated", "value": "No apps are currently playing audio" } }, + "es": { "stringUnit": { "state": "translated", "value": "Ninguna aplicación está reproduciendo audio" } }, + "fr": { "stringUnit": { "state": "translated", "value": "Aucune app ne diffuse actuellement du son" } }, + "ja": { "stringUnit": { "state": "translated", "value": "再生中のアプリはありません" } }, + "ko": { "stringUnit": { "state": "translated", "value": "현재 오디오를 재생 중인 앱이 없습니다" } }, + "pt": { "stringUnit": { "state": "translated", "value": "Nenhum app está reproduzindo áudio no momento" } }, + "ru": { "stringUnit": { "state": "translated", "value": "Нет приложений, воспроизводящих звук" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "暂无正在播放音频的应用" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "暫無正在播放音訊的應用程式" } } + } + }, + "panel.subtitle.unsupported": { + "extractionState": "manual", + "localizations": { + "ar": { "stringUnit": { "state": "translated", "value": "يتطلب macOS 15 أو أحدث" } }, + "de": { "stringUnit": { "state": "translated", "value": "Erfordert macOS 15 oder neuer" } }, + "en": { "stringUnit": { "state": "translated", "value": "Requires macOS 15 or later" } }, + "es": { "stringUnit": { "state": "translated", "value": "Requiere macOS 15 o posterior" } }, + "fr": { "stringUnit": { "state": "translated", "value": "Nécessite macOS 15 ou version ultérieure" } }, + "ja": { "stringUnit": { "state": "translated", "value": "macOS 15 以降が必要です" } }, + "ko": { "stringUnit": { "state": "translated", "value": "macOS 15 이상이 필요합니다" } }, + "pt": { "stringUnit": { "state": "translated", "value": "Requer o macOS 15 ou posterior" } }, + "ru": { "stringUnit": { "state": "translated", "value": "Требуется macOS 15 или новее" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "需要 macOS 15 或更高版本" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "需要 macOS 15 或以上版本" } } + } + }, + "permission.status.onDemand": { + "extractionState": "manual", + "localizations": { + "ar": { "stringUnit": { "state": "translated", "value": "عند الحاجة" } }, + "de": { "stringUnit": { "state": "translated", "value": "Bei Bedarf" } }, + "en": { "stringUnit": { "state": "translated", "value": "On demand" } }, + "es": { "stringUnit": { "state": "translated", "value": "Cuando sea necesario" } }, + "fr": { "stringUnit": { "state": "translated", "value": "À la demande" } }, + "ja": { "stringUnit": { "state": "translated", "value": "必要時" } }, + "ko": { "stringUnit": { "state": "translated", "value": "필요할 때" } }, + "pt": { "stringUnit": { "state": "translated", "value": "Sob demanda" } }, + "ru": { "stringUnit": { "state": "translated", "value": "По запросу" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "按需授权" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "按需授權" } } + } + }, + "permission.status.requesting": { + "extractionState": "manual", + "localizations": { + "ar": { "stringUnit": { "state": "translated", "value": "جارٍ الطلب" } }, + "de": { "stringUnit": { "state": "translated", "value": "Wird angefordert" } }, + "en": { "stringUnit": { "state": "translated", "value": "Requesting" } }, + "es": { "stringUnit": { "state": "translated", "value": "Solicitando" } }, + "fr": { "stringUnit": { "state": "translated", "value": "Demande en cours" } }, + "ja": { "stringUnit": { "state": "translated", "value": "要求中" } }, + "ko": { "stringUnit": { "state": "translated", "value": "요청 중" } }, + "pt": { "stringUnit": { "state": "translated", "value": "Solicitando" } }, + "ru": { "stringUnit": { "state": "translated", "value": "Запрашивается" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "正在请求" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "正在請求" } } + } + }, + "permission.systemAudio.description": { + "extractionState": "manual", + "localizations": { + "ar": { "stringUnit": { "state": "translated", "value": "يعالج صوت التطبيقات محليًا وفي الوقت الفعلي ولا يسجله أو يحفظه أو يرفعه." } }, + "de": { "stringUnit": { "state": "translated", "value": "Verarbeitet App-Audio lokal in Echtzeit und zeichnet es nicht auf, speichert oder lädt es nicht hoch." } }, + "en": { "stringUnit": { "state": "translated", "value": "Processes app audio locally in real time and never records, saves, or uploads it." } }, + "es": { "stringUnit": { "state": "translated", "value": "Procesa localmente y en tiempo real el audio de las aplicaciones; nunca lo graba, guarda ni sube." } }, + "fr": { "stringUnit": { "state": "translated", "value": "Traite l’audio des apps localement et en temps réel, sans jamais l’enregistrer, le sauvegarder ni le téléverser." } }, + "ja": { "stringUnit": { "state": "translated", "value": "アプリの音声をローカルでリアルタイム処理し、録音、保存、アップロードは行いません。" } }, + "ko": { "stringUnit": { "state": "translated", "value": "앱 오디오를 기기에서 실시간으로 처리하며 녹음, 저장 또는 업로드하지 않습니다." } }, + "pt": { "stringUnit": { "state": "translated", "value": "Processa o áudio dos apps localmente e em tempo real, sem gravar, salvar ou enviar." } }, + "ru": { "stringUnit": { "state": "translated", "value": "Обрабатывает звук приложений локально в реальном времени и не записывает, не сохраняет и не загружает его." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "仅在本机实时处理应用音频,不会录制、保存或上传声音。" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "僅在本機即時處理應用程式音訊,不會錄製、儲存或上傳聲音。" } } + } + }, + "permission.systemAudio.footnote.denied": { + "extractionState": "manual", + "localizations": { + "ar": { "stringUnit": { "state": "translated", "value": "افتح إعدادات النظام ← الخصوصية والأمان ← تسجيل الشاشة وصوت النظام، ثم اسمح لـ MacTools." } }, + "de": { "stringUnit": { "state": "translated", "value": "Öffnen Sie Systemeinstellungen → Datenschutz & Sicherheit → Bildschirm- und Systemaudioaufnahme und erlauben Sie MacTools." } }, + "en": { "stringUnit": { "state": "translated", "value": "Open System Settings → Privacy & Security → Screen & System Audio Recording and allow MacTools." } }, + "es": { "stringUnit": { "state": "translated", "value": "Abre Ajustes del Sistema → Privacidad y seguridad → Grabación de pantalla y audio del sistema, y permite MacTools." } }, + "fr": { "stringUnit": { "state": "translated", "value": "Ouvrez Réglages Système → Confidentialité et sécurité → Enregistrement de l’écran et de l’audio système, puis autorisez MacTools." } }, + "ja": { "stringUnit": { "state": "translated", "value": "システム設定 → プライバシーとセキュリティ → 画面とシステムオーディオの録音で、MacTools を許可してください。" } }, + "ko": { "stringUnit": { "state": "translated", "value": "시스템 설정 → 개인정보 보호 및 보안 → 화면 및 시스템 오디오 녹음에서 MacTools를 허용하세요." } }, + "pt": { "stringUnit": { "state": "translated", "value": "Abra Ajustes do Sistema → Privacidade e Segurança → Gravação de Tela e Áudio do Sistema e permita o MacTools." } }, + "ru": { "stringUnit": { "state": "translated", "value": "Откройте «Системные настройки» → «Конфиденциальность и безопасность» → «Запись экрана и системного аудио» и разрешите MacTools." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "前往系统设置 → 隐私与安全性 → 屏幕与系统音频录制,授权 MacTools。" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "前往系統設定 → 私隱與保安 → 螢幕與系統音訊錄製,授權 MacTools。" } } + } + }, + "permission.systemAudio.footnote.requesting": { + "extractionState": "manual", + "localizations": { + "ar": { "stringUnit": { "state": "translated", "value": "اسمح لـ MacTools في مطالبة أذونات macOS." } }, + "de": { "stringUnit": { "state": "translated", "value": "Erlauben Sie MacTools in der macOS-Berechtigungsabfrage." } }, + "en": { "stringUnit": { "state": "translated", "value": "Allow MacTools in the macOS permission prompt." } }, + "es": { "stringUnit": { "state": "translated", "value": "Permite MacTools en el aviso de permisos de macOS." } }, + "fr": { "stringUnit": { "state": "translated", "value": "Autorisez MacTools dans la demande d’autorisation macOS." } }, + "ja": { "stringUnit": { "state": "translated", "value": "macOS のアクセス許可ダイアログで MacTools を許可してください。" } }, + "ko": { "stringUnit": { "state": "translated", "value": "macOS 권한 요청에서 MacTools를 허용하세요." } }, + "pt": { "stringUnit": { "state": "translated", "value": "Permita o MacTools no aviso de permissão do macOS." } }, + "ru": { "stringUnit": { "state": "translated", "value": "Разрешите MacTools в запросе разрешений macOS." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "请在 macOS 权限提示中允许 MacTools。" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "請在 macOS 權限提示中允許 MacTools。" } } + } + }, + "permission.systemAudio.footnote.unknown": { + "extractionState": "manual", + "localizations": { + "ar": { "stringUnit": { "state": "translated", "value": "سيطلب macOS إذن تسجيل صوت النظام عند تعديل مستوى صوت تطبيق لأول مرة." } }, + "de": { "stringUnit": { "state": "translated", "value": "macOS fordert die Berechtigung für Systemaudioaufnahmen an, wenn Sie erstmals die Lautstärke einer App ändern." } }, + "en": { "stringUnit": { "state": "translated", "value": "macOS requests System Audio Recording permission the first time you adjust an app's volume." } }, + "es": { "stringUnit": { "state": "translated", "value": "macOS solicitará permiso para grabar el audio del sistema la primera vez que ajustes el volumen de una aplicación." } }, + "fr": { "stringUnit": { "state": "translated", "value": "macOS demande l’autorisation d’enregistrement de l’audio système lors du premier réglage du volume d’une app." } }, + "ja": { "stringUnit": { "state": "translated", "value": "初めてアプリの音量を調整するときに、macOS がシステムオーディオ録音の許可を求めます。" } }, + "ko": { "stringUnit": { "state": "translated", "value": "앱 음량을 처음 조절할 때 macOS에서 시스템 오디오 녹음 권한을 요청합니다." } }, + "pt": { "stringUnit": { "state": "translated", "value": "O macOS solicitará permissão para gravar o áudio do sistema na primeira vez que você ajustar o volume de um app." } }, + "ru": { "stringUnit": { "state": "translated", "value": "macOS запросит разрешение на запись системного аудио при первой настройке громкости приложения." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "首次调节应用音量时,macOS 会请求系统音频录制权限。" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "首次調整應用程式音量時,macOS 會請求系統音訊錄製權限。" } } + } + }, + "permission.systemAudio.title": { + "extractionState": "manual", + "localizations": { + "ar": { "stringUnit": { "state": "translated", "value": "تسجيل صوت النظام" } }, + "de": { "stringUnit": { "state": "translated", "value": "Systemaudioaufnahme" } }, + "en": { "stringUnit": { "state": "translated", "value": "System Audio Recording" } }, + "es": { "stringUnit": { "state": "translated", "value": "Grabación de audio del sistema" } }, + "fr": { "stringUnit": { "state": "translated", "value": "Enregistrement de l’audio système" } }, + "ja": { "stringUnit": { "state": "translated", "value": "システムオーディオ録音" } }, + "ko": { "stringUnit": { "state": "translated", "value": "시스템 오디오 녹음" } }, + "pt": { "stringUnit": { "state": "translated", "value": "Gravação de Áudio do Sistema" } }, + "ru": { "stringUnit": { "state": "translated", "value": "Запись системного аудио" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "系统音频录制" } }, + "zh-Hant": { "stringUnit": { "state": "translated", "value": "系統音訊錄製" } } + } + } + }, + "version": "1.0" +} diff --git a/Plugins/AppVolume/Sources/AppVolumeModels.swift b/Plugins/AppVolume/Sources/AppVolumeModels.swift new file mode 100644 index 00000000..07224c97 --- /dev/null +++ b/Plugins/AppVolume/Sources/AppVolumeModels.swift @@ -0,0 +1,47 @@ +import CoreAudio +import Foundation + +struct AudioApplication: Identifiable, Equatable, Sendable { + let id: String + let displayName: String + let bundleIdentifier: String? + let processObjectIDs: [AudioObjectID] +} + +struct AudioApplicationSnapshot: Equatable, Sendable { + let applications: [AudioApplication] + let outputDeviceUID: String? + + static let empty = AudioApplicationSnapshot(applications: [], outputDeviceUID: nil) +} + +struct ApplicationVolumeTarget: Equatable, Sendable { + let id: String + let processObjectIDs: [AudioObjectID] + let gain: Double +} + +@MainActor +protocol AudioApplicationMonitoring: AnyObject { + var onUpdate: ((AudioApplicationSnapshot) -> Void)? { get set } + + func start() + func refresh() + func stop() +} + +@MainActor +protocol ApplicationVolumeRouting: AnyObject { + var isSupported: Bool { get } + + func update(targets: [ApplicationVolumeTarget], outputDeviceUID: String?) + func requestSystemAudioAccess() async -> Bool + func stop() +} + +enum AppVolumeAccessState: Equatable { + case unknown + case requesting + case granted + case denied +} diff --git a/Plugins/AppVolume/Sources/AppVolumePlugin.swift b/Plugins/AppVolume/Sources/AppVolumePlugin.swift new file mode 100644 index 00000000..c1be883b --- /dev/null +++ b/Plugins/AppVolume/Sources/AppVolumePlugin.swift @@ -0,0 +1,447 @@ +import AppKit +import Foundation +import SwiftUI +import MacToolsPluginKit + +public final class AppVolumePluginFactory: NSObject, MacToolsPluginBundleFactory { + public static func makeProvider(context: PluginRuntimeContext) throws -> any PluginProvider { + AppVolumePluginProvider(context: context) + } +} + +@MainActor +private struct AppVolumePluginProvider: PluginProvider { + let context: PluginRuntimeContext + + func makePlugins() -> [any MacToolsPlugin] { + [ + AppVolumePlugin( + storage: context.storage, + localization: PluginLocalization(bundle: context.resourceBundle) + ), + ] + } +} + +@MainActor +final class AppVolumePlugin: MacToolsPlugin, PluginPrimaryPanel { + private enum ControlID { + static let volumePrefix = "application-volume." + static let openPermission = "open-system-audio-permission" + } + + private enum PermissionID { + static let systemAudio = "system-audio-recording" + } + + private enum StorageKey { + static let volumes = "applicationVolumes" + } + + let metadata: PluginMetadata + + let primaryPanelDescriptor = PluginPrimaryPanelDescriptor( + controlStyle: .disclosure, + menuActionBehavior: .keepPresented + ) + + var onStateChange: (() -> Void)? + var requestPermissionGuidance: ((String) -> Void)? + var shortcutBindingResolver: ((String) -> ShortcutBinding?)? + + private let monitor: any AudioApplicationMonitoring + private let router: any ApplicationVolumeRouting + private let storage: PluginStorage + private let localization: PluginLocalization + + private var snapshot = AudioApplicationSnapshot.empty + private var volumes: [String: Double] + private var controlApplicationIDs: [String: String] = [:] + private var isExpanded = false + private var accessState = AppVolumeAccessState.unknown + private var accessTask: Task? + + init( + storage: PluginStorage? = nil, + monitor: (any AudioApplicationMonitoring)? = nil, + router: (any ApplicationVolumeRouting)? = nil, + localization: PluginLocalization? = nil + ) { + let storage = storage ?? UserDefaultsPluginStorage(pluginID: "app-volume") + let monitor = monitor ?? CoreAudioApplicationMonitor() + let router = router ?? CoreAudioApplicationVolumeRouter() + let localization = localization ?? PluginLocalization(bundle: .main) + + self.storage = storage + self.monitor = monitor + self.router = router + self.localization = localization + self.volumes = Self.loadVolumes(storage: storage) + self.metadata = PluginMetadata( + id: "app-volume", + title: localization.string("metadata.title", defaultValue: "应用音量"), + iconName: "speaker.wave.2.bubble", + iconTint: Color(nsColor: .systemBlue), + order: 46, + defaultDescription: localization.string( + "metadata.description", + defaultValue: "分别调节正在播放音频的应用音量" + ) + ) + + monitor.onUpdate = { [weak self] snapshot in + self?.receive(snapshot) + } + } + + var primaryPanelState: PluginPanelState { + rebuildControlApplicationIDs() + let applications = snapshot.applications + + return PluginPanelState( + subtitle: panelSubtitle, + isOn: applications.contains { !Self.isUnity(volume(for: $0.id)) }, + isExpanded: isExpanded, + isEnabled: router.isSupported, + isVisible: true, + detail: panelDetail(applications: applications), + errorMessage: panelErrorMessage + ) + } + + var permissionRequirements: [PluginPermissionRequirement] { + guard router.isSupported else { + return [] + } + + return [ + PluginPermissionRequirement( + id: PermissionID.systemAudio, + kind: .screenRecording, + title: localization.string( + "permission.systemAudio.title", + defaultValue: "系统音频录制" + ), + description: localization.string( + "permission.systemAudio.description", + defaultValue: "仅在本机实时处理应用音频,不会录制、保存或上传声音。" + ) + ), + ] + } + + var settingsSections: [PluginSettingsSection] { [] } + var shortcutDefinitions: [PluginShortcutDefinition] { [] } + + func activate(context: PluginRuntimeContext) { + monitor.start() + } + + func deactivate(reason: PluginDeactivationReason) { + accessTask?.cancel() + accessTask = nil + monitor.stop() + router.stop() + snapshot = .empty + onStateChange?() + } + + func refresh() { + monitor.refresh() + } + + func permissionState(for permissionID: String) -> PluginPermissionState { + guard permissionID == PermissionID.systemAudio else { + return PluginPermissionState(isGranted: true, footnote: nil) + } + + switch accessState { + case .unknown: + return PluginPermissionState( + isGranted: false, + footnote: localization.string( + "permission.systemAudio.footnote.unknown", + defaultValue: "首次调节应用音量时,macOS 会请求系统音频录制权限。" + ), + statusText: localization.string("permission.status.onDemand", defaultValue: "按需授权"), + statusSystemImage: "waveform", + statusTone: .neutral + ) + case .requesting: + return PluginPermissionState( + isGranted: false, + footnote: localization.string( + "permission.systemAudio.footnote.requesting", + defaultValue: "请在 macOS 权限提示中允许 MacTools。" + ), + statusText: localization.string("permission.status.requesting", defaultValue: "正在请求"), + statusSystemImage: "ellipsis.circle", + statusTone: .neutral + ) + case .granted: + return PluginPermissionState(isGranted: true, footnote: nil) + case .denied: + return PluginPermissionState( + isGranted: false, + footnote: localization.string( + "permission.systemAudio.footnote.denied", + defaultValue: "前往系统设置 → 隐私与安全性 → 屏幕与系统音频录制,授权 MacTools。" + ) + ) + } + } + + func handlePermissionAction(id: String) { + guard id == PermissionID.systemAudio else { + return + } + + requestAccess(force: true, openSettingsOnFailure: true) + } + + func handleAction(_ action: PluginPanelAction) { + switch action { + case let .setDisclosureExpanded(expanded): + isExpanded = expanded + if expanded { + monitor.refresh() + } + onStateChange?() + case let .setSlider(controlID, value, phase): + guard let applicationID = controlApplicationIDs[controlID] else { + return + } + + volumes[applicationID] = max(0, min(1, value)) + if phase == .ended { + saveVolumes() + } + if !Self.isUnity(volume(for: applicationID)), accessState == .unknown { + requestAccess(force: false, openSettingsOnFailure: false) + } + applyCurrentTargets() + onStateChange?() + case let .invokeAction(controlID) where controlID == ControlID.openPermission: + requestAccess(force: true, openSettingsOnFailure: true) + default: + break + } + } + + func handleSettingsAction(id: String) {} + func handleShortcutAction(id: String) {} + + // MARK: - Snapshot and routing + + private func receive(_ snapshot: AudioApplicationSnapshot) { + self.snapshot = snapshot + rebuildControlApplicationIDs() + applyCurrentTargets() + onStateChange?() + } + + private func applyCurrentTargets() { + let targets = snapshot.applications.map { application in + ApplicationVolumeTarget( + id: application.id, + processObjectIDs: application.processObjectIDs, + gain: volume(for: application.id) + ) + } + let needsProcessing = targets.contains { !Self.isUnity($0.gain) } + + guard needsProcessing else { + router.update(targets: [], outputDeviceUID: snapshot.outputDeviceUID) + return + } + + switch accessState { + case .granted: + router.update(targets: targets, outputDeviceUID: snapshot.outputDeviceUID) + case .unknown, .requesting, .denied: + break + } + } + + private func requestAccess(force: Bool, openSettingsOnFailure: Bool) { + if accessState == .granted { + applyCurrentTargets() + return + } + guard accessTask == nil, force || accessState == .unknown else { + if openSettingsOnFailure, accessState == .denied { + Self.openSystemAudioPrivacySettings() + } + return + } + + accessState = .requesting + onStateChange?() + accessTask = Task { [weak self] in + guard let self else { + return + } + + let granted = await router.requestSystemAudioAccess() + guard !Task.isCancelled else { + return + } + + accessState = granted ? .granted : .denied + accessTask = nil + if granted { + applyCurrentTargets() + } else { + router.update(targets: [], outputDeviceUID: snapshot.outputDeviceUID) + if openSettingsOnFailure { + Self.openSystemAudioPrivacySettings() + } + } + onStateChange?() + } + } + + // MARK: - Panel presentation + + private var panelSubtitle: String { + guard router.isSupported else { + return localization.string("panel.subtitle.unsupported", defaultValue: "需要 macOS 15 或更高版本") + } + + let count = snapshot.applications.count + guard count > 0 else { + return localization.string("panel.subtitle.empty", defaultValue: "暂无正在播放音频的应用") + } + + return localization.format( + "panel.subtitle.countFormat", + defaultValue: "%d 个应用正在播放", + count + ) + } + + private var panelErrorMessage: String? { + guard router.isSupported else { + return localization.string( + "error.unsupported", + defaultValue: "应用独立音量需要 macOS 15 或更高版本" + ) + } + + guard accessState == .denied else { + return nil + } + + return localization.string("error.permissionDenied", defaultValue: "需要系统音频录制权限") + } + + private func panelDetail(applications: [AudioApplication]) -> PluginPanelDetail? { + guard router.isSupported, isExpanded else { + return nil + } + + var controls = applications.map { application in + let value = volume(for: application.id) + return PluginPanelControl( + id: controlID(applicationID: application.id), + kind: .slider, + options: [], + selectedOptionID: nil, + dateValue: nil, + minimumDate: nil, + displayedComponents: nil, + datePickerStyle: nil, + sectionTitle: application.displayName, + sliderValue: value, + sliderBounds: 0...1, + sliderStep: 0.01, + valueLabel: Self.percentLabel(value), + isEnabled: accessState != .requesting + ) + } + + if accessState == .denied { + controls.append( + PluginPanelControl( + id: ControlID.openPermission, + kind: .actionRow, + options: [], + selectedOptionID: nil, + dateValue: nil, + minimumDate: nil, + displayedComponents: nil, + datePickerStyle: nil, + sectionTitle: localization.string( + "panel.permission.description", + defaultValue: "允许后才能实时处理其他应用的音频。" + ), + actionTitle: localization.string( + "panel.permission.openSettings", + defaultValue: "打开隐私设置" + ), + actionIconSystemName: "lock.open", + showsLeadingDivider: !controls.isEmpty, + isEnabled: true + ) + ) + } + + return PluginPanelDetail(controls: controls) + } + + private func rebuildControlApplicationIDs() { + controlApplicationIDs = Dictionary( + uniqueKeysWithValues: snapshot.applications.map { + (controlID(applicationID: $0.id), $0.id) + } + ) + } + + private func controlID(applicationID: String) -> String { + ControlID.volumePrefix + Data(applicationID.utf8).base64EncodedString() + } + + private func volume(for applicationID: String) -> Double { + max(0, min(1, volumes[applicationID] ?? 1)) + } + + // MARK: - Persistence + + private static func loadVolumes(storage: PluginStorage) -> [String: Double] { + guard let data = storage.data(forKey: StorageKey.volumes), + let decoded = try? JSONDecoder().decode([String: Double].self, from: data) else { + return [:] + } + + return decoded.mapValues { max(0, min(1, $0)) } + } + + private func saveVolumes() { + guard let data = try? JSONEncoder().encode(volumes) else { + return + } + storage.set(data, forKey: StorageKey.volumes) + } + + private static func percentLabel(_ value: Double) -> String { + "\(Int((value * 100).rounded()))%" + } + + private static func isUnity(_ gain: Double) -> Bool { + abs(gain - 1) < 0.005 + } + + private static func openSystemAudioPrivacySettings() { + let candidates = [ + "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture", + "x-apple.systempreferences:com.apple.preference.security?Privacy", + ] + + for candidate in candidates { + guard let url = URL(string: candidate), NSWorkspace.shared.open(url) else { + continue + } + return + } + } +} diff --git a/Plugins/AppVolume/Sources/CoreAudioApplicationMonitor.swift b/Plugins/AppVolume/Sources/CoreAudioApplicationMonitor.swift new file mode 100644 index 00000000..f85cff39 --- /dev/null +++ b/Plugins/AppVolume/Sources/CoreAudioApplicationMonitor.swift @@ -0,0 +1,312 @@ +import AppKit +import CoreAudio +import Darwin +import Foundation + +@MainActor +final class CoreAudioApplicationMonitor: AudioApplicationMonitoring { + var onUpdate: ((AudioApplicationSnapshot) -> Void)? + + private let scanLoop = AudioApplicationScanLoop() + private var lastSnapshot = AudioApplicationSnapshot.empty + private var isRunning = false + + func start() { + isRunning = true + scanLoop.start { [weak self] snapshot in + Task { @MainActor in + self?.publish(snapshot) + } + } + } + + func refresh() { + scanLoop.refresh { [weak self] snapshot in + Task { @MainActor in + self?.publish(snapshot) + } + } + } + + func stop() { + isRunning = false + scanLoop.stop() + lastSnapshot = .empty + } + + private func publish(_ snapshot: AudioApplicationSnapshot) { + guard isRunning, snapshot != lastSnapshot else { + return + } + + lastSnapshot = snapshot + onUpdate?(snapshot) + } +} + +private final class AudioApplicationScanLoop: @unchecked Sendable { + typealias Delivery = @Sendable (AudioApplicationSnapshot) -> Void + + private let queue = DispatchQueue( + label: "cc.ggbond.mactools.app-volume.scan", + qos: .utility + ) + private var timer: DispatchSourceTimer? + + func start(delivery: @escaping Delivery) { + queue.async { [weak self] in + guard let self, timer == nil else { + return + } + + let timer = DispatchSource.makeTimerSource(queue: queue) + timer.schedule(deadline: .now(), repeating: 1, leeway: .milliseconds(150)) + timer.setEventHandler { + delivery(CoreAudioApplicationQuery.snapshot()) + } + self.timer = timer + timer.resume() + } + } + + func refresh(delivery: @escaping Delivery) { + queue.async { + delivery(CoreAudioApplicationQuery.snapshot()) + } + } + + func stop() { + queue.sync { + timer?.setEventHandler {} + timer?.cancel() + timer = nil + } + } + + deinit { + timer?.setEventHandler {} + timer?.cancel() + } +} + +private enum CoreAudioApplicationQuery { + private struct Group { + var displayName: String + var bundleIdentifier: String? + var processObjectIDs: [AudioObjectID] + } + + static func snapshot() -> AudioApplicationSnapshot { + let processIDs = objectIDArray( + objectID: AudioObjectID(kAudioObjectSystemObject), + selector: kAudioHardwarePropertyProcessObjectList + ) + var groups: [String: Group] = [:] + + for processObjectID in processIDs { + guard boolProperty( + objectID: processObjectID, + selector: kAudioProcessPropertyIsRunningOutput + ), let pid = pidProperty(objectID: processObjectID), pid != getpid() else { + continue + } + + let audioBundleIdentifier = stringProperty( + objectID: processObjectID, + selector: kAudioProcessPropertyBundleID + ) + let runningApplication = responsibleApplication( + processID: pid, + audioBundleIdentifier: audioBundleIdentifier + ) + let bundleIdentifier = runningApplication?.bundleIdentifier ?? audioBundleIdentifier + let stableID = bundleIdentifier ?? "pid.\(pid)" + let displayName = runningApplication?.localizedName + ?? bundleIdentifier?.split(separator: ".").last.map(String.init) + ?? "PID \(pid)" + + if var group = groups[stableID] { + group.processObjectIDs.append(processObjectID) + groups[stableID] = group + } else { + groups[stableID] = Group( + displayName: displayName, + bundleIdentifier: bundleIdentifier, + processObjectIDs: [processObjectID] + ) + } + } + + let applications = groups.map { id, group in + AudioApplication( + id: id, + displayName: group.displayName, + bundleIdentifier: group.bundleIdentifier, + processObjectIDs: group.processObjectIDs.sorted() + ) + } + .sorted { + $0.displayName.localizedStandardCompare($1.displayName) == .orderedAscending + } + + return AudioApplicationSnapshot( + applications: applications, + outputDeviceUID: defaultOutputDeviceUID() + ) + } + + private static func defaultOutputDeviceUID() -> String? { + var deviceID = AudioDeviceID(kAudioObjectUnknown) + var dataSize = UInt32(MemoryLayout.size) + var address = propertyAddress(selector: kAudioHardwarePropertyDefaultOutputDevice) + let status = AudioObjectGetPropertyData( + AudioObjectID(kAudioObjectSystemObject), + &address, + 0, + nil, + &dataSize, + &deviceID + ) + + guard status == noErr, deviceID != kAudioObjectUnknown else { + return nil + } + + return stringProperty(objectID: deviceID, selector: kAudioDevicePropertyDeviceUID) + } + + private static func objectIDArray( + objectID: AudioObjectID, + selector: AudioObjectPropertySelector + ) -> [AudioObjectID] { + var address = propertyAddress(selector: selector) + var dataSize: UInt32 = 0 + guard AudioObjectGetPropertyDataSize(objectID, &address, 0, nil, &dataSize) == noErr, + dataSize >= MemoryLayout.size else { + return [] + } + + let count = Int(dataSize) / MemoryLayout.size + var values = Array(repeating: AudioObjectID(kAudioObjectUnknown), count: count) + let status = values.withUnsafeMutableBufferPointer { buffer in + AudioObjectGetPropertyData(objectID, &address, 0, nil, &dataSize, buffer.baseAddress!) + } + guard status == noErr else { + return [] + } + + return values.filter { $0 != kAudioObjectUnknown } + } + + private static func pidProperty(objectID: AudioObjectID) -> pid_t? { + var pid = pid_t(0) + var dataSize = UInt32(MemoryLayout.size) + var address = propertyAddress(selector: kAudioProcessPropertyPID) + let status = AudioObjectGetPropertyData( + objectID, + &address, + 0, + nil, + &dataSize, + &pid + ) + return status == noErr && pid > 0 ? pid : nil + } + + private static func boolProperty( + objectID: AudioObjectID, + selector: AudioObjectPropertySelector + ) -> Bool { + var value = UInt32(0) + var dataSize = UInt32(MemoryLayout.size) + var address = propertyAddress(selector: selector) + let status = AudioObjectGetPropertyData( + objectID, + &address, + 0, + nil, + &dataSize, + &value + ) + return status == noErr && value != 0 + } + + private static func stringProperty( + objectID: AudioObjectID, + selector: AudioObjectPropertySelector + ) -> String? { + var value: CFString? + var dataSize = UInt32(MemoryLayout.size) + var address = propertyAddress(selector: selector) + let status = withUnsafeMutablePointer(to: &value) { pointer in + AudioObjectGetPropertyData(objectID, &address, 0, nil, &dataSize, pointer) + } + guard status == noErr else { + return nil + } + + return value as String? + } + + private static func responsibleApplication( + processID: pid_t, + audioBundleIdentifier: String? + ) -> NSRunningApplication? { + var visited: Set = [] + var candidateProcessID = processID + + while candidateProcessID > 0, visited.insert(candidateProcessID).inserted { + if let application = NSRunningApplication(processIdentifier: candidateProcessID), + application.activationPolicy == .regular { + return application + } + + guard let parentProcessID = parentProcessID(processID: candidateProcessID), + parentProcessID != candidateProcessID else { + break + } + candidateProcessID = parentProcessID + } + + guard let audioBundleIdentifier else { + return nil + } + + return NSWorkspace.shared.runningApplications.first { application in + guard application.activationPolicy == .regular, + let bundleIdentifier = application.bundleIdentifier else { + return false + } + + return audioBundleIdentifier == bundleIdentifier + || audioBundleIdentifier.hasPrefix(bundleIdentifier + ".") + } + } + + private static func parentProcessID(processID: pid_t) -> pid_t? { + var processInfo = proc_bsdinfo() + let infoSize = MemoryLayout.stride + let result = proc_pidinfo( + processID, + PROC_PIDTBSDINFO, + 0, + &processInfo, + Int32(infoSize) + ) + guard result == Int32(infoSize), processInfo.pbi_ppid > 0 else { + return nil + } + + return pid_t(processInfo.pbi_ppid) + } + + private static func propertyAddress( + selector: AudioObjectPropertySelector + ) -> AudioObjectPropertyAddress { + AudioObjectPropertyAddress( + mSelector: selector, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + } +} diff --git a/Plugins/AppVolume/Sources/CoreAudioApplicationVolumeRouter.swift b/Plugins/AppVolume/Sources/CoreAudioApplicationVolumeRouter.swift new file mode 100644 index 00000000..890f9943 --- /dev/null +++ b/Plugins/AppVolume/Sources/CoreAudioApplicationVolumeRouter.swift @@ -0,0 +1,670 @@ +import CoreAudio +import Foundation +import OSLog +import Synchronization + +@MainActor +final class CoreAudioApplicationVolumeRouter: ApplicationVolumeRouting { + let isSupported: Bool + + private let worker: any ApplicationVolumeRouteWorking + + init() { + if #available(macOS 15.0, *) { + isSupported = true + worker = ApplicationVolumeRouteWorker() + } else { + isSupported = false + worker = UnsupportedApplicationVolumeRouteWorker() + } + } + + func update(targets: [ApplicationVolumeTarget], outputDeviceUID: String?) { + worker.update(targets: targets, outputDeviceUID: outputDeviceUID) + } + + func requestSystemAudioAccess() async -> Bool { + await worker.requestSystemAudioAccess() + } + + func stop() { + worker.stop() + } +} + +private protocol ApplicationVolumeRouteWorking: Sendable { + func update(targets: [ApplicationVolumeTarget], outputDeviceUID: String?) + func requestSystemAudioAccess() async -> Bool + func stop() +} + +private final class UnsupportedApplicationVolumeRouteWorker: ApplicationVolumeRouteWorking { + func update(targets: [ApplicationVolumeTarget], outputDeviceUID: String?) {} + func requestSystemAudioAccess() async -> Bool { false } + func stop() {} +} + +@available(macOS 15.0, *) +private final class ApplicationVolumeRouteWorker: ApplicationVolumeRouteWorking, @unchecked Sendable { + private let queue = DispatchQueue( + label: "cc.ggbond.mactools.app-volume.route", + qos: .userInitiated + ) + private let logger = Logger( + subsystem: Bundle.main.bundleIdentifier ?? "cc.ggbond.mactools", + category: "AppVolumeRouter" + ) + private var sessions: [String: AudioRouteGainSession] = [:] + + func update(targets: [ApplicationVolumeTarget], outputDeviceUID: String?) { + queue.async { [weak self] in + self?.apply(targets: targets, outputDeviceUID: outputDeviceUID) + } + } + + func requestSystemAudioAccess() async -> Bool { + await withCheckedContinuation { continuation in + queue.async { + continuation.resume(returning: Self.probeSystemAudioAccess()) + } + } + } + + func stop() { + queue.sync { + stopAllSessions() + } + } + + private func apply(targets: [ApplicationVolumeTarget], outputDeviceUID: String?) { + guard let outputDeviceUID else { + stopAllSessions() + return + } + + let desiredTargets = Dictionary( + uniqueKeysWithValues: targets + .filter { !$0.processObjectIDs.isEmpty && !Self.isUnity($0.gain) } + .map { ($0.id, $0) } + ) + + for id in Array(sessions.keys) where desiredTargets[id] == nil { + retireSession(id: id) + } + + for (id, target) in desiredTargets { + let gain = Float(max(0, min(1, target.gain))) + + if let session = sessions[id], + session.processObjectIDs == target.processObjectIDs, + session.outputDeviceUID == outputDeviceUID { + session.setGain(gain) + continue + } + + retireSession(id: id) + guard let session = AudioRouteGainSession( + processObjectIDs: target.processObjectIDs, + outputDeviceUID: outputDeviceUID, + initialGain: gain + ) else { + logger.error("Unable to create an app audio route for \(id, privacy: .public)") + continue + } + + sessions[id] = session + } + } + + private func retireSession(id: String) { + guard let session = sessions.removeValue(forKey: id) else { + return + } + + session.setGain(1) + Thread.sleep(forTimeInterval: 0.015) + stop(session: session) + } + + private func stopAllSessions() { + let activeSessions = Array(sessions.values) + sessions.removeAll() + + for session in activeSessions { + session.setGain(1) + } + if !activeSessions.isEmpty { + Thread.sleep(forTimeInterval: 0.015) + } + for session in activeSessions { + stop(session: session) + } + } + + private func stop(session: AudioRouteGainSession) { + let deadline = Date().addingTimeInterval(0.6) + while !session.stop(), Date() < deadline { + Thread.sleep(forTimeInterval: 0.02) + } + + if !session.isStopped { + logger.error("Timed out while removing an application audio route") + } + } + + private static func probeSystemAudioAccess() -> Bool { + let description = CATapDescription(stereoGlobalTapButExcludeProcesses: []) + description.name = "MacTools Application Volume Permission" + description.isPrivate = true + description.muteBehavior = .unmuted + + var tapID = AudioObjectID(kAudioObjectUnknown) + let status = AudioHardwareCreateProcessTap(description, &tapID) + guard status == noErr, tapID != kAudioObjectUnknown else { + return false + } + + AudioHardwareDestroyProcessTap(tapID) + return true + } + + private static func isUnity(_ gain: Double) -> Bool { + abs(gain - 1) < 0.005 + } +} + +@available(macOS 15.0, *) +private final class AudioRouteGainSession: @unchecked Sendable { + let processObjectIDs: [AudioObjectID] + let outputDeviceUID: String + + private let gainState: RealtimeGainState + private var tapID = AudioObjectID(kAudioObjectUnknown) + private var aggregateDeviceID = AudioObjectID(kAudioObjectUnknown) + private var ioProcID: AudioDeviceIOProcID? + private var isRunning = false + + init?( + processObjectIDs: [AudioObjectID], + outputDeviceUID: String, + initialGain: Float + ) { + guard !processObjectIDs.isEmpty else { + return nil + } + + self.processObjectIDs = processObjectIDs + self.outputDeviceUID = outputDeviceUID + self.gainState = RealtimeGainState(initialGain: initialGain) + + guard Self.outputChannelCount(deviceUID: outputDeviceUID) == 2 else { + return nil + } + + let tapDescription = CATapDescription(stereoMixdownOfProcesses: processObjectIDs) + tapDescription.name = "MacTools Application Volume \(UUID().uuidString)" + tapDescription.isPrivate = true + tapDescription.muteBehavior = .mutedWhenTapped + + guard AudioHardwareCreateProcessTap(tapDescription, &tapID) == noErr, + tapID != kAudioObjectUnknown, + let format = Self.tapFormat(tapID: tapID), + Self.supports(format: format) else { + cleanUpAfterFailedStart() + return nil + } + + let aggregateDescription: [String: Any] = [ + kAudioAggregateDeviceNameKey: "MacTools Application Volume Route", + kAudioAggregateDeviceUIDKey: "cc.ggbond.mactools.app-volume.\(UUID().uuidString)", + kAudioAggregateDeviceIsPrivateKey: true, + kAudioAggregateDeviceMainSubDeviceKey: outputDeviceUID, + kAudioAggregateDeviceSubDeviceListKey: [ + [kAudioSubDeviceUIDKey: outputDeviceUID], + ], + kAudioAggregateDeviceTapListKey: [ + [ + kAudioSubTapUIDKey: tapDescription.uuid.uuidString, + kAudioSubTapDriftCompensationKey: true, + ], + ], + kAudioAggregateDeviceTapAutoStartKey: true, + ] + + guard AudioHardwareCreateAggregateDevice( + aggregateDescription as CFDictionary, + &aggregateDeviceID + ) == noErr, aggregateDeviceID != kAudioObjectUnknown else { + cleanUpAfterFailedStart() + return nil + } + + let gainState = gainState + let createStatus = AudioDeviceCreateIOProcIDWithBlock( + &ioProcID, + aggregateDeviceID, + nil + ) { _, inputData, _, outputData, _ in + Self.render( + inputData: inputData, + outputData: outputData, + format: format, + gainState: gainState + ) + } + guard createStatus == noErr, let ioProcID else { + cleanUpAfterFailedStart() + return nil + } + + guard AudioDeviceStart(aggregateDeviceID, ioProcID) == noErr else { + cleanUpAfterFailedStart() + return nil + } + isRunning = true + } + + func setGain(_ gain: Float) { + gainState.store(max(0, min(1, gain))) + } + + var isStopped: Bool { + ioProcID == nil + && aggregateDeviceID == kAudioObjectUnknown + && tapID == kAudioObjectUnknown + } + + @discardableResult + func stop() -> Bool { + if let ioProcID, aggregateDeviceID != kAudioObjectUnknown { + if isRunning { + let status = AudioDeviceStop(aggregateDeviceID, ioProcID) + guard Self.didStop(status) else { + return false + } + isRunning = false + } + + let status = AudioDeviceDestroyIOProcID(aggregateDeviceID, ioProcID) + guard Self.didDestroy(status) else { + return false + } + self.ioProcID = nil + } + + if aggregateDeviceID != kAudioObjectUnknown { + let status = AudioHardwareDestroyAggregateDevice(aggregateDeviceID) + guard Self.didDestroy(status) else { + return false + } + aggregateDeviceID = kAudioObjectUnknown + } + + if tapID != kAudioObjectUnknown { + let status = AudioHardwareDestroyProcessTap(tapID) + guard Self.didDestroy(status) else { + return false + } + tapID = kAudioObjectUnknown + } + + return true + } + + deinit { + stop() + } + + private func cleanUpAfterFailedStart() { + let deadline = Date().addingTimeInterval(0.6) + while !stop(), Date() < deadline { + Thread.sleep(forTimeInterval: 0.02) + } + } + + private static func didStop(_ status: OSStatus) -> Bool { + status == noErr + || status == kAudioHardwareNotRunningError + || didDestroy(status) + } + + private static func didDestroy(_ status: OSStatus) -> Bool { + status == noErr + || status == kAudioHardwareBadObjectError + || status == kAudioHardwareBadDeviceError + } + + private static func outputChannelCount(deviceUID: String) -> Int? { + guard let deviceID = audioDeviceID(deviceUID: deviceUID) else { + return nil + } + + var address = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyStreamConfiguration, + mScope: kAudioDevicePropertyScopeOutput, + mElement: kAudioObjectPropertyElementMain + ) + var dataSize: UInt32 = 0 + guard AudioObjectGetPropertyDataSize(deviceID, &address, 0, nil, &dataSize) == noErr, + dataSize >= MemoryLayout.size else { + return nil + } + + let storage = UnsafeMutableRawPointer.allocate( + byteCount: Int(dataSize), + alignment: MemoryLayout.alignment + ) + defer { storage.deallocate() } + storage.initializeMemory(as: UInt8.self, repeating: 0, count: Int(dataSize)) + + guard AudioObjectGetPropertyData( + deviceID, + &address, + 0, + nil, + &dataSize, + storage + ) == noErr else { + return nil + } + + let bufferList = storage.assumingMemoryBound(to: AudioBufferList.self) + return UnsafeMutableAudioBufferListPointer(bufferList) + .reduce(0) { $0 + Int($1.mNumberChannels) } + } + + private static func audioDeviceID(deviceUID: String) -> AudioObjectID? { + audioObjectIDs( + objectID: AudioObjectID(kAudioObjectSystemObject), + selector: kAudioHardwarePropertyDevices + ) + .first { deviceID in + stringProperty( + objectID: deviceID, + selector: kAudioDevicePropertyDeviceUID + ) == deviceUID + } + } + + private static func audioObjectIDs( + objectID: AudioObjectID, + selector: AudioObjectPropertySelector + ) -> [AudioObjectID] { + var address = AudioObjectPropertyAddress( + mSelector: selector, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + var dataSize: UInt32 = 0 + guard AudioObjectGetPropertyDataSize(objectID, &address, 0, nil, &dataSize) == noErr, + dataSize >= MemoryLayout.size else { + return [] + } + + let count = Int(dataSize) / MemoryLayout.size + var values = Array(repeating: AudioObjectID(kAudioObjectUnknown), count: count) + let status = values.withUnsafeMutableBufferPointer { buffer in + AudioObjectGetPropertyData( + objectID, + &address, + 0, + nil, + &dataSize, + buffer.baseAddress! + ) + } + return status == noErr ? values.filter { $0 != kAudioObjectUnknown } : [] + } + + private static func stringProperty( + objectID: AudioObjectID, + selector: AudioObjectPropertySelector + ) -> String? { + var value: CFString? + var dataSize = UInt32(MemoryLayout.size) + var address = AudioObjectPropertyAddress( + mSelector: selector, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + let status = withUnsafeMutablePointer(to: &value) { pointer in + AudioObjectGetPropertyData( + objectID, + &address, + 0, + nil, + &dataSize, + pointer + ) + } + return status == noErr ? value as String? : nil + } + + private static func tapFormat(tapID: AudioObjectID) -> AudioStreamBasicDescription? { + var format = AudioStreamBasicDescription() + var dataSize = UInt32(MemoryLayout.size) + var address = AudioObjectPropertyAddress( + mSelector: kAudioTapPropertyFormat, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + let status = AudioObjectGetPropertyData( + tapID, + &address, + 0, + nil, + &dataSize, + &format + ) + return status == noErr ? format : nil + } + + private static func supports(format: AudioStreamBasicDescription) -> Bool { + guard format.mFormatID == kAudioFormatLinearPCM, + format.mBytesPerFrame > 0 else { + return false + } + + let flags = format.mFormatFlags + if flags & kAudioFormatFlagIsFloat != 0 { + return format.mBitsPerChannel == 32 || format.mBitsPerChannel == 64 + } + if flags & kAudioFormatFlagIsSignedInteger != 0 { + return format.mBitsPerChannel == 16 || format.mBitsPerChannel == 32 + } + return false + } + + private static func render( + inputData: UnsafePointer, + outputData: UnsafeMutablePointer, + format: AudioStreamBasicDescription, + gainState: RealtimeGainState + ) { + let inputBuffers = UnsafeMutableAudioBufferListPointer( + UnsafeMutablePointer(mutating: inputData) + ) + let outputBuffers = UnsafeMutableAudioBufferListPointer(outputData) + guard let firstBuffer = inputBuffers.first, + format.mBytesPerFrame > 0 else { + return + } + + let frameCount = firstBuffer.mDataByteSize / format.mBytesPerFrame + guard frameCount > 0 else { + return + } + + let ramp = gainState.nextRamp(frameCount: frameCount) + for index in inputBuffers.indices { + guard index < outputBuffers.count else { + break + } + write( + input: inputBuffers[index], + output: &outputBuffers[index], + format: format, + ramp: ramp, + frameCount: frameCount + ) + } + } + + private static func write( + input: AudioBuffer, + output: inout AudioBuffer, + format: AudioStreamBasicDescription, + ramp: GainRamp, + frameCount: UInt32 + ) { + guard let sourceData = input.mData, + let destinationData = output.mData, + input.mNumberChannels == output.mNumberChannels, + output.mDataByteSize >= input.mDataByteSize else { + return + } + + output.mDataByteSize = input.mDataByteSize + let flags = format.mFormatFlags + let channels = max(Int(input.mNumberChannels), 1) + + if flags & kAudioFormatFlagIsFloat != 0, format.mBitsPerChannel == 32 { + scaleFloat32( + source: sourceData, + destination: destinationData, + sampleCount: Int(input.mDataByteSize) / MemoryLayout.size, + channels: channels, + frameCount: Int(frameCount), + ramp: ramp + ) + } else if flags & kAudioFormatFlagIsFloat != 0, format.mBitsPerChannel == 64 { + scaleFloat64( + source: sourceData, + destination: destinationData, + sampleCount: Int(input.mDataByteSize) / MemoryLayout.size, + channels: channels, + frameCount: Int(frameCount), + ramp: ramp + ) + } else if format.mBitsPerChannel == 16 { + scaleInt16( + source: sourceData, + destination: destinationData, + sampleCount: Int(input.mDataByteSize) / MemoryLayout.size, + channels: channels, + frameCount: Int(frameCount), + ramp: ramp + ) + } else if format.mBitsPerChannel == 32 { + scaleInt32( + source: sourceData, + destination: destinationData, + sampleCount: Int(input.mDataByteSize) / MemoryLayout.size, + channels: channels, + frameCount: Int(frameCount), + ramp: ramp + ) + } + } + + private static func scaleFloat32( + source: UnsafeRawPointer, + destination: UnsafeMutableRawPointer, + sampleCount: Int, + channels: Int, + frameCount: Int, + ramp: GainRamp + ) { + let input = source.assumingMemoryBound(to: Float.self) + let output = destination.assumingMemoryBound(to: Float.self) + for sample in 0.. + private var renderedGain: Float + + init(initialGain: Float) { + let gain = max(0, min(1, initialGain)) + targetBits = Atomic(gain.bitPattern) + renderedGain = gain + } + + func store(_ gain: Float) { + targetBits.store(gain.bitPattern, ordering: .relaxed) + } + + func nextRamp(frameCount: UInt32) -> GainRamp { + let target = Float(bitPattern: targetBits.load(ordering: .relaxed)) + let ramp = GainRamp(start: renderedGain, end: target) + renderedGain = target + return ramp + } +} + +private struct GainRamp: Sendable { + let start: Float + let end: Float + + func value(frame: Int, frameCount: Int) -> Float { + guard frameCount > 1 else { + return end + } + + let progress = Float(min(max(frame, 0), frameCount - 1)) / Float(frameCount - 1) + return start + (end - start) * progress + } +} diff --git a/Plugins/AppVolume/Tests/AppVolumePluginTests.swift b/Plugins/AppVolume/Tests/AppVolumePluginTests.swift new file mode 100644 index 00000000..0a2bf3f0 --- /dev/null +++ b/Plugins/AppVolume/Tests/AppVolumePluginTests.swift @@ -0,0 +1,244 @@ +import CoreAudio +import XCTest +import MacToolsPluginKit +@testable import AppVolumePlugin +@testable import MacTools + +@MainActor +final class AppVolumePluginTests: XCTestCase { + func testMetadataAndPermissionRequirement() { + let plugin = makePlugin() + + XCTAssertEqual(plugin.metadata.id, "app-volume") + XCTAssertEqual(plugin.metadata.title, "应用音量") + XCTAssertEqual(plugin.primaryPanelDescriptor.controlStyle, .disclosure) + XCTAssertEqual(plugin.permissionRequirements.map(\.id), ["system-audio-recording"]) + } + + func testPanelShowsSliderForEachPlayingApplication() { + let monitor = AppVolumeMonitorMock() + let plugin = makePlugin(monitor: monitor) + plugin.activate(context: PluginRuntimeContext(pluginID: "app-volume")) + + monitor.send(snapshot(applications: [ + application(id: "com.example.music", name: "Music", objectID: 41), + application(id: "com.example.browser", name: "Browser", objectID: 42), + ])) + plugin.handleAction(.setDisclosureExpanded(true)) + + let state = plugin.primaryPanelState + let sliders = state.detail?.controls.filter { $0.kind == .slider } ?? [] + XCTAssertEqual(sliders.map(\.sectionTitle), ["Browser", "Music"]) + XCTAssertEqual(sliders.compactMap(\.sliderValue), [1, 1]) + XCTAssertEqual(sliders.map(\.valueLabel), ["100%", "100%"]) + XCTAssertEqual(state.subtitle, "2 个应用正在播放") + } + + func testChangingSliderRequestsAccessAndRoutesTarget() async throws { + let monitor = AppVolumeMonitorMock() + let router = AppVolumeRouterMock(accessResult: true) + let plugin = makePlugin(monitor: monitor, router: router) + plugin.activate(context: PluginRuntimeContext(pluginID: "app-volume")) + monitor.send(snapshot(applications: [ + application(id: "com.example.music", name: "Music", objectID: 51), + ])) + plugin.handleAction(.setDisclosureExpanded(true)) + let sliderID = try XCTUnwrap(plugin.primaryPanelState.detail?.controls.first?.id) + + plugin.handleAction(.setSlider(controlID: sliderID, value: 0.35, phase: .ended)) + await Task.yield() + await Task.yield() + + XCTAssertEqual(router.accessRequestCount, 1) + let target = try XCTUnwrap(router.lastTargets.first) + XCTAssertEqual(target.id, "com.example.music") + XCTAssertEqual(target.gain, 0.35, accuracy: 0.001) + XCTAssertTrue(plugin.primaryPanelState.isOn) + } + + func testReturningSliderToUnityStopsProcessing() async throws { + let monitor = AppVolumeMonitorMock() + let router = AppVolumeRouterMock(accessResult: true) + let plugin = makePlugin(monitor: monitor, router: router) + plugin.activate(context: PluginRuntimeContext(pluginID: "app-volume")) + monitor.send(snapshot(applications: [ + application(id: "com.example.music", name: "Music", objectID: 61), + ])) + plugin.handleAction(.setDisclosureExpanded(true)) + let sliderID = try XCTUnwrap(plugin.primaryPanelState.detail?.controls.first?.id) + + plugin.handleAction(.setSlider(controlID: sliderID, value: 0.5, phase: .ended)) + await Task.yield() + await Task.yield() + plugin.handleAction(.setSlider(controlID: sliderID, value: 1, phase: .ended)) + + XCTAssertTrue(router.lastTargets.isEmpty) + XCTAssertFalse(plugin.primaryPanelState.isOn) + } + + func testVolumePreferenceIsRestoredForMatchingApplication() throws { + let storage = AppVolumeStorageMock() + let monitor = AppVolumeMonitorMock() + let firstPlugin = makePlugin(storage: storage, monitor: monitor) + firstPlugin.activate(context: PluginRuntimeContext(pluginID: "app-volume")) + monitor.send(snapshot(applications: [ + application(id: "com.example.music", name: "Music", objectID: 71), + ])) + firstPlugin.handleAction(.setDisclosureExpanded(true)) + let sliderID = try XCTUnwrap(firstPlugin.primaryPanelState.detail?.controls.first?.id) + firstPlugin.handleAction(.setSlider(controlID: sliderID, value: 0.2, phase: .ended)) + + let secondMonitor = AppVolumeMonitorMock() + let secondPlugin = makePlugin(storage: storage, monitor: secondMonitor) + secondPlugin.activate(context: PluginRuntimeContext(pluginID: "app-volume")) + secondMonitor.send(snapshot(applications: [ + application(id: "com.example.music", name: "Music", objectID: 72), + ])) + secondPlugin.handleAction(.setDisclosureExpanded(true)) + + XCTAssertEqual(secondPlugin.primaryPanelState.detail?.controls.first?.sliderValue, 0.2) + } + + func testRestoredVolumeDoesNotRequestAccessUntilUserChangesIt() async throws { + let storage = AppVolumeStorageMock() + let firstMonitor = AppVolumeMonitorMock() + let firstPlugin = makePlugin(storage: storage, monitor: firstMonitor) + firstPlugin.activate(context: PluginRuntimeContext(pluginID: "app-volume")) + firstMonitor.send(snapshot(applications: [ + application(id: "com.example.music", name: "Music", objectID: 81), + ])) + firstPlugin.handleAction(.setDisclosureExpanded(true)) + let firstSliderID = try XCTUnwrap(firstPlugin.primaryPanelState.detail?.controls.first?.id) + firstPlugin.handleAction(.setSlider(controlID: firstSliderID, value: 0.2, phase: .ended)) + + let restoredMonitor = AppVolumeMonitorMock() + let restoredRouter = AppVolumeRouterMock(accessResult: true) + let restoredPlugin = makePlugin(storage: storage, monitor: restoredMonitor, router: restoredRouter) + restoredPlugin.activate(context: PluginRuntimeContext(pluginID: "app-volume")) + restoredMonitor.send(snapshot(applications: [ + application(id: "com.example.music", name: "Music", objectID: 82), + ])) + await Task.yield() + + XCTAssertEqual(restoredRouter.accessRequestCount, 0) + XCTAssertTrue(restoredRouter.lastTargets.isEmpty) + } + + func testDeactivationStopsMonitorAndRouter() { + let monitor = AppVolumeMonitorMock() + let router = AppVolumeRouterMock() + let plugin = makePlugin(monitor: monitor, router: router) + plugin.activate(context: PluginRuntimeContext(pluginID: "app-volume")) + + plugin.deactivate(reason: .disabled) + + XCTAssertFalse(monitor.isRunning) + XCTAssertTrue(router.didStop) + XCTAssertTrue(plugin.primaryPanelState.detail?.controls.isEmpty ?? true) + } + + func testUnsupportedSystemDisablesPlugin() { + let router = AppVolumeRouterMock(isSupported: false) + let plugin = makePlugin(router: router) + + XCTAssertFalse(plugin.primaryPanelState.isEnabled) + XCTAssertEqual(plugin.primaryPanelState.subtitle, "需要 macOS 15 或更高版本") + XCTAssertTrue(plugin.permissionRequirements.isEmpty) + } + + private func makePlugin( + storage: AppVolumeStorageMock? = nil, + monitor: AppVolumeMonitorMock? = nil, + router: AppVolumeRouterMock? = nil + ) -> AppVolumePlugin { + AppVolumePlugin( + storage: storage ?? AppVolumeStorageMock(), + monitor: monitor ?? AppVolumeMonitorMock(), + router: router ?? AppVolumeRouterMock() + ) + } + + private func snapshot(applications: [AudioApplication]) -> AudioApplicationSnapshot { + AudioApplicationSnapshot( + applications: applications.sorted { + $0.displayName.localizedStandardCompare($1.displayName) == .orderedAscending + }, + outputDeviceUID: "test-output" + ) + } + + private func application(id: String, name: String, objectID: AudioObjectID) -> AudioApplication { + AudioApplication( + id: id, + displayName: name, + bundleIdentifier: id, + processObjectIDs: [objectID] + ) + } +} + +@MainActor +private final class AppVolumeMonitorMock: AudioApplicationMonitoring { + var onUpdate: ((AudioApplicationSnapshot) -> Void)? + private(set) var isRunning = false + + func start() { + isRunning = true + } + + func refresh() {} + + func stop() { + isRunning = false + } + + func send(_ snapshot: AudioApplicationSnapshot) { + onUpdate?(snapshot) + } +} + +@MainActor +private final class AppVolumeRouterMock: ApplicationVolumeRouting { + let isSupported: Bool + let accessResult: Bool + private(set) var accessRequestCount = 0 + private(set) var updates: [[ApplicationVolumeTarget]] = [] + private(set) var didStop = false + + var lastTargets: [ApplicationVolumeTarget] { + updates.last ?? [] + } + + init(isSupported: Bool = true, accessResult: Bool = true) { + self.isSupported = isSupported + self.accessResult = accessResult + } + + func update(targets: [ApplicationVolumeTarget], outputDeviceUID: String?) { + updates.append(targets) + } + + func requestSystemAudioAccess() async -> Bool { + accessRequestCount += 1 + return accessResult + } + + func stop() { + didStop = true + } +} + +@MainActor +private final class AppVolumeStorageMock: PluginStorage { + private var values: [String: Any] = [:] + + func object(forKey key: String) -> Any? { values[key] } + func data(forKey key: String) -> Data? { values[key] as? Data } + func string(forKey key: String) -> String? { values[key] as? String } + func stringArray(forKey key: String) -> [String]? { values[key] as? [String] } + func integer(forKey key: String) -> Int { values[key] as? Int ?? 0 } + func bool(forKey key: String) -> Bool { values[key] as? Bool ?? false } + func set(_ value: Any?, forKey key: String) { values[key] = value } + func removeObject(forKey key: String) { values.removeValue(forKey: key) } + func migrateValueIfNeeded(fromLegacyKey legacyKey: String, to key: String) {} +} diff --git a/Plugins/AppVolume/plugin.json b/Plugins/AppVolume/plugin.json new file mode 100644 index 00000000..a85c4782 --- /dev/null +++ b/Plugins/AppVolume/plugin.json @@ -0,0 +1,69 @@ +{ + "id": "app-volume", + "displayName": "应用音量", + "summary": "分别调节正在播放音频的应用音量", + "localizedMetadata": { + "ar": { + "displayName": "مستوى صوت التطبيقات", + "summary": "اضبط مستوى صوت كل تطبيق يشغّل الصوت على حدة." + }, + "de": { + "displayName": "App-Lautstärke", + "summary": "Lautstärke aktiver Audio-Apps einzeln anpassen." + }, + "en": { + "displayName": "App Volume", + "summary": "Adjust the volume of each app currently playing audio." + }, + "es": { + "displayName": "Volumen por aplicación", + "summary": "Ajusta por separado el volumen de cada aplicación con audio." + }, + "fr": { + "displayName": "Volume des apps", + "summary": "Réglez séparément le volume de chaque app qui diffuse du son." + }, + "ja": { + "displayName": "アプリ音量", + "summary": "再生中のアプリごとに音量を調整します。" + }, + "ko": { + "displayName": "앱 음량", + "summary": "오디오를 재생 중인 앱별로 음량을 조절합니다." + }, + "pt": { + "displayName": "Volume por app", + "summary": "Ajuste separadamente o volume de cada app que reproduz áudio." + }, + "ru": { + "displayName": "Громкость приложений", + "summary": "Настраивайте громкость каждого приложения с активным звуком." + }, + "zh-Hans": { + "displayName": "应用音量", + "summary": "分别调节正在播放音频的应用音量" + }, + "zh-Hant": { + "displayName": "應用程式音量", + "summary": "分別調整正在播放音訊的應用程式音量" + } + }, + "version": "1.0.0", + "minHostVersion": "1.1.4", + "pluginKitVersion": 3, + "bundleRelativePath": "AppVolume.bundle", + "factoryClass": "AppVolumePlugin.AppVolumePluginFactory", + "build": { + "project": "../../MacTools.xcodeproj", + "scheme": "AppVolumePlugin" + }, + "capabilities": { + "primaryPanel": true, + "componentPanel": false, + "configuration": false + }, + "permissions": [ + "screen-recording" + ], + "category": "audio" +} diff --git a/Plugins/DiskClean/Resources/Localizable.xcstrings b/Plugins/DiskClean/Resources/Localizable.xcstrings index 6528cfd8..4a776387 100644 --- a/Plugins/DiskClean/Resources/Localizable.xcstrings +++ b/Plugins/DiskClean/Resources/Localizable.xcstrings @@ -1853,67 +1853,67 @@ "ar": { "stringUnit": { "state": "translated", - "value": "تنظيف ذاكرة التخزين المؤقت، وذاكرة التخزين المؤقت للمطورين، وذاكرة التخزين المؤقت للمتصفح." + "value": "يمسح ذاكرات التخزين المؤقت للنظام ومخرجات التطوير وبرامج التثبيت المتبقية؛ ينقل افتراضيًا إلى سلة المهملات مع فحوصات أمان المسار قبل الحذف." } }, "de": { "stringUnit": { "state": "translated", - "value": "Caches, Entwickler-Caches und Browser-Caches bereinigen." + "value": "System-Caches, Entwicklerartefakte und übrig gebliebene Installer scannen; standardmäßig in den Papierkorb, mit Pfadsicherheitsprüfung vor dem Löschen." } }, "en": { "stringUnit": { "state": "translated", - "value": "Clean caches, developer caches, and browser caches." + "value": "Scan system caches, developer artifacts, and leftover installers; move to Trash by default with path safety checks before deletion" } }, "es": { "stringUnit": { "state": "translated", - "value": "Limpiar cachés, cachés de desarrolladores y cachés de navegador." + "value": "Escanea cachés del sistema, artefactos de desarrollo e instaladores residuales; por defecto mueve a la Papelera, con comprobaciones de seguridad de ruta antes de borrar." } }, "fr": { "stringUnit": { "state": "translated", - "value": "Nettoyer les caches, les caches des développeurs et les caches du navigateur." + "value": "Analyse les caches système, les artefacts de développement et les installateurs restants ; met à la corbeille par défaut, avec contrôles de sécurité des chemins avant suppression." } }, "ja": { "stringUnit": { "state": "translated", - "value": "キャッシュ、開発者キャッシュ、ブラウザ キャッシュをクリーンアップします。" + "value": "システムキャッシュ、開発成果物、残留インストーラをスキャン。既定でゴミ箱へ移動し、削除前にパス安全性を確認します。" } }, "ko": { "stringUnit": { "state": "translated", - "value": "캐시, 개발자 캐시 및 브라우저 캐시를 정리합니다." + "value": "시스템 캐시, 개발 산출물, 잔여 설치 패키지를 스캔합니다. 기본적으로 휴지통으로 옮기며 삭제 전 경로 안전성을 검사합니다." } }, "pt": { "stringUnit": { "state": "translated", - "value": "Limpe caches, caches de desenvolvedores e caches de navegador." + "value": "Analisa caches do sistema, artefactos de desenvolvimento e instaladores residuais; por predefinição move para o Lixo, com verificações de segurança do caminho antes de apagar." } }, "ru": { "stringUnit": { "state": "translated", - "value": "Очистка кеша, кеша разработчика и кеша браузера." + "value": "Сканирует системные кэши, артефакты разработки и оставшиеся установщики; по умолчанию перемещает в Корзину с проверкой безопасности пути перед удалением." } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "缓存、开发者缓存和浏览器缓存清理" + "value": "扫描系统缓存、开发产物与残留安装包,默认移到废纸篓,执行前校验路径安全" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "緩存、開發者緩存和瀏覽器緩存清理" + "value": "掃描系統快取、開發產物與殘留安裝包,預設移到廢紙簍,執行前校驗路徑安全" } } } @@ -3692,6 +3692,9582 @@ } } } + }, + "detail.category.collapse": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "收起项目" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Collapse items" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "收起項目" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "項目を折りたたむ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "항목 접기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Elemente einklappen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Contraer elementos" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Réduire les éléments" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Recolher itens" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Свернуть элементы" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "طي العناصر" + } + } + } + }, + "detail.category.expand": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "展开项目" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Expand items" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "展開項目" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "項目を展開" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "항목 펼치기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Elemente ausklappen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Expandir elementos" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Développer les éléments" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Expandir itens" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Развернуть элементы" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "توسيع العناصر" + } + } + } + }, + "detail.category.counts": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "共 %d 项 · 可清理 %d 项 · 已选 %d 项" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%d total · %d cleanable · %d selected" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "共 %d 項 · 可清理 %d 項 · 已選 %d 項" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "合計 %d · クリーン可 %d · 選択 %d" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "총 %d개 · 정리 가능 %d개 · 선택 %d개" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%d gesamt · %d bereinigbar · %d ausgewählt" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%d en total · %d limpiables · %d seleccionados" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%d au total · %d nettoyables · %d sélectionnés" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "%d no total · %d limpáveis · %d selecionados" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Всего %d · можно очистить %d · выбрано %d" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "الإجمالي %d · قابل للتنظيف %d · محدد %d" + } + } + } + }, + "detail.category.deselectAll": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "取消选择本类全部项目" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Deselect all items in this category" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "取消選擇本類全部項目" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このカテゴリの選択をすべて解除" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 범주의 모든 항목 선택 해제" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Alle Elemente dieser Kategorie abwählen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Deseleccionar todos los elementos de esta categoría" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Désélectionner tous les éléments de cette catégorie" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Desmarcar todos os itens desta categoria" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Снять выбор со всех элементов этой категории" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إلغاء تحديد كل عناصر هذه الفئة" + } + } + } + }, + "detail.category.selectLowRisk": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "选中本类所有低风险项目" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Select all low-risk items in this category" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "選中本類所有低風險項目" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このカテゴリの低リスク項目をすべて選択" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 범주의 모든 저위험 항목 선택" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Alle risikoarmen Elemente dieser Kategorie auswählen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Seleccionar todos los elementos de bajo riesgo de esta categoría" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Sélectionner tous les éléments à faible risque de cette catégorie" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Selecionar todos os itens de baixo risco desta categoria" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выбрать все элементы низкого риска в этой категории" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تحديد كل عناصر المخاطر المنخفضة في هذه الفئة" + } + } + } + }, + "detail.candidate.sizing": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "计算中…" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Calculating…" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "計算中…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "計算中…" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "계산 중…" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wird berechnet…" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Calculando…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Calcul…" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Calculando…" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Вычисление…" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "جارٍ الحساب…" + } + } + } + }, + "badge.inUse": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "使用中:%@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "In use: %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "使用中:%@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "使用中:%@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사용 중: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "In Verwendung: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "En uso: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "En cours d’utilisation : %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Em uso: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Используется: %@" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "قيد الاستخدام: %@" + } + } + } + }, + "badge.whitelisted": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "白名单" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Allowlist" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "白名單" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "許可リスト" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "허용 목록" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zulassungsliste" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Lista de permitidos" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Liste d’autorisation" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Lista de permissões" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Белый список" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "القائمة البيضاء" + } + } + } + }, + "badge.protected": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "受保护" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Protected" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "受保護" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "保護されています" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "보호됨" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Geschützt" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Protegido" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Protégé" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Protegido" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Защищено" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "محمي" + } + } + } + }, + "badge.invalid": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "路径不安全" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Unsafe path" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "路徑不安全" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "安全でないパス" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "안전하지 않은 경로" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Unsicherer Pfad" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ruta no segura" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Chemin non sécurisé" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Caminho inseguro" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Небезопасный путь" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "مسار غير آمن" + } + } + } + }, + "badge.requiresAdmin": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "需要管理员" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Administrator required" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "需要管理員" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "管理者権限が必要" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "관리자 권한 필요" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Administrator erforderlich" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Se requiere administrador" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Administrateur requis" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Administrador necessário" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Требуются права администратора" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "يتطلب مسؤولًا" + } + } + } + }, + "badge.sizing": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "计算中" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Calculating…" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "計算中" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "計算中…" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "계산 중…" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wird berechnet…" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Calculando…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Calcul…" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Calculando…" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Вычисление…" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "جارٍ الحساب…" + } + } + } + }, + "badge.crossedMountPoint": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "含挂载点" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Contains mount point" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "含掛載點" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "マウントポイントを含む" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "마운트 지점 포함" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Enthält Mount-Punkt" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Contiene punto de montaje" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Contient un point de montage" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Contém ponto de montagem" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Содержит точку монтирования" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "يحتوي على نقطة تركيب" + } + } + } + }, + "badge.partial": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "扫描不完整:%@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Incomplete scan: %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "掃描不完整:%@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スキャン不完全:%@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "불완전한 스캔: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Unvollständiger Scan: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Escaneo incompleto: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Analyse incomplète : %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Varredura incompleta: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Неполное сканирование: %@" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "مسح غير مكتمل: %@" + } + } + } + }, + "badge.mayNotBeInstaller": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未必是安装包" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "May not be an installer" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未必是安裝包" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インストーラではない可能性" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "설치 패키지가 아닐 수 있음" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Möglicherweise kein Installer" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Puede que no sea un instalador" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Peut ne pas être un installateur" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Pode não ser um instalador" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Может быть не установщиком" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "قد لا يكون برنامج تثبيت" + } + } + } + }, + "badge.recentlyDownloaded": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "近期下载:%@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Recently downloaded: %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "近期下載:%@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "最近のダウンロード:%@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "최근 다운로드: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kürzlich heruntergeladen: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Descargado recientemente: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Téléchargé récemment : %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Baixado recentemente: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Недавно загружено: %@" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "نُزّل مؤخراً: %@" + } + } + } + }, + "badge.repository.uncommitted": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "仓库有未提交改动" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Uncommitted changes in repository" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "倉庫有未提交改動" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リポジトリに未コミットの変更" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "저장소에 커밋되지 않은 변경" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Uncommittete Änderungen im Repository" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cambios sin confirmar en el repositorio" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Modifications non validées dans le dépôt" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Alterações não confirmadas no repositório" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Незакоммиченные изменения в репозитории" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تغييرات غير مُرسَلة في المستودع" + } + } + } + }, + "badge.repository.unpushed": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "仓库有未推送提交" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Unpushed commits in repository" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "倉庫有未推送提交" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リポジトリに未プッシュのコミット" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "저장소에 푸시되지 않은 커밋" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nicht gepushte Commits im Repository" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Commits sin enviar en el repositorio" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Commits non poussés dans le dépôt" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Commits não enviados no repositório" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Неотправленные коммиты в репозитории" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إرسالات غير مدفوعة في المستودع" + } + } + } + }, + "badge.repository.inspectionFailed": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法确认仓库状态:%@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Couldn’t verify repository status: %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法確認倉庫狀態:%@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リポジトリ状態を確認できません:%@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "저장소 상태를 확인할 수 없음: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Repository-Status nicht prüfbar: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo verificar el repositorio: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible de vérifier le dépôt : %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Não foi possível verificar o repositório: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось проверить репозиторий: %@" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذّر التحقق من حالة المستودع: %@" + } + } + } + }, + "badge.changedSinceScan": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "内容已变化,请重新扫描" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Content changed — rescan" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "內容已變化,請重新掃描" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "内容が変更されました。再スキャンしてください" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "내용이 변경됨 — 다시 스캔하세요" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Inhalt geändert — erneut scannen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Contenido cambiado: vuelve a escanear" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Contenu modifié — rescanner" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Conteúdo alterado — escaneie de novo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Содержимое изменилось — пересканируйте" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تغيّر المحتوى — أعد المسح" + } + } + } + }, + "badge.partiallyDeleted": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "只删除了一部分" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Only partly deleted" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "只刪除了一部分" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "一部のみ削除" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "일부만 삭제됨" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nur teilweise gelöscht" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Eliminado solo en parte" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Supprimé en partie seulement" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Excluído apenas em parte" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Удалено лишь частично" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "حُذف جزئياً فقط" + } + } + } + }, + "badge.rollbackBlocked": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法放回原处" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Couldn’t restore to original location" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法放回原處" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "元の場所に戻せません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "원래 위치로 되돌릴 수 없음" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wiederherstellung am Ursprungspfad blockiert" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo restaurar a la ubicación original" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible de restaurer à l’emplacement d’origine" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Não foi possível restaurar ao local original" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось вернуть на исходное место" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذّر الإرجاع إلى الموقع الأصلي" + } + } + } + }, + "history.status.ok": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已清理" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Cleaned" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已清理" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クリーンアップ済み" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "정리됨" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bereinigt" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Limpiado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nettoyé" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Limpo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Очищено" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تم التنظيف" + } + } + } + }, + "history.status.skipped": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已跳过" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Skipped" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已跳過" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スキップ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "건너뜀" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Übersprungen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Omitido" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ignoré" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Ignorado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Пропущено" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تم التخطي" + } + } + } + }, + "history.status.changed": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "内容已变化" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Content changed" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "內容已變化" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "内容が変更されました" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "내용이 변경됨" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Inhalt geändert" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Contenido cambiado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Contenu modifié" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Conteúdo alterado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Содержимое изменилось" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تغيّر المحتوى" + } + } + } + }, + "history.status.failed": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "失败" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "失敗" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "失敗" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "실패" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Fehlgeschlagen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Error" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Échec" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Falhou" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ошибка" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "فشل" + } + } + } + }, + "history.status.partiallyDeleted": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "只删除了一部分" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Only partly deleted" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "只刪除了一部分" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "一部のみ削除" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "일부만 삭제됨" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nur teilweise gelöscht" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Eliminado solo en parte" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Supprimé en partie seulement" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Excluído apenas em parte" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Удалено лишь частично" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "حُذف جزئياً فقط" + } + } + } + }, + "history.status.rollbackBlocked": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法放回原处" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Couldn’t restore to original location" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法放回原處" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "元の場所に戻せません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "원래 위치로 되돌릴 수 없음" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wiederherstellung am Ursprungspfad blockiert" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo restaurar a la ubicación original" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible de restaurer à l’emplacement d’origine" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Não foi possível restaurar ao local original" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось вернуть на исходное место" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذّر الإرجاع إلى الموقع الأصلي" + } + } + } + }, + "history.status.reconciledRolledBack": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启动时已放回" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Restored at launch" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "啟動時已放回" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "起動時に復元済み" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "실행 시 복원됨" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Beim Start wiederhergestellt" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Restaurado al iniciar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Restauré au lancement" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Restaurado ao iniciar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Восстановлено при запуске" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "أُعيد عند التشغيل" + } + } + } + }, + "history.status.reconciledAbsent": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "上次已完成" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Completed last time" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "上次已完成" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "前回完了済み" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "지난번에 완료됨" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zuletzt abgeschlossen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Completado la última vez" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Terminé la dernière fois" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Concluído da última vez" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Завершено в прошлый раз" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "اكتمل في المرة السابقة" + } + } + } + }, + "history.status.reconcileFailed": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未完成,待处理" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Incomplete — needs attention" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未完成,待處理" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "未完了 — 要対応" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "미완료 — 조치 필요" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Unvollständig — Handlung nötig" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Incompleto: requiere atención" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Incomplet — attention requise" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Incompleto — precisa de atenção" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не завершено — требует внимания" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "غير مكتمل — يحتاج إلى انتباه" + } + } + } + }, + "detail.history.title": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "清理历史" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Cleanup history" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "清理歷史" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クリーンアップ履歴" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "정리 기록" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bereinigungsverlauf" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Historial de limpieza" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Historique de nettoyage" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Histórico de limpeza" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "История очистки" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "سجل التنظيف" + } + } + } + }, + "detail.history.reload": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "刷新" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Refresh" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "刷新" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "更新" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "새로고침" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aktualisieren" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Actualizar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Actualiser" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Atualizar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Обновить" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تحديث" + } + } + } + }, + "detail.history.loading": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在读取记录…" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Loading records…" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在讀取記錄…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "記録を読み込み中…" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기록 불러오는 중…" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Einträge werden geladen…" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cargando registros…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Chargement des enregistrements…" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "A carregar registos…" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Загрузка записей…" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "جارٍ تحميل السجلات…" + } + } + } + }, + "detail.history.empty": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "还没有清理记录" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "No cleanup history yet" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "還沒有清理記錄" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クリーンアップ履歴はまだありません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아직 정리 기록이 없습니다" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Noch kein Bereinigungsverlauf" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Aún no hay historial de limpieza" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucun historique de nettoyage pour l’instant" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Ainda sem histórico de limpeza" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Истории очистки пока нет" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لا يوجد سجل تنظيف بعد" + } + } + } + }, + "detail.history.attention.title": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "有 %d 项清理未完成" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%d cleanup items need attention" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "有 %d 項清理未完成" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%d 件のクリーンアップが未完了です" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "정리 미완료 항목 %d개" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%d Bereinigungen benötigen Aufmerksamkeit" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%d limpiezas requieren atención" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%d nettoyages nécessitent une attention" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "%d limpezas precisam de atenção" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%d очисток требуют внимания" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "%d عناصر تنظيف تحتاج إلى انتباه" + } + } + } + }, + "detail.history.attention.description": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "这些项目在磁盘上留下了以 .mactools-staged- 开头的暂存对象,可在下方记录里查看原路径后自行处理。" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "These items left staged objects on disk whose names start with .mactools-staged-. Check the original path in the records below, then handle them yourself." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "這些項目在磁碟上留下了以 .mactools-staged- 開頭的暫存對象,可在下方記錄裡查看原路徑後自行處理。" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "これらの項目は .mactools-staged- で始まるステージングオブジェクトを残しています。下の記録で元のパスを確認し、ご自身で処理してください。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 항목들은 이름이 .mactools-staged-로 시작하는 스테이징 객체를 남겼습니다. 아래 기록에서 원래 경로를 확인한 뒤 직접 처리하세요." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Diese Elemente haben Staging-Objekte mit Namen beginnend mit .mactools-staged- hinterlassen. Originalpfad unten prüfen und selbst behandeln." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Estos elementos dejaron objetos temporales cuyo nombre empieza por .mactools-staged-. Consulta la ruta original abajo y gestionalos tú." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ces éléments ont laissé des objets de staging dont le nom commence par .mactools-staged-. Vérifiez le chemin d’origine ci-dessous, puis traitez-les vous-même." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Estes itens deixaram objetos de staging com nomes a começar por .mactools-staged-. Veja o caminho original abaixo e trate-os você mesmo." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Эти элементы оставили на диске объекты со именами .mactools-staged-. Проверьте исходный путь в записях ниже и обработайте вручную." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تركت هذه العناصر كائنات مؤقتة تبدأ أسماؤها بـ .mactools-staged-. راجع المسار الأصلي في السجلات أدناه ثم عالجها بنفسك." + } + } + } + }, + "detail.history.stagedName": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "暂存名:%@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Staged name: %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "暫存名:%@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ステージ名:%@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "스테이징 이름: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Staging-Name: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Nombre temporal: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nom de staging : %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Nome de staging: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Имя staging: %@" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "الاسم المؤقت: %@" + } + } + } + }, + "controller.subtitle.expired": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "结果已过期,请重新扫描" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Results expired — rescan" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "結果已過期,請重新掃描" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "結果の有効期限切れ — 再スキャン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "결과가 만료됨 — 다시 스캔" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ergebnisse abgelaufen — erneut scannen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Resultados caducados: vuelve a escanear" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Résultats expirés — rescanner" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Resultados expirados — escaneie de novo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Результаты устарели — пересканируйте" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "انتهت صلاحية النتائج — أعد المسح" + } + } + } + }, + "controller.subtitle.confirming": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "等待确认永久清理" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Waiting to confirm permanent deletion" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "等待確認永久清理" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "完全削除の確認待ち" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "영구 삭제 확인 대기 중" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Warte auf Bestätigung der endgültigen Löschung" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Esperando confirmación de eliminación permanente" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "En attente de confirmation de la suppression définitive" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Aguardando confirmação da exclusão permanente" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ожидание подтверждения безвозвратного удаления" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "بانتظار تأكيد الحذف النهائي" + } + } + } + }, + "controller.error.confirmationExpired": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "确认已超时,请重新发起清理" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Confirmation timed out. Start cleanup again." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "確認已超時,請重新發起清理" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "確認がタイムアウトしました。もう一度クリーンアップを開始してください" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "확인 시간이 초과되었습니다. 정리를 다시 시작하세요" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bestätigung abgelaufen. Cleanup erneut starten." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La confirmación caducó. Inicia la limpieza de nuevo." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Confirmation expirée. Relancez le nettoyage." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "A confirmação expirou. Inicie a limpeza novamente." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Время подтверждения истекло. Запустите очистку снова." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "انتهت مهلة التأكيد. ابدأ التنظيف من جديد." + } + } + } + }, + "detail.confirm.confirm": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "永久删除" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Delete Permanently" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "永久刪除" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "完全に削除" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "영구 삭제" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Endgültig löschen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Eliminar permanentemente" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Supprimer définitivement" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Apagar permanentemente" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Удалить безвозвратно" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "حذف نهائي" + } + } + } + }, + "detail.action.cancelClean": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "取消" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Cancel" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "取消" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キャンセル" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "취소" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Abbrechen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cancelar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Annuler" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Cancelar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отмена" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إلغاء" + } + } + } + }, + "detail.confirm.message": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "永久删除不进废纸篓,无法恢复。" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Permanent deletion skips the Trash and can’t be undone." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "永久刪除不進廢紙簍,無法恢復。" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "完全削除はゴミ箱を経由せず、元に戻せません。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "영구 삭제는 휴지통을 거치지 않으며 되돌릴 수 없습니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Endgültiges Löschen umgeht den Papierkorb und kann nicht rückgängig gemacht werden." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La eliminación permanente no usa la Papelera y no se puede deshacer." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La suppression définitive n’utilise pas la corbeille et est irréversible." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "A exclusão permanente não passa pelo Lixo e não pode ser desfeita." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Безвозвратное удаление минует Корзину и не отменяется." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "الحذف النهائي يتجاوز سلة المهملات ولا يمكن التراجع عنه." + } + } + } + }, + "detail.fda.title": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "完全磁盘访问未开启——部分系统缓存将被跳过" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Full Disk Access is off — some system caches will be skipped" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "完全磁碟存取未開啟——部分系統快取將被跳過" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フルディスクアクセスがオフです — 一部のシステムキャッシュはスキップされます" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "전체 디스크 접근이 꺼져 있음 — 일부 시스템 캐시가 건너뛰어집니다" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vollzugriff auf den Festspeicher ist aus — einige System-Caches werden übersprungen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Acceso total al disco desactivado: se omitirán algunas cachés del sistema" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Accès complet au disque désactivé — certains caches système seront ignorés" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Acesso Total ao Disco desligado — alguns caches do sistema serão ignorados" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Полный доступ к диску выключен — часть системных кэшей будет пропущена" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "الوصول الكامل إلى القرص معطّل — سيتم تخطي بعض ذاكرات التخزين المؤقت للنظام" + } + } + } + }, + "detail.fda.footnote": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "授权后请退出并重新打开 MacTools。" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "After granting access, quit and reopen MacTools." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "授權後請退出並重新打開 MacTools。" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "許可後、MacTools を終了して再度開いてください。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "권한을 허용한 뒤 MacTools를 종료했다가 다시 여세요." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nach der Freigabe MacTools beenden und erneut öffnen." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Tras conceder el acceso, cierra y vuelve a abrir MacTools." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Après autorisation, quittez puis rouvrez MacTools." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Depois de conceder o acesso, saia e reabra o MacTools." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "После выдачи доступа выйдите из MacTools и откройте снова." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "بعد منح الوصول، أغلِق MacTools ثم أعد فتحه." + } + } + } + }, + "detail.fda.openSettings": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "前往授权" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Settings" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "前往授權" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "設定を開く" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "설정 열기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Einstellungen öffnen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Abrir Ajustes" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ouvrir Réglages" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Abrir Ajustes" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Открыть Настройки" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "فتح الإعدادات" + } + } + } + }, + "detail.limited.title": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "本次扫描不完整" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "This scan is incomplete" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "本次掃描不完整" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このスキャンは不完全です" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이번 스캔이 불완전합니다" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Dieser Scan ist unvollständig" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Este escaneo está incompleto" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Cette analyse est incomplète" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Esta varredura está incompleta" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Это сканирование неполное" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "هذا المسح غير مكتمل" + } + } + } + }, + "detail.error.title": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "操作未完成" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Action incomplete" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "操作未完成" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "操作は完了しませんでした" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "작업이 완료되지 않음" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aktion unvollständig" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Acción incompleta" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Action incomplète" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Ação incompleta" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Действие не завершено" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "العملية غير مكتملة" + } + } + } + }, + "detail.scope.title": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "扫描范围" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Scan scope" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "掃描範圍" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スキャン範囲" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "스캔 범위" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Scanbereich" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ámbito de escaneo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Portée de l’analyse" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Âmbito da varredura" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Область сканирования" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "نطاق المسح" + } + } + } + }, + "detail.categories.title": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "清理项目" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Cleanup items" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "清理項目" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クリーンアップ項目" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "정리 항목" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bereinigungselemente" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Elementos de limpieza" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Éléments à nettoyer" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Itens de limpeza" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Элементы очистки" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "عناصر التنظيف" + } + } + } + }, + "detail.categories.idle": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "点击「扫描」查看可清理内容" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Click Scan to see what can be cleaned" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "點擊「掃描」查看可清理內容" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "「スキャン」を押してクリーンアップ可能な項目を表示" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "「스캔」을 눌러 정리 가능한 항목 확인" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "„Scannen“ tippen, um bereinigbare Inhalte zu sehen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Pulsa Escanear para ver lo que se puede limpiar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Cliquez sur Analyser pour voir ce qui peut être nettoyé" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Clique em Escanear para ver o que pode ser limpo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Нажмите «Сканировать», чтобы увидеть, что можно очистить" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "انقر «مسح» لعرض ما يمكن تنظيفه" + } + } + } + }, + "detail.result.title": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "清理结果" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Cleanup results" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "清理結果" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クリーンアップ結果" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "정리 결과" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bereinigungsergebnis" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Resultados de limpieza" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Résultats du nettoyage" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Resultados da limpeza" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Результаты очистки" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "نتائج التنظيف" + } + } + } + }, + "detail.result.stagedName": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "暂存名:%@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Staged name: %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "暫存名:%@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ステージ名:%@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "스테이징 이름: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Staging-Name: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Nombre temporal: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nom de staging : %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Nome de staging: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Имя staging: %@" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "الاسم المؤقت: %@" + } + } + } + }, + "detail.result.trashed": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已移到废纸篓" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Moved to Trash" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已移到廢紙簍" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ゴミ箱に移動済み" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "휴지통으로 이동됨" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "In den Papierkorb verschoben" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Movido a la Papelera" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Mis à la corbeille" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Movido para o Lixo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Перемещено в Корзину" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "نُقل إلى سلة المهملات" + } + } + } + }, + "detail.result.removed": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已删除" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Deleted" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已刪除" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "削除済み" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "삭제됨" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Gelöscht" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Eliminado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Supprimé" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Apagado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Удалено" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تم الحذف" + } + } + } + }, + "detail.result.skipped": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已跳过" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Skipped" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已跳過" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スキップ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "건너뜀" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Übersprungen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Omitido" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ignoré" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Ignorado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Пропущено" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تم التخطي" + } + } + } + }, + "detail.result.failed": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未完成" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Not completed" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未完成" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "未完了" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "완료되지 않음" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nicht abgeschlossen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No completado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Non terminé" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Não concluído" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не завершено" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "غير مكتمل" + } + } + } + }, + "detail.result.trashedBytes": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已移到废纸篓" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Moved to Trash" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已移到廢紙簍" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ゴミ箱に移動済み" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "휴지통으로 이동됨" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "In den Papierkorb verschoben" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Movido a la Papelera" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Mis à la corbeille" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Movido para o Lixo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Перемещено в Корзину" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "نُقل إلى سلة المهملات" + } + } + } + }, + "detail.result.reclaimedBytes": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已释放" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Freed" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已釋放" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "解放済み" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "확보됨" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Freigegeben" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Liberado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Libéré" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Libertado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Освобождено" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تم التحرير" + } + } + } + }, + "detail.settings.title": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "清理设置" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Cleanup settings" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "清理設置" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クリーンアップ設定" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "정리 설정" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bereinigungseinstellungen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ajustes de limpieza" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Réglages de nettoyage" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Ajustes de limpeza" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Настройки очистки" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إعدادات التنظيف" + } + } + } + }, + "detail.removalMode.title": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "删除方式" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Deletion method" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "刪除方式" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "削除方法" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "삭제 방식" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Löschmethode" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Método de eliminación" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Méthode de suppression" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Método de exclusão" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Способ удаления" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "طريقة الحذف" + } + } + } + }, + "detail.removalMode.trash": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "废纸篓" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Trash" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "廢紙簍" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ゴミ箱" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "휴지통" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Papierkorb" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Papelera" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Corbeille" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Lixo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Корзина" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "سلة المهملات" + } + } + } + }, + "detail.removalMode.permanent": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "永久删除" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Permanent delete" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "永久刪除" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "完全削除" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "영구 삭제" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Endgültig löschen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Eliminación permanente" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Suppression définitive" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Exclusão permanente" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Безвозвратное удаление" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "حذف نهائي" + } + } + } + }, + "detail.removalMode.trash.description": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "移到废纸篓,清空废纸篓后才真正释放空间。放回时会落在暂存名下。" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Moves items to the Trash. Space is freed only after you empty it. Putting back uses the staged name." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "移到廢紙簍,清空廢紙簍後才真正釋放空間。放回時會落在暫存名下。" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ゴミ箱に移します。空にして初めて容量が解放されます。戻すとステージ名のままになります。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "휴지통으로 이동합니다. 비운 뒤에야 공간이 확보됩니다. 복원 시 스테이징 이름으로 남습니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verschiebt in den Papierkorb. Speicher wird erst nach dem Leeren freigegeben. Wiederherstellen nutzt den Staging-Namen." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mueve a la Papelera. El espacio se libera al vaciarla. Al restaurar se usa el nombre temporal." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Déplace vers la corbeille. L’espace n’est libéré qu’après l’avoir vidée. La restauration utilise le nom de staging." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Move para o Lixo. O espaço só é libertado ao esvaziá-lo. Ao restaurar fica com o nome de staging." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Перемещает в Корзину. Место освобождается только после очистки. При возврате используется staging-имя." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "ينقل إلى سلة المهملات. لا تُحرَّر المساحة إلا بعد تفريغها. عند الإرجاع يُستخدم الاسم المؤقت." + } + } + } + }, + "detail.removalMode.permanent.description": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "直接删除,不进废纸篓,无法恢复。执行前需要再确认一次。" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Deletes immediately without the Trash. Requires a second confirmation." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "直接刪除,不進廢紙簍,無法恢復。執行前需要再確認一次。" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ゴミ箱を経由せず即時削除します。実行前にもう一度確認が必要です。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "휴지통 없이 바로 삭제합니다. 실행 전 한 번 더 확인이 필요합니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Löscht sofort ohne Papierkorb. Erfordert eine zweite Bestätigung." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Elimina de inmediato sin la Papelera. Requiere una segunda confirmación." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Supprime immédiatement sans la corbeille. Une seconde confirmation est requise." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Apaga de imediato sem o Lixo. Exige uma segunda confirmação." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Удаляет сразу, минуя Корзину. Требуется повторное подтверждение." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "يحذف فورًا دون سلة المهملات. يتطلب تأكيدًا ثانيًا." + } + } + } + }, + "detail.candidate.size": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "约 %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "About %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "約 %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "約 %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "약 %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ca. %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Aprox. %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Env. %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Cerca de %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ок. %@" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "حوالي %@" + } + } + } + }, + "detail.action.trash": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "移到废纸篓" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Move to Trash" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "移到廢紙簍" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ゴミ箱に入れる" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "휴지통으로 이동" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "In den Papierkorb" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mover a la Papelera" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Mettre à la corbeille" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Mover para o Lixo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Переместить в Корзину" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "نقل إلى سلة المهملات" + } + } + } + }, + "detail.action.trashSelected": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "移到废纸篓 · %d 项 · 约 %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Move to Trash · %d items · about %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "移到廢紙簍 · %d 項 · 約 %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ゴミ箱へ · %d 項目 · 約 %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "휴지통으로 · %d개 · 약 %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "In den Papierkorb · %d Elemente · ca. %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mover a la Papelera · %d elementos · aprox. %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Mettre à la corbeille · %d éléments · env. %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Mover para o Lixo · %d itens · cerca de %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "В Корзину · %d элем. · ок. %@" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إلى سلة المهملات · %d عنصر · حوالي %@" + } + } + } + }, + "detail.action.cleanSelected": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "清理 · %d 项 · 约 %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Clean · %d items · about %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "清理 · %d 項 · 約 %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クリーンアップ · %d 項目 · 約 %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "정리 · %d개 · 약 %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bereinigen · %d Elemente · ca. %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Limpiar · %d elementos · aprox. %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nettoyer · %d éléments · env. %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Limpar · %d itens · cerca de %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Очистить · %d элем. · ок. %@" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تنظيف · %d عنصر · حوالي %@" + } + } + } + }, + "detail.selection.idle": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "尚未扫描" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Not scanned yet" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "尚未掃描" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "まだスキャンしていません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아직 스캔하지 않음" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Noch nicht gescannt" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Aún no escaneado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Pas encore analysé" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Ainda não escaneado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ещё не сканировалось" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لم يُمسح بعد" + } + } + } + }, + "detail.selection.expired": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "结果已过期,请重新扫描" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Results expired — rescan" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "結果已過期,請重新掃描" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "結果の有効期限切れ — 再スキャン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "결과가 만료됨 — 다시 스캔" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ergebnisse abgelaufen — erneut scannen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Resultados caducados: vuelve a escanear" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Résultats expirés — rescanner" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Resultados expirados — escaneie de novo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Результаты устарели — пересканируйте" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "انتهت صلاحية النتائج — أعد المسح" + } + } + } + }, + "detail.selection.stale": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "清理范围已变化,请重新扫描" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Cleanup scope changed — rescan" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "清理範圍已變化,請重新掃描" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クリーンアップ範囲が変わりました — 再スキャン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "정리 범위가 변경됨 — 다시 스캔" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bereinigungsbereich geändert — erneut scannen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "El alcance de limpieza cambió: vuelve a escanear" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Portée du nettoyage modifiée — rescanner" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Âmbito da limpeza mudou — escaneie de novo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Область очистки изменилась — пересканируйте" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تغيّر نطاق التنظيف — أعد المسح" + } + } + } + }, + "detail.selection.summary": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "可清理 %d 项 · 已选 %d 项 · 约 %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%d cleanable · %d selected · about %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "可清理 %d 項 · 已選 %d 項 · 約 %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クリーン可 %d · 選択 %d · 約 %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "정리 가능 %d개 · 선택 %d개 · 약 %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%d bereinigbar · %d ausgewählt · ca. %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%d limpiables · %d seleccionados · aprox. %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%d nettoyables · %d sélectionnés · env. %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "%d limpáveis · %d selecionados · cerca de %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Можно очистить %d · выбрано %d · ок. %@" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "قابل للتنظيف %d · محدد %d · حوالي %@" + } + } + } + }, + "detail.confirm.fallbackTitle": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "确认永久清理" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Confirm permanent cleanup" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "確認永久清理" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "完全クリーンアップの確認" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "영구 정리 확인" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Endgültige Bereinigung bestätigen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Confirmar limpieza permanente" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Confirmer le nettoyage définitif" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Confirmar limpeza permanente" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Подтвердить безвозвратную очистку" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تأكيد التنظيف النهائي" + } + } + } + }, + "detail.confirm.title": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "确认永久清理 %d 项 · 约 %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Permanently delete %d items · about %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "確認永久清理 %d 項 · 約 %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%d 項目を完全削除 · 約 %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%d개 영구 삭제 · 약 %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%d Elemente endgültig löschen · ca. %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Eliminar permanentemente %d elementos · aprox. %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Supprimer définitivement %d éléments · env. %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Apagar permanentemente %d itens · cerca de %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Безвозвратно удалить %d элем. · ок. %@" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "حذف %d عنصر نهائياً · حوالي %@" + } + } + } + }, + "partial.timedOut": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "超时" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Timed out" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "超時" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タイムアウト" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "시간 초과" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zeitüberschreitung" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Tiempo de espera agotado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Délai dépassé" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Tempo esgotado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Тайм-аут" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "انتهت المهلة" + } + } + } + }, + "partial.permissionDenied": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "权限不足" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Permission denied" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "權限不足" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "権限がありません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "권한 부족" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine Berechtigung" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Permiso denegado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Permission refusée" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Permissão negada" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Нет доступа" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تم رفض الإذن" + } + } + } + }, + "partial.unsupportedVolume": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "不支持的卷" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Unsupported volume" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "不支持的卷" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "未対応のボリューム" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "지원되지 않는 볼륨" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nicht unterstütztes Volume" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Volumen no compatible" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Volume non pris en charge" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Volume sem suporte" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Неподдерживаемый том" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "مجلد غير مدعوم" + } + } + } + }, + "partial.crossedMountPoint": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "含挂载点" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Contains mount point" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "含掛載點" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "マウントポイントを含む" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "마운트 지점 포함" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Enthält Mount-Punkt" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Contiene punto de montaje" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Contient un point de montage" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Contém ponto de montagem" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Содержит точку монтирования" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "يحتوي على نقطة تركيب" + } + } + } + }, + "partial.walkError": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "读取异常" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Read error" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "讀取異常" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "読み取りエラー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "읽기 오류" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Lesefehler" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Error de lectura" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Erreur de lecture" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Erro de leitura" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ошибка чтения" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "خطأ في القراءة" + } + } + } + }, + "limitation.fdaRestricted": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未开启完全磁盘访问,已跳过 %d 项系统缓存" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Full Disk Access is off — skipped %d system cache targets" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未開啟完全磁碟存取,已跳過 %d 項系統快取" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フルディスクアクセスがオフのため、%d 件のシステムキャッシュをスキップ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "전체 디스크 접근이 꺼져 %d개 시스템 캐시 대상을 건너뜀" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vollzugriff aus — %d System-Cache-Ziele übersprungen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Acceso total al disco desactivado: se omitieron %d cachés del sistema" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Accès complet au disque désactivé — %d cibles de cache système ignorées" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Acesso Total ao Disco desligado — %d caches do sistema ignorados" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Полный доступ к диску выключен — пропущено %d системных кэшей" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "الوصول الكامل إلى القرص معطّل — تم تخطي %d من ذاكرة التخزين المؤقت للنظام" + } + } + } + }, + "limitation.dynamicRuleFailed": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "动态规则 %@ 未能运行:%@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Dynamic rule %@ couldn’t run: %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "動態規則 %@ 未能運行:%@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "動的ルール %@ を実行できません:%@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "동적 규칙 %@를 실행할 수 없음: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Dynamische Regel %@ konnte nicht laufen: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La regla dinámica %@ no pudo ejecutarse: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La règle dynamique %@ n’a pas pu s’exécuter : %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "A regra dinâmica %@ não pôde ser executada: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Динамическое правило %@ не выполнилось: %@" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذّر تشغيل القاعدة الديناميكية %@: %@" + } + } + } + }, + "limitation.targetExpansionFailed": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "规则 %@ 展开失败:%@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Rule %@ failed to expand: %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "規則 %@ 展開失敗:%@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ルール %@ の展開に失敗:%@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "규칙 %@ 확장 실패: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Regel %@ konnte nicht erweitert werden: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La regla %@ no se pudo expandir: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Échec de l’expansion de la règle %@ : %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Falha ao expandir a regra %@: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось развернуть правило %@: %@" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "فشل توسيع القاعدة %@: %@" + } + } + } + }, + "limitation.volumeSkipped": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已跳过非本地卷:%@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Skipped non-local volume: %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已跳過非本地卷:%@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "非ローカルボリュームをスキップ:%@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "로컬이 아닌 볼륨 건너뜀: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nicht lokales Volume übersprungen: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Volumen no local omitido: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Volume non local ignoré : %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Volume não local ignorado: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Пропущен нелокальный том: %@" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تم تخطي مجلد غير محلي: %@" + } + } + } + }, + "limitation.scanRootUnreadable": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法读取 %@(%@)" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Couldn’t read %@ (%@)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法讀取 %@(%@)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ を読み取れません(%@)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@를 읽을 수 없음(%@)" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ konnte nicht gelesen werden (%@)" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo leer %@ (%@)" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible de lire %@ (%@)" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Não foi possível ler %@ (%@)" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось прочитать %@ (%@)" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذّرت قراءة %@ (%@)" + } + } + } + }, + "limitation.scanRootDenied": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "没有访问 %@ 的权限,其中的内容未被扫描" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "No permission to access %@; its contents were not scanned" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "沒有訪問 %@ 的權限,其中的內容未被掃描" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ にアクセスする権限がなく、内容はスキャンされませんでした" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@에 대한 권한이 없어 내용이 스캔되지 않았습니다" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kein Zugriff auf %@; Inhalt wurde nicht gescannt" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Sin permiso para acceder a %@; no se escaneó su contenido" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Pas d’autorisation pour accéder à %@ ; son contenu n’a pas été analysé" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Sem permissão para aceder a %@; o conteúdo não foi analisado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Нет доступа к %@; содержимое не сканировалось" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لا إذن للوصول إلى %@؛ لم يُمسح محتواه" + } + } + } + }, + "limitation.walkerCircuitBroken": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "扫描引擎已降级,本次不再计算大小,重启应用后恢复" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Scan engine degraded; sizes won’t be calculated this run. Restart the app to recover." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "掃描引擎已降級,本次不再計算大小,重啟應用後恢復" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スキャンエンジンが縮退動作中です。今回はサイズ計算を行いません。アプリを再起動すると復旧します。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "스캔 엔진이 저하되었습니다. 이번 실행에서는 크기를 계산하지 않습니다. 앱을 재시작하면 복구됩니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Scan-Engine im Notbetrieb; Größen werden diesmal nicht berechnet. App neu starten zum Wiederherstellen." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Motor de escaneo degradado; no se calcularán tamaños en esta ejecución. Reinicia la app para recuperarlo." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Moteur d’analyse dégradé ; les tailles ne seront pas calculées cette fois. Redémarrez l’app pour rétablir." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Motor de varredura degradado; tamanhos não serão calculados desta vez. Reinicie a app para recuperar." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Движок сканирования в урезанном режиме; размеры в этот раз не считаются. Перезапустите приложение." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "محرك المسح متراجع؛ لن تُحسب الأحجام هذه المرة. أعد تشغيل التطبيق للاستعادة." + } + } + } + }, + "limitation.threadsAbandoned": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "有 %d 个扫描线程未能按时返回" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%d scan threads didn’t return in time" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "有 %d 個掃描線程未能按時返回" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%d 個のスキャンスレッドが時間内に戻りませんでした" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "스캔 스레드 %d개가 제때 반환되지 않음" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%d Scan-Threads kamen nicht rechtzeitig zurück" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%d hilos de escaneo no respondieron a tiempo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%d fils d’analyse n’ont pas répondu à temps" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "%d threads de varredura não responderam a tempo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%d потоков сканирования не вернулись вовремя" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "%d من خيوط المسح لم تُرجع في الوقت" + } + } + } + }, + "detail.result.partiallyDeleted": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "只删除了一部分" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Only partly deleted" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "只刪除了一部分" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "一部のみ削除" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "일부만 삭제됨" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nur teilweise gelöscht" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Eliminado solo en parte" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Supprimé en partie seulement" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Excluído apenas em parte" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Удалено лишь частично" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "حُذف جزئياً فقط" + } + } + } + }, + "detail.result.rollbackBlocked": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法放回原处" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Couldn’t restore to original location" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法放回原處" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "元の場所に戻せません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "원래 위치로 되돌릴 수 없음" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wiederherstellung am Ursprungspfad blockiert" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo restaurar a la ubicación original" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible de restaurer à l’emplacement d’origine" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Não foi possível restaurar ao local original" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось вернуть на исходное место" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذّر الإرجاع إلى الموقع الأصلي" + } + } + } + }, + "detail.result.attention": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "需要处理" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Needs attention" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "需要處理" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "要対応" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "조치 필요" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Handlung nötig" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Requiere atención" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Attention requise" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Precisa de atenção" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Требует внимания" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "يحتاج إلى انتباه" + } + } + } + }, + "detail.result.partiallyDeleted.guidance": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "删除中途失败,暂存对象里只剩一部分内容,不会被放回原路径。可在访达中按暂存名找到并自行删除。" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Deletion failed mid-way. The staged object keeps only part of the content and won’t be restored to the original path. Find it in Finder by the staged name and remove it yourself." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "刪除中途失敗,暫存對象裡只剩一部分內容,不會被放回原路徑。可在訪達中按暫存名找到並自行刪除。" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "削除が途中で失敗しました。ステージングオブジェクトには一部だけ残り、元のパスには戻りません。Finder でステージ名を探し、手動で削除してください。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "삭제가 중간에 실패했습니다. 스테이징 객체에는 일부만 남아 원래 경로로 복원되지 않습니다. Finder에서 스테이징 이름으로 찾아 직접 삭제하세요." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Löschen mittendrin fehlgeschlagen. Das Staging-Objekt enthält nur noch Teile und wird nicht zurückgelegt. Im Finder per Staging-Namen finden und selbst löschen." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La eliminación falló a medias. El objeto temporal solo conserva parte del contenido y no se restaurará. Búscalo en el Finder por el nombre temporal y elimínalo tú." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La suppression a échoué en cours de route. L’objet de staging ne contient plus qu’une partie et ne sera pas restauré. Retrouvez-le dans le Finder via le nom de staging et supprimez-le." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "A exclusão falhou a meio. O objeto de staging ficou só com parte do conteúdo e não será restaurado. Encontre-o no Finder pelo nome de staging e apague-o." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Удаление прервалось. В staging-объекте осталась лишь часть содержимого; на исходный путь он не вернётся. Найдите его в Finder по staging-имени и удалите вручную." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "فشل الحذف في منتصف الطريق. يحتفظ الكائن المؤقت بجزء فقط ولن يُعاد إلى المسار الأصلي. اعثر عليه في Finder بالاسم المؤقت واحذفه بنفسك." + } + } + } + }, + "detail.result.rollbackBlocked.guidance": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "原路径已被重新创建,暂存对象未覆盖它。可在访达中按暂存名找到并自行处理。" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "The original path was recreated, so the staged object wasn’t put back over it. Find it in Finder by the staged name and handle it yourself." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "原路徑已被重新創建,暫存對象未覆蓋它。可在訪達中按暫存名找到並自行處理。" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "元のパスが再作成されたため、ステージングオブジェクトは上書き復元されませんでした。Finder でステージ名を探し、手動で処理してください。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "원래 경로가 다시 만들어져 스테이징 객체가 덮어쓰지 않았습니다. Finder에서 스테이징 이름으로 찾아 직접 처리하세요." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Der Originalpfad wurde neu angelegt, daher wurde das Staging-Objekt nicht darüber zurückgelegt. Im Finder per Staging-Namen finden und selbst behandeln." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Se recreó la ruta original, así que el objeto temporal no la sobrescribió. Búscalo en el Finder por el nombre temporal y gestionalo tú." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le chemin d’origine a été recréé ; l’objet de staging n’a donc pas été remis par-dessus. Retrouvez-le dans le Finder via le nom de staging et traitez-le." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "O caminho original foi recriado, por isso o objeto de staging não o substituiu. Encontre-o no Finder pelo nome de staging e trate-o." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Исходный путь был создан заново, поэтому staging-объект не вернули поверх него. Найдите его в Finder по staging-имени и обработайте вручную." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "أُعيد إنشاء المسار الأصلي فلم يُرجَع الكائن المؤقت فوقه. اعثر عليه في Finder بالاسم المؤقت وعالجه بنفسك." + } + } + } + }, + "scanLog.purge.partialRoot": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ 有子目录未能读取(%@),可能漏报部分产物" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ had subfolders that couldn’t be read (%@); some artifacts may be missing" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@ 有子目錄未能讀取(%@),可能漏報部分產物" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ に読み取れないサブフォルダがあります(%@)。一部の成果物が漏れる可能性があります" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@에 읽을 수 없는 하위 폴더가 있습니다(%@). 일부 산출물이 누락될 수 있습니다" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ hatte unlesbare Unterordner (%@); einige Artefakte fehlen möglicherweise" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%@ tenía subcarpetas ilegibles (%@); pueden faltar algunos artefactos" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%@ contenait des sous-dossiers illisibles (%@) ; certains artefacts peuvent manquer" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "%@ tinha subpastas ilegíveis (%@); alguns artefactos podem faltar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "В %@ были нечитаемые подпапки (%@); часть артефактов могла не попасть в отчёт" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "كان لدى %@ مجلدات فرعية غير قابلة للقراءة (%@)؛ قد تُفوت بعض المخرجات" + } + } + } + }, + "scanLog.missingTarget": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "规则目录缺少 target %@,已跳过对应候选" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Rule catalog is missing target %@; related candidates were skipped" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "規則目錄缺少 target %@,已跳過對應候選" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ルールカタログに target %@ がありません。関連候補をスキップしました" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "규칙 카탈로그에 target %@가 없어 관련 후보를 건너뜀" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Regelkatalog fehlt target %@; zugehörige Kandidaten übersprungen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "El catálogo de reglas no tiene el target %@; se omitieron candidatos relacionados" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le catalogue de règles n’a pas le target %@ ; candidats associés ignorés" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "O catálogo de regras não tem o target %@; candidatos relacionados ignorados" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "В каталоге правил нет target %@; связанные кандидаты пропущены" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "كتالوج القواعد يفتقد target %@؛ تم تخطي المرشحين المرتبطين" + } + } + } + }, + "panel.action.trash": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "移到废纸篓" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Move to Trash" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "移到廢紙簍" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ゴミ箱に入れる" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "휴지통으로 이동" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "In den Papierkorb" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mover a la Papelera" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Mettre à la corbeille" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Mover para o Lixo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Переместить в Корзину" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "نقل إلى سلة المهملات" + } + } + } + }, + "panel.action.trashSelected": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "移到废纸篓 · %d 项 · 约 %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Move to Trash · %d items · about %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "移到廢紙簍 · %d 項 · 約 %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ゴミ箱へ · %d 項目 · 約 %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "휴지통으로 · %d개 · 약 %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "In den Papierkorb · %d Elemente · ca. %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mover a la Papelera · %d elementos · aprox. %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Mettre à la corbeille · %d éléments · env. %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Mover para o Lixo · %d itens · cerca de %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "В Корзину · %d элем. · ок. %@" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إلى سلة المهملات · %d عنصر · حوالي %@" + } + } + } + }, + "panel.action.cleanSelected": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "清理 · %d 项 · 约 %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Clean · %d items · about %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "清理 · %d 項 · 約 %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クリーンアップ · %d 項目 · 約 %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "정리 · %d개 · 약 %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bereinigen · %d Elemente · ca. %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Limpiar · %d elementos · aprox. %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nettoyer · %d éléments · env. %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Limpar · %d itens · cerca de %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Очистить · %d элем. · ок. %@" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تنظيف · %d عنصر · حوالي %@" + } + } + } + }, + "panel.action.confirmClean": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "确认永久清理 %d 项 · 约 %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Permanently delete %d items · about %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "確認永久清理 %d 項 · 約 %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%d 項目を完全削除 · 約 %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%d개 영구 삭제 · 약 %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%d Elemente endgültig löschen · ca. %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Eliminar permanentemente %d elementos · aprox. %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Supprimer définitivement %d éléments · env. %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Apagar permanentemente %d itens · cerca de %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Безвозвратно удалить %d элем. · ок. %@" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "حذف %d عنصر نهائياً · حوالي %@" + } + } + } + }, + "panel.action.confirmCleanFallback": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "确认永久清理" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Confirm permanent cleanup" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "確認永久清理" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "完全クリーンアップの確認" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "영구 정리 확인" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Endgültige Bereinigung bestätigen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Confirmar limpieza permanente" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Confirmer le nettoyage définitif" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Confirmar limpeza permanente" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Подтвердить безвозвратную очистку" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تأكيد التنظيف النهائي" + } + } + } + }, + "panel.action.cancelClean": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "取消" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Cancel" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "取消" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キャンセル" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "취소" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Abbrechen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cancelar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Annuler" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Cancelar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отмена" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إلغاء" + } + } + } + }, + "panel.subtitle.scanning": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在扫描 · %d 项,约 %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Scanning · %d items, about %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在掃描 · %d 項,約 %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スキャン中 · %d 項目、約 %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "스캔 중 · %d개, 약 %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Scan läuft · %d Elemente, ca. %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Escaneando · %d elementos, aprox. %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Analyse… · %d éléments, env. %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "A escanear · %d itens, cerca de %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сканирование · %d элем., ок. %@" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "جارٍ المسح · %d عنصر، حوالي %@" + } + } + } + }, + "panel.subtitle.limitedSuffix": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "(受限)" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "(limited)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "(受限)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "(制限あり)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "(제한됨)" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "(eingeschränkt)" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "(limitado)" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "(limité)" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "(limitado)" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "(ограничено)" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "(محدود)" + } + } + } + }, + "panel.subtitle.confirming": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "确认永久清理 %d 项 · 约 %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Confirm permanent cleanup of %d items · about %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "確認永久清理 %d 項 · 約 %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%d 項目の完全削除を確認 · 約 %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%d개 영구 정리 확인 · 약 %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Endgültige Bereinigung von %d Elementen bestätigen · ca. %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Confirmar limpieza permanente de %d elementos · aprox. %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Confirmer le nettoyage définitif de %d éléments · env. %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Confirmar limpeza permanente de %d itens · cerca de %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Подтвердить безвозвратную очистку %d элем. · ок. %@" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تأكيد التنظيف النهائي لـ %d عنصر · حوالي %@" + } + } + } + }, + "detail.purgeRoots.title": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "扫描文件夹" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Folders to scan" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "掃描文件夾" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スキャンするフォルダ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "스캔할 폴더" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zu scannende Ordner" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Carpetas a escanear" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Dossiers à analyser" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Pastas a escanear" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Папки для сканирования" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "المجلدات للمسح" + } + } + } + }, + "detail.purgeRoots.description": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "只扫描这些文件夹,最多向下 6 层。请添加具体工程目录,不要选择个人文件夹或系统目录。" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Only these folders are scanned, up to 6 levels deep. Add specific project directories—not your home folder or system locations." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "只掃描這些資料夾,最多向下 6 層。請新增具體專案目錄,不要選擇個人資料夾或系統目錄。" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "これらのフォルダのみを最大 6 階層までスキャンします。ホームやシステムではなく、具体的なプロジェクトディレクトリを追加してください。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 폴더만 최대 6단계 깊이까지 스캔합니다. 홈이나 시스템 위치가 아닌 구체적인 프로젝트 디렉터리를 추가하세요." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nur diese Ordner werden gescannt, maximal 6 Ebenen tief. Füge konkrete Projektordner hinzu – nicht deinen persönlichen Ordner oder Systemorte." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Solo se escanean estas carpetas, hasta 6 niveles. Añade directorios de proyecto concretos, no tu carpeta de inicio ni ubicaciones del sistema." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Seuls ces dossiers sont analysés, jusqu’à 6 niveaux. Ajoutez des répertoires de projet précis, pas votre dossier personnel ni des emplacements système." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Só estas pastas são analisadas, até 6 níveis. Adicione diretórios de projeto específicos — não a pasta pessoal nem locais do sistema." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сканируются только эти папки, максимум 6 уровней. Добавляйте конкретные каталоги проектов, а не домашнюю папку или системные пути." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "يُمسح فقط هذه المجلدات حتى عمق 6 مستويات. أضف مجلدات مشاريع محددة، وليس مجلد المنزل أو مواقع النظام." + } + } + } + }, + "detail.purgeRoots.add": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "添加文件夹" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Add Folder" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "添加文件夾" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フォルダを追加" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "폴더 추가" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ordner hinzufügen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Añadir carpeta" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ajouter un dossier" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Adicionar pasta" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Добавить папку" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إضافة مجلد" + } + } + } + }, + "detail.purgeRoots.remove": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "移除此文件夹" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove this folder" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "移除此文件夾" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このフォルダを削除" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 폴더 제거" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Diesen Ordner entfernen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Quitar esta carpeta" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Retirer ce dossier" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Remover esta pasta" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Удалить эту папку" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إزالة هذا المجلد" + } + } + } + }, + "detail.purgeRoots.dismiss": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "知道了" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Got It" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "知道了" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "了解" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "확인" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verstanden" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Entendido" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Compris" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Entendi" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Понятно" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "حسنًا" + } + } + } + }, + "detail.purgeRoots.rejected.unresolvable": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ 无法解析,可能已被移除或没有访问权限。" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ couldn’t be resolved. It may have been removed or you don’t have access." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@ 無法解析,可能已被移除或沒有訪問權限。" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ を解決できません。削除されたか、アクセス権がない可能性があります。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@를 확인할 수 없습니다. 제거되었거나 접근 권한이 없을 수 있습니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ konnte nicht aufgelöst werden. Möglicherweise entfernt oder kein Zugriff." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo resolver %@. Puede haberse eliminado o no tienes acceso." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible de résoudre %@. Il a peut‑être été supprimé ou l’accès est refusé." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Não foi possível resolver %@. Pode ter sido removido ou sem permissão." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось разрешить %@. Возможно, удалён или нет доступа." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذّر حل %@. ربما أُزيل أو ليس لديك إذن." + } + } + } + }, + "detail.purgeRoots.rejected.duplicate": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ 已经在列表里了。" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ is already in the list." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@ 已經在列表裡了。" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ はすでにリストにあります。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@는 이미 목록에 있습니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ ist bereits in der Liste." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%@ ya está en la lista." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%@ est déjà dans la liste." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "%@ já está na lista." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%@ уже есть в списке." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "%@ موجود بالفعل في القائمة." + } + } + } + }, + "detail.purgeRoots.rejected.covered": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ 已包含在 %@ 之内,无需重复添加。" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ is already covered by %@. No need to add it again." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@ 已包含在 %@ 之內,無需重複添加。" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ はすでに %@ に含まれています。追加する必要はありません。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@는 이미 %@에 포함되어 있습니다. 다시 추가할 필요 없습니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ ist bereits in %@ enthalten. Kein erneutes Hinzufügen nötig." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%@ ya está cubierto por %@. No hace falta añadirlo de nuevo." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%@ est déjà couvert par %@. Pas besoin de l’ajouter à nouveau." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "%@ já está coberto por %@. Não é preciso adicionar de novo." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%@ уже входит в %@. Повторно добавлять не нужно." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "%@ مُضمَّن بالفعل ضمن %@. لا حاجة لإضافته مجددًا." + } + } + } + }, + "detail.purgeRoots.choose": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "选择" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "選擇" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "選択" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "선택" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Auswählen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Elegir" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Choisir" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Escolher" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выбрать" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "اختيار" + } + } + } + }, + "detail.purgeRoots.chooseMessage": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "选择具体的工程文件夹(不要选个人文件夹或系统目录)" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose a specific project folder (not your home folder or a system location)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "選擇具體的專案資料夾(不要選個人資料夾或系統目錄)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "具体的なプロジェクトフォルダを選択してください(ホームやシステム場所は不可)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "구체적인 프로젝트 폴더를 선택하세요(홈이나 시스템 위치는 안 됨)" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wähle einen konkreten Projektordner (nicht persönlichen Ordner oder Systemorte)" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Elige una carpeta de proyecto concreta (no tu inicio ni una ubicación del sistema)" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Choisissez un dossier de projet précis (pas votre dossier personnel ni un emplacement système)" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Escolha uma pasta de projeto específica (não a pasta pessoal nem um local do sistema)" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выберите конкретную папку проекта (не домашнюю и не системную)" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "اختر مجلد مشروع محددًا (وليس مجلد المنزل أو موقعًا نظاميًا)" + } + } + } + }, + "scanLog.expandTarget": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "展开 %@:匹配 %d 项" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Expand %@: %d matches" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "展開 %@:匹配 %d 項" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ を展開:%d 件一致" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@ 확장: %d개 일치" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ erweitern: %d Treffer" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Expandir %@: %d coincidencias" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Expansion de %@ : %d correspondances" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Expandir %@: %d correspondências" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Развернуть %@: %d совпадений" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "توسيع %@: %d تطابقات" + } + } + } + }, + "scanLog.targetFailed": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "展开失败:%@(%@)" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Expand failed: %@ (%@)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "展開失敗:%@(%@)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "展開失敗:%@(%@)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "확장 실패: %@(%@)" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Erweitern fehlgeschlagen: %@ (%@)" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Error al expandir: %@ (%@)" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Échec de l’expansion : %@ (%@)" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Falha ao expandir: %@ (%@)" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сбой развёртывания: %@ (%@)" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "فشل التوسيع: %@ (%@)" + } + } + } + }, + "scanLog.limitation.fda": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未开启完全磁盘访问,已跳过 %d 项规则" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Full Disk Access is off — skipped %d rules" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未開啟完全磁碟存取,已跳過 %d 項規則" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フルディスクアクセスがオフのため、%d 件のルールをスキップ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "전체 디스크 접근이 꺼져 규칙 %d개를 건너뜀" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vollzugriff aus — %d Regeln übersprungen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Acceso total al disco desactivado: se omitieron %d reglas" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Accès complet au disque désactivé — %d règles ignorées" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Acesso Total ao Disco desligado — %d regras ignoradas" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Полный доступ к диску выключен — пропущено %d правил" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "الوصول الكامل إلى القرص معطّل — تم تخطي %d قاعدة" + } + } + } + }, + "scanLog.limitation.dynamicFailed": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "动态规则失败:%@(%@)" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Dynamic rule failed: %@ (%@)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "動態規則失敗:%@(%@)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "動的ルール失敗:%@(%@)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "동적 규칙 실패: %@(%@)" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Dynamische Regel fehlgeschlagen: %@ (%@)" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Regla dinámica fallida: %@ (%@)" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Échec de la règle dynamique : %@ (%@)" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Regra dinâmica falhou: %@ (%@)" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Динамическое правило сбой: %@ (%@)" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "فشل القاعدة الديناميكية: %@ (%@)" + } + } + } + }, + "scanLog.limitation.expansionFailed": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "规则展开失败:%@(%@)" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Rule expansion failed: %@ (%@)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "規則展開失敗:%@(%@)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ルール展開失敗:%@(%@)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "규칙 확장 실패: %@(%@)" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Regelerweiterung fehlgeschlagen: %@ (%@)" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Expansión de regla fallida: %@ (%@)" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Échec de l’expansion de règle : %@ (%@)" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Falha na expansão da regra: %@ (%@)" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сбой развёртывания правила: %@ (%@)" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "فشل توسيع القاعدة: %@ (%@)" + } + } + } + }, + "scanLog.limitation.volumeSkipped": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已跳过不支持的卷:%@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Skipped unsupported volume: %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已跳過不支持的卷:%@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "未対応ボリュームをスキップ:%@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "지원되지 않는 볼륨 건너뜀: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nicht unterstütztes Volume übersprungen: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Volumen no compatible omitido: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Volume non pris en charge ignoré : %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Volume sem suporte ignorado: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Пропущен неподдерживаемый том: %@" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تم تخطي مجلد غير مدعوم: %@" + } + } + } + }, + "scanLog.limitation.scanRootUnreadable": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法读取 %@(%@)" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Couldn’t read %@ (%@)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法讀取 %@(%@)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ を読み取れません(%@)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@를 읽을 수 없음(%@)" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ konnte nicht gelesen werden (%@)" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo leer %@ (%@)" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible de lire %@ (%@)" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Não foi possível ler %@ (%@)" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось прочитать %@ (%@)" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذّرت قراءة %@ (%@)" + } + } + } + }, + "scanLog.limitation.circuitBroken": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "扫描引擎已降级,重启应用恢复" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Scan engine degraded — restart the app to recover" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "掃描引擎已降級,重啟應用恢復" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スキャンエンジンが縮退 — アプリを再起動して復旧" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "스캔 엔진 저하 — 앱을 재시작해 복구" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Scan-Engine im Notbetrieb — App neu starten" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Motor de escaneo degradado: reinicia la app" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Moteur d’analyse dégradé — redémarrez l’app" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Motor de varredura degradado — reinicie a app" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Движок сканирования в урезанном режиме — перезапустите приложение" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "محرك المسح متراجع — أعد تشغيل التطبيق للاستعادة" + } + } + } + }, + "scanLog.limitation.threadsAbandoned": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%d 个扫描线程无响应已被放弃" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%d scan threads unresponsive and abandoned" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%d 個掃描線程無響應已被放棄" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "応答しないスキャンスレッド %d 個を放棄" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "응답 없는 스캔 스레드 %d개 포기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%d nicht reagierende Scan-Threads verworfen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%d hilos de escaneo sin respuesta abandonados" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%d fils d’analyse sans réponse abandonnés" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "%d threads de varredura sem resposta abandonadas" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%d неотвечающих потоков сканирования отброшены" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تم التخلي عن %d من خيوط المسح غير المستجيبة" + } + } + } + }, + "detail.developerArtifacts.needsRoots": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "先添加要扫描的工程文件夹,只会扫描你指定的目录" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Add project folders to scan. Only the folders you choose are scanned." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "先添加要掃描的工程文件夾,只會掃描你指定的目錄" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スキャンするプロジェクトフォルダを追加します。指定したフォルダだけが対象です。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "스캔할 프로젝트 폴더를 추가하세요. 지정한 폴더만 스캔합니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Projektordner zum Scannen hinzufügen. Es werden nur von dir gewählte Ordner gescannt." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Añade carpetas de proyecto para escanear. Solo se escanean las que elijas." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ajoutez des dossiers de projet à analyser. Seuls les dossiers choisis sont parcourus." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Adicione pastas de projeto para escanear. Só as pastas escolhidas são analisadas." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Добавьте папки проектов для сканирования. Сканируются только выбранные вами." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "أضف مجلدات المشاريع للمسح. يُمسح فقط ما تختاره." + } + } + } + }, + "detail.section.notScanned": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "点击「扫描」查看可清理内容" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Click Scan to see what can be cleaned" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "點擊「掃描」查看可清理內容" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "「スキャン」を押してクリーンアップ可能な項目を表示" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "「스캔」을 눌러 정리 가능한 항목 확인" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "„Scannen“ tippen, um bereinigbare Inhalte zu sehen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Pulsa Escanear para ver lo que se puede limpiar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Cliquez sur Analyser pour voir ce qui peut être nettoyé" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Clique em Escanear para ver o que pode ser limpo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Нажмите «Сканировать», чтобы увидеть, что можно очистить" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "انقر «مسح» لعرض ما يمكن تنظيفه" + } + } + } + }, + "detail.section.accessDenied.title": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "没有访问权限,无法确认里面有什么" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "No access — can’t confirm what’s inside" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "沒有訪問權限,無法確認裡面有什麼" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アクセス権がないため内容を確認できません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "접근 권한 없음 — 내용을 확인할 수 없음" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kein Zugriff — Inhalt nicht prüfbar" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Sin acceso: no se puede confirmar el contenido" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Pas d’accès — impossible de confirmer le contenu" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Sem acesso — não é possível confirmar o conteúdo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Нет доступа — содержимое не подтвердить" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لا يوجد وصول — يتعذّر تأكيد المحتوى" + } + } + } + }, + "detail.section.accessDenied.message": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "系统拒绝了对 %@ 的访问。在系统设置里允许后重新扫描。" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "The system denied access to %@. Allow access in System Settings, then scan again." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "系統拒絕了對 %@ 的訪問。在系統設置裡允許後重新掃描。" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "システムが %@ へのアクセスを拒否しました。システム設定で許可してから再スキャンしてください。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "시스템이 %@에 대한 접근을 거부했습니다. 시스템 설정에서 허용한 뒤 다시 스캔하세요." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Das System hat den Zugriff auf %@ verweigert. In den Systemeinstellungen erlauben und erneut scannen." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "El sistema denegó el acceso a %@. Permítelo en Ajustes del Sistema y vuelve a escanear." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le système a refusé l’accès à %@. Autorisez-le dans Réglages Système, puis analysez à nouveau." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "O sistema negou o acesso a %@. Permita em Ajustes do Sistema e escaneie de novo." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Система отказала в доступе к %@. Разрешите в «Системных настройках» и просканируйте снова." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "رفض النظام الوصول إلى %@. اسمح به في إعدادات النظام ثم أعد المسح." + } + } + } + }, + "detail.section.rootsUnreadable.title": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "有文件夹已无法读取" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Some folders can no longer be read" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "有文件夾已無法讀取" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "読み取れないフォルダがあります" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "더 이상 읽을 수 없는 폴더가 있습니다" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Einige Ordner sind nicht mehr lesbar" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Algunas carpetas ya no se pueden leer" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Certains dossiers ne sont plus lisibles" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Algumas pastas já não podem ser lidas" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Некоторые папки больше не читаются" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "بعض المجلدات لم يعد بالإمكان قراءتها" + } + } + } + }, + "detail.section.rootsUnreadable.line": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ 已被移除、替换或位于其他卷上。" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ was removed, replaced, or is on another volume." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@ 已被移除、替換或位於其他卷上。" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ は削除・置換されたか、別のボリューム上にあります。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@가 제거·교체되었거나 다른 볼륨에 있습니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ wurde entfernt, ersetzt oder liegt auf einem anderen Volume." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%@ se eliminó, se reemplazó o está en otro volumen." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%@ a été supprimé, remplacé ou se trouve sur un autre volume." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "%@ foi removido, substituído ou está noutro volume." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%@ удалён, заменён или находится на другом томе." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "أُزيل %@ أو استُبدل أو يقع على مجلد آخر." + } + } + } + }, + "category.developerArtifacts.title": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "开发产物" + } + } + } + }, + "category.developerArtifacts.consequence": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "源码与提交不受影响,依赖需要重新安装、产物需要重新构建。" + } + } + } + }, + "category.installers.title": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "残留安装包" + } + } + } + }, + "category.installers.consequence": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已安装的应用不受影响,安装包需要时可重新下载。" + } + } + } + }, + "category.userEssentials.title": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "用户缓存" + } + } + } + }, + "category.appCaches.title": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "应用缓存" + } + } + } + }, + "category.systemCaches.title": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "系统状态缓存" + } + } + } + }, + "category.logs.title": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "日志与诊断报告" + } + } + } + }, + "category.developer.title": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "开发者缓存" + } + } + } + }, + "category.browsers.title": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "浏览器缓存" + } + } + } + }, + "category.cloudOffice.title": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "云盘与办公" + } + } + } + }, + "category.communication.title": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "通讯应用" + } + } + } + }, + "category.aiTools.title": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "AI 工具" + } + } + } + }, + "category.virtualization.title": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "虚拟化" + } + } + } + }, + "category.userEssentials.consequence": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "应用首次启动会稍慢,登录状态与文档不受影响。" + } + } + } + }, + "category.appCaches.consequence": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "相关应用首次打开会稍慢,缩略图与预览需要重新生成。" + } + } + } + }, + "category.systemCaches.consequence": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "下次打开应用可能不恢复上次的窗口,预览缩略图需要重建。" + } + } + } + }, + "category.logs.consequence": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "历史日志与诊断报告会丢失,反馈问题时可能缺少记录。" + } + } + } + }, + "category.developer.consequence": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "首次构建会变慢,依赖与索引需要重新下载或生成。" + } + } + } + }, + "category.browsers.consequence": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "首次访问网站会稍慢,书签与登录状态不受影响。" + } + } + } + }, + "category.cloudOffice.consequence": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "云盘与办公应用需要重建本地缓存,同步会短暂变慢。" + } + } + } + }, + "category.communication.consequence": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "聊天记录不受影响,图片与文件需要重新下载。" + } + } + } + }, + "category.aiTools.consequence": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "对话记录不受影响,模型与界面资源需要重新缓存。" + } + } + } + }, + "category.virtualization.consequence": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "虚拟机磁盘不受影响,快照缓存与临时文件需要重建。" + } + } + } + }, + "panel.subtitle.trashed": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已移到废纸篓约 %@" + } + } + } + }, + "detail.purgeRoots.rejected.tooBroad": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ 范围过大,请选择具体的工程目录。" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ is too broad. Choose a specific project directory." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@ 範圍過大,請選擇具體的專案目錄。" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ の範囲が広すぎます。具体的なプロジェクトディレクトリを選択してください。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@ 범위가 너무 큽니다. 구체적인 프로젝트 디렉터리를 선택하세요." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ ist zu weit gefasst. Wähle ein konkretes Projektverzeichnis." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%@ es demasiado amplio. Elige un directorio de proyecto concreto." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%@ est trop large. Choisissez un répertoire de projet précis." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "%@ é abrangente demais. Escolha um diretório de projeto específico." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%@ слишком широкий путь. Выберите конкретный каталог проекта." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "%@ نطاقه واسع جدًا. اختر مجلد مشروع محددًا." + } + } + } } }, "version": "1.0" diff --git a/Plugins/DiskClean/Sources/DiskCleanAuditLog.swift b/Plugins/DiskClean/Sources/DiskCleanAuditLog.swift new file mode 100644 index 00000000..d38b9bc0 --- /dev/null +++ b/Plugins/DiskClean/Sources/DiskCleanAuditLog.swift @@ -0,0 +1,127 @@ +import Foundation + +/// Deletion audit log (design §7.8). +/// +/// Append-only JSONL; rotate to `.1` at 5MB, keep 2 generations (current + one rotated). +/// Log path lives under the plugin support directory, protected by the SafetyPolicy "cleanup tool state" branch. +final class DiskCleanAuditLog: @unchecked Sendable { + struct Record: Codable, Equatable, Sendable { + enum Action: String, Codable, Sendable { + case trash + case delete + /// Scan-level events: circuit breakers, thread abandonment, reconciliation results. + case scanEvent + } + + let timestamp: Date + let action: Action + let targetID: String? + let legacyRuleID: String? + let category: String? + let path: String? + let stagedName: String? + let estimatedBytes: Int64? + /// ok / skipped / failed / partiallyDeleted / rollbackBlocked / reconciled… + let status: String + let skipReason: String? + let error: String? + + init( + timestamp: Date, + action: Action, + targetID: String? = nil, + legacyRuleID: String? = nil, + category: String? = nil, + path: String? = nil, + stagedName: String? = nil, + estimatedBytes: Int64? = nil, + status: String, + skipReason: String? = nil, + error: String? = nil + ) { + self.timestamp = timestamp + self.action = action + self.targetID = targetID + self.legacyRuleID = legacyRuleID + self.category = category + self.path = path + self.stagedName = stagedName + self.estimatedBytes = estimatedBytes + self.status = status + self.skipReason = skipReason + self.error = error + } + } + + static let fileName = "audit.jsonl" + static let rotatedFileName = "audit.jsonl.1" + /// Rotation threshold. Tests may inject a smaller value. + let maximumFileSizeBytes: Int + + private let directory: URL + private let fileURL: URL + private let rotatedFileURL: URL + private let lock = NSLock() + private let encoder: JSONEncoder + private let decoder: JSONDecoder + + /// Same as `DiskCleanStagingJournal`: init does not touch the filesystem; the directory is created on first write. + init(directory: URL, maximumFileSizeBytes: Int = 5 * 1024 * 1024) { + self.directory = directory + self.fileURL = directory.appendingPathComponent(Self.fileName) + self.rotatedFileURL = directory.appendingPathComponent(Self.rotatedFileName) + self.maximumFileSizeBytes = maximumFileSizeBytes + self.encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + self.decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + } + + /// Append one record. Audit failure must not block deletion (best effort), but rotation is decided before the append. + func append(_ record: Record) { + lock.lock() + defer { lock.unlock() } + + rotateIfNeeded() + guard let data = try? encoder.encode(record) else { return } + do { + if !FileManager.default.fileExists(atPath: fileURL.path) { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try Data().write(to: fileURL) + } + let handle = try FileHandle(forWritingTo: fileURL) + defer { try? handle.close() } + try handle.seekToEnd() + try handle.write(contentsOf: data + Data([UInt8(ascii: "\n")])) + } catch { + // Audit is observability, not a safety barrier; failure must not block deletion. + } + } + + /// Read recent records newest-first (cleanup history UI). Spans the current file and one rotated `.1` generation. + func recentRecords(limit: Int) -> [Record] { + lock.lock() + defer { lock.unlock() } + + var records: [Record] = [] + for url in [fileURL, rotatedFileURL] { + guard let data = try? Data(contentsOf: url) else { continue } + for lineData in data.split(separator: UInt8(ascii: "\n")) { + if let record = try? decoder.decode(Record.self, from: Data(lineData)) { + records.append(record) + } + } + } + return Array(records.sorted { $0.timestamp > $1.timestamp }.prefix(limit)) + } + + private func rotateIfNeeded() { + let attributes = try? FileManager.default.attributesOfItem(atPath: fileURL.path) + let size = (attributes?[.size] as? NSNumber)?.intValue ?? 0 + guard size >= maximumFileSizeBytes else { return } + + try? FileManager.default.removeItem(at: rotatedFileURL) + try? FileManager.default.moveItem(at: fileURL, to: rotatedFileURL) + } +} diff --git a/Plugins/DiskClean/Sources/DiskCleanBulkAttributeParser.swift b/Plugins/DiskClean/Sources/DiskCleanBulkAttributeParser.swift new file mode 100644 index 00000000..d7784dc2 --- /dev/null +++ b/Plugins/DiskClean/Sources/DiskCleanBulkAttributeParser.swift @@ -0,0 +1,248 @@ +import Darwin +import Foundation + +/// One raw attribute entry returned by `getattrlistbulk`. Missing fields are nil—non-APFS volumes may omit attributes, +/// so the parser must not assume a fixed layout (design §3.3). +struct DiskCleanBulkAttributeEntry: Equatable, Sendable { + /// Entry name as **raw NUL-terminated bytes**. + /// + /// File names are not guaranteed to be valid UTF-8 (HFS+ / external volumes / FUSE may produce illegal sequences). + /// Converting to String then back for `openat`/`fstatat` can drop bytes, fail descent, and be misreported as walkError, + /// so keep raw bytes and only lossily convert for display. + var nameBytes: [CChar]? + var fileType: DiskCleanRootIdentity.FileType? + var devid: UInt64? + var fileID: UInt64? + var linkCount: UInt32? + var dataLength: Int64? + /// true = fixed-section end does not match `ATTR_CMN_NAME`'s `attr_dataoffset`. + /// + /// See the layout-check notes on `DiskCleanBulkAttributeParser`. When set, every attribute except `nameBytes` + /// is untrustworthy; the caller must fall back to per-entry `fstatat`. + var hasLayoutMismatch: Bool = false + + var displayName: String? { + guard let nameBytes else { return nil } + let bytes = nameBytes.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) } + return String(decoding: bytes, as: UTF8.self) + } + + /// Whether required attributes are present. Directories omit `ATTR_FILE_*`, so missing linkCount/dataLength is allowed for them. + var isFullyResolved: Bool { + guard !hasLayoutMismatch, nameBytes != nil, let fileType, devid != nil, fileID != nil else { + return false + } + if fileType == .directory { + return true + } + return linkCount != nil && dataLength != nil + } +} + +struct DiskCleanBulkAttributeParseResult: Equatable, Sendable { + let entries: [DiskCleanBulkAttributeEntry] + /// Buffer structure was abnormal and parsing stopped early: fewer entries than the kernel declared. + let isTruncated: Bool +} + +/// `getattrlistbulk` buffer parser. +/// +/// **Layout facts (verified against the kernel; do not infer from the man page alone):** +/// 1. Fixed fields inside an entry are packed in ascending attribute-bit order with no alignment padding, so reads must be unaligned. +/// 2. `RETURNED_ATTRS` is always the first field (immediately after the 4-byte entry length). +/// 3. Requested-but-not-returned attributes do **not** always vanish from the buffer: directory entries truly omit `ATTR_FILE_*`, +/// but `ATTR_CMN_ERROR` still occupies 4 bytes even when its returned bit is 0. This parser therefore +/// **does not request `ATTR_CMN_ERROR`**—skipping it via the bitmap would misalign every following field. +/// Per-entry failure detection is left to the caller's `fstatat` fallback (strictly more informative). +/// 4. To defend against similar unknown "reserved but not flagged" behavior, after the fixed section we re-check bounds using `ATTR_CMN_NAME`'s +/// `attr_dataoffset` (start of the variable section, independent of the bitmap); on mismatch set +/// `hasLayoutMismatch`, turning silent misalignment into a detectable safe fallback. +enum DiskCleanBulkAttributeParser { + static let requestedCommonAttributes: attrgroup_t = + attrgroup_t(ATTR_CMN_RETURNED_ATTRS) + | attrgroup_t(ATTR_CMN_NAME) + | attrgroup_t(ATTR_CMN_DEVID) + | attrgroup_t(ATTR_CMN_OBJTYPE) + | attrgroup_t(ATTR_CMN_FILEID) + + static let requestedFileAttributes: attrgroup_t = + attrgroup_t(ATTR_FILE_LINKCOUNT) | attrgroup_t(ATTR_FILE_DATALENGTH) + + /// Indexes of the kernel-returned attribute bitmaps in `attribute_set_t`: common/vol/dir/file/fork. + private static let commonGroupIndex = 0 + private static let fileGroupIndex = 3 + private static let returnedAttributesSize = 20 + + static func makeAttributeList() -> attrlist { + var list = attrlist() + list.bitmapcount = u_short(ATTR_BIT_MAP_COUNT) + list.commonattr = requestedCommonAttributes + list.fileattr = requestedFileAttributes + return list + } + + static func parse( + buffer: UnsafeRawBufferPointer, + entryCount: Int + ) -> DiskCleanBulkAttributeParseResult { + guard entryCount > 0 else { + return DiskCleanBulkAttributeParseResult(entries: [], isTruncated: false) + } + + var entries: [DiskCleanBulkAttributeEntry] = [] + entries.reserveCapacity(entryCount) + var cursor = 0 + + for _ in 0..= 4, cursor + entryLength <= buffer.count else { + return DiskCleanBulkAttributeParseResult(entries: entries, isTruncated: true) + } + + entries.append( + parseEntry( + buffer: UnsafeRawBufferPointer(rebasing: buffer[cursor..<(cursor + entryLength)]) + ) + ) + cursor += entryLength + } + + return DiskCleanBulkAttributeParseResult(entries: entries, isTruncated: false) + } + + private static func parseEntry(buffer: UnsafeRawBufferPointer) -> DiskCleanBulkAttributeEntry { + var entry = DiskCleanBulkAttributeEntry() + + // The 4-byte entry length + 20-byte RETURNED_ATTRS are prerequisites for any further parsing. + guard buffer.count >= 4 + returnedAttributesSize else { + entry.hasLayoutMismatch = true + return entry + } + + let commonReturned = buffer.loadUnaligned( + fromByteOffset: 4 + commonGroupIndex * 4, + as: attrgroup_t.self + ) + let fileReturned = buffer.loadUnaligned( + fromByteOffset: 4 + fileGroupIndex * 4, + as: attrgroup_t.self + ) + + var reader = Reader(buffer: buffer, offset: 4 + returnedAttributesSize) + var expectedNameDataOffset: Int? + + if commonReturned & attrgroup_t(ATTR_CMN_NAME) != 0 { + let referenceOffset = reader.offset + guard let dataOffset = reader.readInt32(), let length = reader.readUInt32() else { + entry.hasLayoutMismatch = true + return entry + } + let nameDataOffset = referenceOffset + Int(dataOffset) + expectedNameDataOffset = nameDataOffset + entry.nameBytes = readName(buffer: buffer, offset: nameDataOffset, length: Int(length)) + } + + if commonReturned & attrgroup_t(ATTR_CMN_DEVID) != 0 { + guard let value = reader.readInt32() else { + entry.hasLayoutMismatch = true + return entry + } + entry.devid = UInt64(UInt32(bitPattern: value)) + } + + if commonReturned & attrgroup_t(ATTR_CMN_OBJTYPE) != 0 { + guard let value = reader.readUInt32() else { + entry.hasLayoutMismatch = true + return entry + } + entry.fileType = fileType(objectType: value) + } + + if commonReturned & attrgroup_t(ATTR_CMN_FILEID) != 0 { + guard let value = reader.readUInt64() else { + entry.hasLayoutMismatch = true + return entry + } + entry.fileID = value + } + + if fileReturned & attrgroup_t(ATTR_FILE_LINKCOUNT) != 0 { + guard let value = reader.readUInt32() else { + entry.hasLayoutMismatch = true + return entry + } + entry.linkCount = value + } + + if fileReturned & attrgroup_t(ATTR_FILE_DATALENGTH) != 0 { + guard let value = reader.readInt64() else { + entry.hasLayoutMismatch = true + return entry + } + entry.dataLength = value + } + + // The fixed-section end must be exactly the start of the variable section (NAME is the only variable field); otherwise there are + // attributes that occupy space without being declared in the bitmap, and every later field is misaligned. + if let expectedNameDataOffset, reader.offset != expectedNameDataOffset { + entry.hasLayoutMismatch = true + entry.fileType = nil + entry.devid = nil + entry.fileID = nil + entry.linkCount = nil + entry.dataLength = nil + } + + return entry + } + + private static func readName( + buffer: UnsafeRawBufferPointer, + offset: Int, + length: Int + ) -> [CChar]? { + guard offset >= 0, length > 0, offset < buffer.count else { return nil } + let available = min(length, buffer.count - offset) + var bytes: [CChar] = [] + bytes.reserveCapacity(available) + for index in 0.. DiskCleanRootIdentity.FileType { + switch objectType { + case UInt32(VDIR.rawValue): return .directory + case UInt32(VREG.rawValue): return .regularFile + case UInt32(VLNK.rawValue): return .symlink + default: return .other + } + } + + /// Bounds-checked unaligned cursor: out-of-range reads return nil so the caller can set `hasLayoutMismatch`. + private struct Reader { + let buffer: UnsafeRawBufferPointer + var offset: Int + + mutating func readInt32() -> Int32? { read(as: Int32.self) } + mutating func readUInt32() -> UInt32? { read(as: UInt32.self) } + mutating func readInt64() -> Int64? { read(as: Int64.self) } + mutating func readUInt64() -> UInt64? { read(as: UInt64.self) } + + private mutating func read(as type: T.Type) -> T? { + let size = MemoryLayout.size + guard offset >= 0, offset + size <= buffer.count else { return nil } + let value = buffer.loadUnaligned(fromByteOffset: offset, as: type) + offset += size + return value + } + } +} diff --git a/Plugins/DiskClean/Sources/DiskCleanCategoryListView.swift b/Plugins/DiskClean/Sources/DiskCleanCategoryListView.swift new file mode 100644 index 00000000..bf2ad9f2 --- /dev/null +++ b/Plugins/DiskClean/Sources/DiskCleanCategoryListView.swift @@ -0,0 +1,505 @@ +import SwiftUI +import MacToolsPluginKit + +// MARK: - Grouping + +/// Data for one category card. The view derives it from the candidate list and holds no extra state. +struct DiskCleanCategoryGroup: Identifiable, Equatable, Sendable { + let category: DiskCleanCategoryID + let candidates: [DiskCleanCandidate] + let selectableCount: Int + let selectedCount: Int + let selectedEstimatedBytes: Int64 + + var id: String { category.rawValue } + + /// Grouped by `DiskCleanCategoryID.displayOrder` (low risk → high). Empty categories are omitted. + static func groups( + candidates: [DiskCleanCandidate], + selection: DiskCleanSelectionProjection + ) -> [DiskCleanCategoryGroup] { + let candidatesByCategory = Dictionary(grouping: candidates, by: \.category) + return DiskCleanCategoryID.displayOrder.compactMap { category in + guard let items = candidatesByCategory[category], !items.isEmpty else { return nil } + let selected = items.filter { selection.isSelected($0.id) } + return DiskCleanCategoryGroup( + category: category, + candidates: items, + selectableCount: items.filter { selection.isSelectable($0.id) }.count, + selectedCount: selected.count, + selectedEstimatedBytes: selected.reduce(0) { $0 + max($1.estimatedBytes, 0) } + ) + } + } +} + +// MARK: - Category card list + +/// Category card list on the detail page (design §8.3 item 3). +struct DiskCleanCategoryListView: View { + let groups: [DiskCleanCategoryGroup] + let selection: DiskCleanSelectionProjection + /// Per-item terminal status from the last run, used for badges such as "content changed". + let outcomesByCandidateID: [DiskCleanCandidate.ID: DiskCleanExecutionItemResult.Outcome] + let localization: PluginLocalization + let isInteractionEnabled: Bool + let onToggleCandidate: (DiskCleanCandidate.ID, Bool) -> Void + let onToggleCategory: (DiskCleanCategoryID, Bool) -> Void + + @Binding var expandedCategories: Set + + var body: some View { + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.sectionHeaderContent) { + ForEach(groups) { group in + DiskCleanCategoryCard( + group: group, + state: selection.state(of: group.category), + selection: selection, + outcomesByCandidateID: outcomesByCandidateID, + localization: localization, + isInteractionEnabled: isInteractionEnabled, + isExpanded: expandedCategories.contains(group.category), + onToggleExpanded: { + if expandedCategories.contains(group.category) { + expandedCategories.remove(group.category) + } else { + expandedCategories.insert(group.category) + } + }, + onToggleCandidate: onToggleCandidate, + onToggleCategory: onToggleCategory + ) + } + } + } +} + +// MARK: - Category cards + +private struct DiskCleanCategoryCard: View { + let group: DiskCleanCategoryGroup + let state: DiskCleanCategorySelectionState + let selection: DiskCleanSelectionProjection + let outcomesByCandidateID: [DiskCleanCandidate.ID: DiskCleanExecutionItemResult.Outcome] + let localization: PluginLocalization + let isInteractionEnabled: Bool + let isExpanded: Bool + let onToggleExpanded: () -> Void + let onToggleCandidate: (DiskCleanCandidate.ID, Bool) -> Void + let onToggleCategory: (DiskCleanCategoryID, Bool) -> Void + + var body: some View { + VStack(spacing: 0) { + header + if isExpanded { + PluginSettingsListDivider() + candidateRows + } + } + .pluginSettingsCardBackground(.plugin) + } + + private var header: some View { + HStack(alignment: .top, spacing: PluginSettingsTheme.Spacing.rowContentControl) { + DiskCleanTriStateCheckbox( + state: state, + // "Select all" = select every low-risk item in this category; if anything is already selected, one click clears all. + // Tri-state checkboxes must never feel like a no-op click, so both directions always produce a change. + onToggle: { onToggleCategory(group.category, !state.isChecked) }, + help: checkboxHelp + ) + .disabled(!isInteractionEnabled || !state.isSelectable) + + Image(systemName: group.category.symbolName) + .pluginSettingsRowIconStyle() + + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.rowTitleDescription) { + Text(group.category.title(localization: localization)) + .font(PluginSettingsTheme.Typography.emphasizedRowTitle) + Text(group.category.consequence(localization: localization)) + .font(PluginSettingsTheme.Typography.rowDescription) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + Text(countSummary) + .font(PluginSettingsTheme.Typography.statusBadge) + .foregroundStyle(.secondary) + } + + Spacer(minLength: PluginSettingsTheme.Spacing.rowContentControl) + + Text(DiskCleanFormat.approximateBytes(group.selectedEstimatedBytes, localization: localization)) + .font(PluginSettingsTheme.Typography.monospacedValue) + .frame(width: DiskCleanFormat.byteColumnWidth, alignment: .trailing) + + Button(action: onToggleExpanded) { + Image(systemName: isExpanded ? "chevron.up" : "chevron.down") + .font(PluginSettingsTheme.Typography.rowIcon) + } + .buttonStyle(.borderless) + .help( + isExpanded + ? localization.string("detail.category.collapse", defaultValue: "收起项目") + : localization.string("detail.category.expand", defaultValue: "展开项目") + ) + } + .pluginSettingsListRowPadding(interactive: true) + } + + private var candidateRows: some View { + VStack(spacing: 0) { + ForEach(group.candidates) { candidate in + DiskCleanCandidateRow( + candidate: candidate, + isSelected: selection.isSelected(candidate.id), + isSelectable: selection.isSelectable(candidate.id), + outcome: outcomesByCandidateID[candidate.id], + localization: localization, + isInteractionEnabled: isInteractionEnabled, + onToggle: { onToggleCandidate(candidate.id, $0) } + ) + if candidate.id != group.candidates.last?.id { + PluginSettingsListDivider() + } + } + } + } + + private var countSummary: String { + localization.format( + "detail.category.counts", + defaultValue: "共 %d 项 · 可清理 %d 项 · 已选 %d 项", + group.candidates.count, + group.selectableCount, + group.selectedCount + ) + } + + private var checkboxHelp: String { + state.isChecked + ? localization.string("detail.category.deselectAll", defaultValue: "取消选择本类全部项目") + : localization.string("detail.category.selectLowRisk", defaultValue: "选中本类所有低风险项目") + } +} + +// MARK: - Candidate row + +private struct DiskCleanCandidateRow: View { + let candidate: DiskCleanCandidate + let isSelected: Bool + let isSelectable: Bool + let outcome: DiskCleanExecutionItemResult.Outcome? + let localization: PluginLocalization + let isInteractionEnabled: Bool + let onToggle: (Bool) -> Void + + var body: some View { + HStack(alignment: .top, spacing: PluginSettingsTheme.Spacing.rowContentControl) { + Toggle("", isOn: Binding(get: { isSelected }, set: { onToggle($0) })) + .toggleStyle(.checkbox) + .labelsHidden() + .disabled(!isInteractionEnabled || !isSelectable) + + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.rowTitleDescription) { + Text(candidate.displayName) + .font(PluginSettingsTheme.Typography.rowTitle) + .lineLimit(1) + .truncationMode(.middle) + + Text(candidate.path) + .font(PluginSettingsTheme.Typography.monospacedValue) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.enabled) + + if !badges.isEmpty { + HStack(spacing: PluginSettingsTheme.Spacing.controlCluster) { + ForEach(badges) { badge in + DiskCleanBadgeLabel(badge: badge) + } + } + } + } + + Spacer(minLength: PluginSettingsTheme.Spacing.rowContentControl) + + Text(sizeText) + .font(PluginSettingsTheme.Typography.monospacedValue) + .foregroundStyle(candidate.sizeResult == nil ? Color.secondary : Color.primary) + .frame(width: DiskCleanFormat.byteColumnWidth, alignment: .trailing) + } + .pluginSettingsListRowPadding() + } + + /// Unresolved sizes show "calculating…" rather than "0 bytes"—the latter would look like an empty item. + private var sizeText: String { + guard candidate.sizeResult != nil else { + return localization.string("detail.candidate.sizing", defaultValue: "计算中…") + } + return DiskCleanFormat.approximateBytes(candidate.estimatedBytes, localization: localization) + } + + private var badges: [DiskCleanBadge] { + DiskCleanBadge.badges(for: candidate, outcome: outcome, localization: localization) + } +} + +// MARK: - Badges + +struct DiskCleanBadge: Identifiable, Equatable, Sendable { + enum Tone: Equatable, Sendable { + case neutral + case warning + } + + let id: String + let text: String + let tone: Tone + + /// Per-item badges (design §8.3): in use / whitelist / protected / partial-reason / content changed / mount point / calculating. + /// + /// Order is priority: first "what happened on the last cleanup", then "why it cannot be selected now". + static func badges( + for candidate: DiskCleanCandidate, + outcome: DiskCleanExecutionItemResult.Outcome?, + localization: PluginLocalization + ) -> [DiskCleanBadge] { + var badges: [DiskCleanBadge] = [] + + if let outcome { + badges.append(contentsOf: self.badges(for: outcome, localization: localization)) + } + + // Candidate-local facts (P2 repo status, installer age) come before safety status: + // they explain "why this item was not selected by default"—the first question users ask when something is unchecked. + badges += candidate.notes.compactMap { badge(for: $0, localization: localization) } + + switch candidate.safety { + case .allowed: + break + case let .inUse(processName): + badges.append( + DiskCleanBadge( + id: "inUse", + text: localization.format("badge.inUse", defaultValue: "使用中:%@", processName), + tone: .warning + ) + ) + case .whitelisted: + badges.append( + DiskCleanBadge( + id: "whitelisted", + text: localization.string("badge.whitelisted", defaultValue: "白名单"), + tone: .neutral + ) + ) + case .protected: + badges.append( + DiskCleanBadge( + id: "protected", + text: localization.string("badge.protected", defaultValue: "受保护"), + tone: .neutral + ) + ) + case .invalid: + badges.append( + DiskCleanBadge( + id: "invalid", + text: localization.string("badge.invalid", defaultValue: "路径不安全"), + tone: .neutral + ) + ) + case .requiresAdmin: + badges.append( + DiskCleanBadge( + id: "requiresAdmin", + text: localization.string("badge.requiresAdmin", defaultValue: "需要管理员"), + tone: .neutral + ) + ) + } + + guard let sizeResult = candidate.sizeResult else { + badges.append( + DiskCleanBadge( + id: "sizing", + text: localization.string("badge.sizing", defaultValue: "计算中"), + tone: .neutral + ) + ) + return badges + } + + let reasons = sizeResult.completeness.partialReasons + // Mount points are listed separately: not "incomplete sizing", but "this directory contains another volume; deleting it would cross boundaries". + if reasons.contains(.crossedMountPoint) { + badges.append( + DiskCleanBadge( + id: "crossedMountPoint", + text: localization.string("badge.crossedMountPoint", defaultValue: "含挂载点"), + tone: .warning + ) + ) + } + let otherReasons = reasons.subtracting([.crossedMountPoint]) + if !otherReasons.isEmpty { + badges.append( + DiskCleanBadge( + id: "partial", + text: localization.format( + "badge.partial", + defaultValue: "扫描不完整:%@", + DiskCleanFormat.partialReasons(otherReasons, localization: localization) + ), + tone: .warning + ) + ) + } + + return badges + } + + /// Candidate annotation badges (design §10 P2 badge system). + /// + /// "Owning project" is not a badge: it is **locating information**, not a warning; packing it into the capsule row would drown the real + /// "uncommitted changes" signal. Project path is already expressed by the candidate path itself. + private static func badge( + for note: DiskCleanCandidateNote, + localization: PluginLocalization + ) -> DiskCleanBadge? { + switch note { + case let .repositoryHasChanges(_, reason): + return DiskCleanBadge( + id: "repositoryHasChanges", + text: repositoryChangeText(reason, localization: localization), + tone: .warning + ) + case .mayNotBeInstaller: + return DiskCleanBadge( + id: "mayNotBeInstaller", + text: localization.string("badge.mayNotBeInstaller", defaultValue: "未必是安装包"), + tone: .neutral + ) + case let .recentlyDownloaded(modifiedAt): + return DiskCleanBadge( + id: "recentlyDownloaded", + text: localization.format( + "badge.recentlyDownloaded", + defaultValue: "近期下载:%@", + DiskCleanFormat.timestamp(modifiedAt) + ), + tone: .neutral + ) + case .developerProject: + return nil + } + } + + /// Treat check failure separately from real dirt: the former is "could not determine; treat as dirty", writing it as "has uncommitted changes" + /// would invent a fact the user cannot find in the repo. + private static func repositoryChangeText( + _ reason: DiskCleanPurgeGitState.DirtyReason, + localization: PluginLocalization + ) -> String { + switch reason { + case .uncommittedChanges: + return localization.string("badge.repository.uncommitted", defaultValue: "仓库有未提交改动") + case .unpushedCommits: + return localization.string("badge.repository.unpushed", defaultValue: "仓库有未推送提交") + case let .inspectionFailed(detail): + return localization.format( + "badge.repository.inspectionFailed", + defaultValue: "无法确认仓库状态:%@", + detail + ) + } + } + + private static func badges( + for outcome: DiskCleanExecutionItemResult.Outcome, + localization: PluginLocalization + ) -> [DiskCleanBadge] { + switch outcome { + case .changedSinceScan: + return [ + DiskCleanBadge( + id: "changedSinceScan", + text: localization.string("badge.changedSinceScan", defaultValue: "内容已变化,请重新扫描"), + tone: .warning + ) + ] + case .partiallyDeleted: + return [ + DiskCleanBadge( + id: "partiallyDeleted", + text: localization.string("badge.partiallyDeleted", defaultValue: "只删除了一部分"), + tone: .warning + ) + ] + case .rollbackBlocked: + return [ + DiskCleanBadge( + id: "rollbackBlocked", + text: localization.string("badge.rollbackBlocked", defaultValue: "无法放回原处"), + tone: .warning + ) + ] + case let .failed(message): + return [DiskCleanBadge(id: "failed", text: message, tone: .warning)] + case .removed, .trashed, .skipped: + return [] + } + } +} + +struct DiskCleanBadgeLabel: View { + let badge: DiskCleanBadge + + var body: some View { + Text(badge.text) + .font(PluginSettingsTheme.Typography.statusBadge) + .foregroundStyle(badge.tone == .warning ? Color.orange : Color.secondary) + .lineLimit(1) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background( + Capsule().fill(Color(nsColor: .quaternaryLabelColor).opacity(0.4)) + ) + } +} + +// MARK: - Tri-state checkbox + +/// Tri-state checkbox. +/// +/// `Toggle` only has on/off, but category selection must express "partially selected"—categories that contain medium/high items +/// necessarily land in mixed after "select all low-risk"; a two-state control would keep showing "not fully selected" +/// and users would think they mis-clicked. +struct DiskCleanTriStateCheckbox: View { + let state: DiskCleanCategorySelectionState + let onToggle: () -> Void + let help: String + + var body: some View { + Button(action: onToggle) { + Image(systemName: symbolName) + .font(.system(size: 14)) + .foregroundStyle(state.isChecked ? Color.accentColor : Color.secondary) + .frame(width: PluginSettingsTheme.Size.rowIcon, height: PluginSettingsTheme.Size.rowIcon) + } + .buttonStyle(.borderless) + .help(help) + .accessibilityLabel(help) + } + + private var symbolName: String { + switch state { + case .allSelected: + return "checkmark.square.fill" + case .partiallySelected: + return "minus.square.fill" + case .noneSelected, .unavailable: + return "square" + } + } +} diff --git a/Plugins/DiskClean/Sources/DiskCleanCleanupHistoryView.swift b/Plugins/DiskClean/Sources/DiskCleanCleanupHistoryView.swift new file mode 100644 index 00000000..7ef5cb2a --- /dev/null +++ b/Plugins/DiskClean/Sources/DiskCleanCleanupHistoryView.swift @@ -0,0 +1,353 @@ +import SwiftUI +import MacToolsPluginKit + +// MARK: - History entry + +/// Status of a cleanup history entry. +/// +/// Domain matches the `status` strings written to the audit log (`DiskCleanExecutor.status(of:)` and +/// `DiskCleanStagingReconciler.record(for:)`). Parse rather than display the raw string because +/// "which statuses need a pinned alert" is a product decision that needs a single home. +enum DiskCleanCleanupHistoryStatus: Equatable, Sendable { + case ok + case skipped + case changedSinceScan + case failed + case partiallyDeleted + case rollbackBlocked + case reconciledRolledBack + case reconciledAbsent + case reconcileFailed + case unknown(String) + + init(rawValue: String) { + switch rawValue { + case "ok": + self = .ok + case "skipped": + self = .skipped + case "changedSinceScan": + self = .changedSinceScan + case "failed": + self = .failed + case "partiallyDeleted": + self = .partiallyDeleted + case "rollbackBlocked": + self = .rollbackBlocked + case "reconciledRolledBack": + self = .reconciledRolledBack + // A missing parent directory and a missing staged object reach the same conclusion: nothing left to restore. + case "reconciledAbsent", "reconciledParentMissing": + self = .reconciledAbsent + case "reconcileFailed": + self = .reconcileFailed + default: + self = .unknown(rawValue) + } + } + + /// Honest terminal statuses that need a pinned alert (design §7.5, §13-M4-6). + /// + /// What they share: the disk still holds a staged object the user may not know about, or a deletion only finished halfway. + /// Ordinary `failed`/`skipped` leave no residue and need not interrupt the user. + var needsAttention: Bool { + switch self { + case .partiallyDeleted, .rollbackBlocked, .reconcileFailed: + return true + case .ok, .skipped, .changedSinceScan, .failed, + .reconciledRolledBack, .reconciledAbsent, .unknown: + return false + } + } + + var symbolName: String { + switch self { + case .ok: + return "checkmark.circle.fill" + case .skipped, .changedSinceScan: + return "minus.circle" + case .failed: + return "xmark.circle" + case .partiallyDeleted, .rollbackBlocked, .reconcileFailed: + return "exclamationmark.triangle.fill" + case .reconciledRolledBack: + return "arrow.uturn.backward.circle" + case .reconciledAbsent: + return "circle" + case .unknown: + return "circle" + } + } + + func title(localization: PluginLocalization) -> String { + switch self { + case .ok: + return localization.string("history.status.ok", defaultValue: "已清理") + case .skipped: + return localization.string("history.status.skipped", defaultValue: "已跳过") + case .changedSinceScan: + return localization.string("history.status.changed", defaultValue: "内容已变化") + case .failed: + return localization.string("history.status.failed", defaultValue: "失败") + case .partiallyDeleted: + return localization.string("history.status.partiallyDeleted", defaultValue: "只删除了一部分") + case .rollbackBlocked: + return localization.string("history.status.rollbackBlocked", defaultValue: "无法放回原处") + case .reconciledRolledBack: + return localization.string("history.status.reconciledRolledBack", defaultValue: "启动时已放回") + case .reconciledAbsent: + return localization.string("history.status.reconciledAbsent", defaultValue: "上次已完成") + case .reconcileFailed: + return localization.string("history.status.reconcileFailed", defaultValue: "未完成,待处理") + case let .unknown(rawValue): + return rawValue + } + } +} + +/// One cleanup history row. A display projection of an audit record. +struct DiskCleanCleanupHistoryEntry: Identifiable, Equatable, Sendable { + let id: Int + let timestamp: Date + let status: DiskCleanCleanupHistoryStatus + let path: String? + /// Staged name. In Trash mode, "Put Back" lands under this name, so it must be shown (design §7.4). + let stagedName: String? + let estimatedBytes: Int64? + let errorMessage: String? + + var needsAttention: Bool { status.needsAttention } + + /// Audit record → display entry. **Attention-needed rows are pinned** (design §13-M4-6); the rest stay reverse-chronological. + /// + /// Pin rather than only tint red: `partiallyDeleted` / `rollbackBlocked` mean residue remains on disk; + /// burying them among 200 history rows is the same as not saying it. + static func entries(from records: [DiskCleanAuditLog.Record]) -> [DiskCleanCleanupHistoryEntry] { + let entries = records.enumerated().map { index, record in + DiskCleanCleanupHistoryEntry( + id: index, + timestamp: record.timestamp, + status: DiskCleanCleanupHistoryStatus(rawValue: record.status), + path: record.path, + stagedName: record.stagedName, + estimatedBytes: record.estimatedBytes, + errorMessage: record.error + ) + } + return entries.filter(\.needsAttention) + entries.filter { !$0.needsAttention } + } +} + +// MARK: - Loading + +/// Read seam for cleanup history. The audit log is on disk; loading must not block the main thread. +protocol DiskCleanCleanupHistoryProviding: Sendable { + func recentEntries(limit: Int) async -> [DiskCleanCleanupHistoryEntry] +} + +struct DiskCleanAuditLogHistoryProvider: DiskCleanCleanupHistoryProviding { + let directory: URL + + func recentEntries(limit: Int) async -> [DiskCleanCleanupHistoryEntry] { + let directory = directory + let records = await Task.detached(priority: .utility) { + DiskCleanAuditLog(directory: directory).recentRecords(limit: limit) + }.value + return DiskCleanCleanupHistoryEntry.entries(from: records) + } +} + +// MARK: - View + +/// Cleanup history section. Load from disk only when expanded—rescanning the log every time settings appear is unnecessary. +struct DiskCleanCleanupHistorySection: View { + let provider: any DiskCleanCleanupHistoryProviding + let localization: PluginLocalization + + private static let recordLimit = 100 + + @State private var isExpanded = false + @State private var entries: [DiskCleanCleanupHistoryEntry] = [] + @State private var isLoading = false + + var body: some View { + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.sectionHeaderContent) { + DisclosureGroup(isExpanded: $isExpanded) { + content + .padding(.top, PluginSettingsTheme.Spacing.sectionHeaderContent) + } label: { + Text(localization.string("detail.history.title", defaultValue: "清理历史")) + .font(PluginSettingsTheme.Typography.rowTitle) + } + } + .task(id: isExpanded) { + guard isExpanded else { return } + isLoading = true + entries = await provider.recentEntries(limit: Self.recordLimit) + isLoading = false + } + } + + /// Place the reload button in the content area, not the DisclosureGroup label: + /// the label's hit target belongs to expand/collapse; a button there would fight for clicks. + private var reloadButton: some View { + Button { + Task { entries = await provider.recentEntries(limit: Self.recordLimit) } + } label: { + Label( + localization.string("detail.history.reload", defaultValue: "刷新"), + systemImage: "arrow.clockwise" + ) + .font(PluginSettingsTheme.Typography.controlLabel) + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(isLoading) + } + + @ViewBuilder + private var content: some View { + if isLoading { + HStack(spacing: PluginSettingsTheme.Spacing.controlCluster) { + ProgressView().controlSize(.small) + Text(localization.string("detail.history.loading", defaultValue: "正在读取记录…")) + .font(PluginSettingsTheme.Typography.rowDescription) + .foregroundStyle(.secondary) + } + .pluginSettingsListRowPadding() + } else if entries.isEmpty { + Text(localization.string("detail.history.empty", defaultValue: "还没有清理记录")) + .font(PluginSettingsTheme.Typography.rowDescription) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .pluginSettingsListRowPadding() + .pluginSettingsCardBackground(.plugin) + } else { + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.sectionHeaderContent) { + if let attentionCount { + attentionBanner(count: attentionCount) + } + + HStack { + Spacer() + reloadButton + } + + ScrollView { + LazyVStack(spacing: 0) { + ForEach(entries) { entry in + DiskCleanCleanupHistoryRow(entry: entry, localization: localization) + if entry.id != entries.last?.id { + PluginSettingsListDivider() + } + } + } + } + .frame(maxHeight: 260) + .pluginSettingsCardBackground(.plugin) + } + } + } + + private var attentionCount: Int? { + let count = entries.filter(\.needsAttention).count + return count > 0 ? count : nil + } + + private func attentionBanner(count: Int) -> some View { + HStack(alignment: .top, spacing: PluginSettingsTheme.Spacing.controlCluster) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + .frame(width: PluginSettingsTheme.Size.rowIcon) + + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.rowTitleDescription) { + Text( + localization.format( + "detail.history.attention.title", + defaultValue: "有 %d 项清理未完成", + count + ) + ) + .font(PluginSettingsTheme.Typography.rowTitle) + + Text( + localization.string( + "detail.history.attention.description", + defaultValue: "这些项目在磁盘上留下了以 .mactools-staged- 开头的暂存对象,可在下方记录里查看原路径后自行处理。" + ) + ) + .font(PluginSettingsTheme.Typography.rowDescription) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer(minLength: 0) + } + .pluginSettingsListRowPadding() + .pluginSettingsCardBackground(.plugin) + } +} + +private struct DiskCleanCleanupHistoryRow: View { + let entry: DiskCleanCleanupHistoryEntry + let localization: PluginLocalization + + var body: some View { + HStack(alignment: .top, spacing: PluginSettingsTheme.Spacing.rowContentControl) { + Image(systemName: entry.status.symbolName) + .foregroundStyle(entry.needsAttention ? Color.orange : Color.secondary) + .frame(width: PluginSettingsTheme.Size.rowIcon) + + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.rowTitleDescription) { + HStack(alignment: .firstTextBaseline, spacing: PluginSettingsTheme.Spacing.controlCluster) { + Text(entry.status.title(localization: localization)) + .font(PluginSettingsTheme.Typography.rowTitle) + Spacer(minLength: PluginSettingsTheme.Spacing.rowContentControl) + Text(DiskCleanFormat.timestamp(entry.timestamp)) + .font(PluginSettingsTheme.Typography.statusBadge) + .foregroundStyle(.secondary) + } + + if let path = entry.path { + Text(path) + .font(PluginSettingsTheme.Typography.monospacedValue) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.enabled) + } + + // Objects in Trash land under the staged name; without showing it the user cannot find them (design §7.4). + if let stagedName = entry.stagedName { + Text( + localization.format( + "detail.history.stagedName", + defaultValue: "暂存名:%@", + stagedName + ) + ) + .font(PluginSettingsTheme.Typography.monospacedValue) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.enabled) + } + + if let errorMessage = entry.errorMessage { + Text(errorMessage) + .font(PluginSettingsTheme.Typography.rowDescription) + .foregroundStyle(.secondary) + .lineLimit(2) + } + } + + if let bytes = entry.estimatedBytes { + Text(DiskCleanFormat.approximateBytes(bytes, localization: localization)) + .font(PluginSettingsTheme.Typography.monospacedValue) + .foregroundStyle(.secondary) + .frame(width: DiskCleanFormat.byteColumnWidth, alignment: .trailing) + } + } + .pluginSettingsListRowPadding() + } +} diff --git a/Plugins/DiskClean/Sources/DiskCleanCleanupReadiness.swift b/Plugins/DiskClean/Sources/DiskCleanCleanupReadiness.swift new file mode 100644 index 00000000..f753190e --- /dev/null +++ b/Plugins/DiskClean/Sources/DiskCleanCleanupReadiness.swift @@ -0,0 +1,28 @@ +import Foundation + +/// Process-local gate: cleanup stays disabled until startup journal reconciliation finishes. +/// +/// Without this, `activate` can launch reconciliation in parallel with a user-triggered clean. +/// Reconciliation and the executor must also share one journal instance; this gate covers the +/// "enable Clean only after reconcile" half of that contract. +final class DiskCleanCleanupReadiness: @unchecked Sendable { + private let lock = NSLock() + private var isReady = false + + /// Test seams may start ready when no journal recovery is needed. + init(isReady: Bool = false) { + self.isReady = isReady + } + + var ready: Bool { + lock.lock() + defer { lock.unlock() } + return isReady + } + + func markReady() { + lock.lock() + isReady = true + lock.unlock() + } +} diff --git a/Plugins/DiskClean/Sources/DiskCleanController.swift b/Plugins/DiskClean/Sources/DiskCleanController.swift index b70bc54f..7150e4a8 100644 --- a/Plugins/DiskClean/Sources/DiskCleanController.swift +++ b/Plugins/DiskClean/Sources/DiskCleanController.swift @@ -6,35 +6,86 @@ enum DiskCleanControllerPhase: Equatable, Sendable { case idle case scanning case scanned + /// Second step of permanent delete (design §8.4). The plan is cast and frozen, waiting for user confirmation. + case confirming case cleaning case completed } +/// Time source for the expiry gate (injectable Clock from design §4.4). +/// +/// Expiry must be **time-driven**: buttons grey out when the threshold hits, not only when the user next clicks. +/// So besides "what time is it now" we need "sleep until", so tests can drive the transition without really waiting 300s. +/// The confirmation window (§8.4) reuses the same clock. +protocol DiskCleanClock: Sendable { + var now: Date { get } + func sleep(until deadline: Date) async throws +} + +struct DiskCleanSystemClock: DiskCleanClock { + var now: Date { Date() } + + func sleep(until deadline: Date) async throws { + let interval = deadline.timeIntervalSinceNow + guard interval > 0 else { return } + // The upper bound is defensive only: the expiry window is 300s and cannot approach overflow. + let nanoseconds = min(interval, 86_400) * 1_000_000_000 + try await Task.sleep(nanoseconds: UInt64(nanoseconds)) + } +} + struct DiskCleanControllerSnapshot: Equatable, Sendable { let phase: DiskCleanControllerPhase - let selectedChoices: Set + /// Current scan scope. Rules segment uses checked groups; purge segment uses configured roots; installer segment has no parameters. + let scope: DiskCleanScanScope let scanResult: DiskCleanScanResult? let executionResult: DiskCleanExecutionResult? + /// Selected scan scope no longer matches the result. let isResultStale: Bool + /// Expiry gate has fired (design §4.4). Mis-operation protection, not TOCTOU protection. + let isResultExpired: Bool let errorMessage: String? let scanLogEntries: [DiskCleanScanLogEntry] + /// Current removal mode. The plan freezes the value at cast time; changing this invalidates a pending plan. + let removalMode: DiskCleanRemovalMode + /// Frozen summary of the pending plan. Non-nil only in the `confirming` phase; the full plan stays in Controller private state. + let pendingPlan: DiskCleanPendingPlanSummary? + /// Read-only projection of authoritative selection state (design §8.1). Menu bar and detail page read the same copy. + let selection: DiskCleanSelectionProjection + /// False until startup journal reconciliation finishes. + let isCleanupReady: Bool init( phase: DiskCleanControllerPhase, - selectedChoices: Set, + scope: DiskCleanScanScope, scanResult: DiskCleanScanResult?, executionResult: DiskCleanExecutionResult?, isResultStale: Bool, + isResultExpired: Bool = false, errorMessage: String?, - scanLogEntries: [DiskCleanScanLogEntry] = [] + scanLogEntries: [DiskCleanScanLogEntry] = [], + removalMode: DiskCleanRemovalMode = .trash, + pendingPlan: DiskCleanPendingPlanSummary? = nil, + selection: DiskCleanSelectionProjection = .empty, + isCleanupReady: Bool = true ) { self.phase = phase - self.selectedChoices = selectedChoices + self.scope = scope self.scanResult = scanResult self.executionResult = executionResult self.isResultStale = isResultStale + self.isResultExpired = isResultExpired self.errorMessage = errorMessage self.scanLogEntries = scanLogEntries + self.removalMode = removalMode + self.pendingPlan = pendingPlan + self.selection = selection + self.isCleanupReady = isCleanupReady + } + + /// Checked groups for the rules segment. Other segments have no group concept and return an empty set. + var selectedChoices: Set { + scope.choices } var subtitle: String { @@ -51,6 +102,9 @@ struct DiskCleanControllerSnapshot: Equatable, Sendable { if isResultStale { return localization.string("controller.subtitle.stale", defaultValue: "清理范围已变化") } + if isResultExpired { + return localization.string("controller.subtitle.expired", defaultValue: "结果已过期,请重新扫描") + } return scanResult.map { localization.format( "controller.subtitle.scannedCount", @@ -58,6 +112,8 @@ struct DiskCleanControllerSnapshot: Equatable, Sendable { $0.cleanableCandidates.count ) } ?? localization.string("controller.subtitle.scanned", defaultValue: "扫描完成") + case .confirming: + return localization.string("controller.subtitle.confirming", defaultValue: "等待确认永久清理") case .cleaning: return localization.string("controller.subtitle.cleaning", defaultValue: "正在清理") case .completed: @@ -69,19 +125,25 @@ struct DiskCleanControllerSnapshot: Equatable, Sendable { phase == .scanning || phase == .cleaning } + /// Disable scan when scope is empty: rules segment means no group checked; purge segment means no folder added yet. + /// Both cases need UI guidance rather than a button that does nothing. var canScan: Bool { - !isBusy && !selectedChoices.isEmpty + !isBusy && phase != .confirming && !scope.isEmpty } + /// Can clean = has a result, result is fresh, startup recovery finished, **and the user currently has a selection**. + /// When the selection is empty the button greys out rather than meaning "clean everything"—menu bar and detail page share one selection. var canClean: Bool { phase == .scanned && !isResultStale - && scanResult?.cleanableCandidates.isEmpty == false + && !isResultExpired + && !selection.isEmpty + && isCleanupReady } static let initial = DiskCleanControllerSnapshot( phase: .idle, - selectedChoices: Set(DiskCleanChoice.allCases), + scope: .rules(choices: Set(DiskCleanChoice.allCases)), scanResult: nil, executionResult: nil, isResultStale: false, @@ -94,15 +156,39 @@ protocol DiskCleanControlling: AnyObject { var onStateChange: (() -> Void)? { get set } var snapshot: DiskCleanControllerSnapshot { get } + /// Replace the whole scan scope. Purge-segment root add/remove goes through here. + func setScope(_ scope: DiskCleanScanScope) + /// Group checkbox for the rules segment. No-op under non-rules scopes. func setChoice(_ choice: DiskCleanChoice, isSelected: Bool) + func setRemovalMode(_ mode: DiskCleanRemovalMode) + func setCandidateSelected(_ candidateID: DiskCleanCandidate.ID, isSelected: Bool) + /// Category-level select all (`true` = all low-risk items in the category) / deselect all. + func setCategorySelection(_ category: DiskCleanCategoryID, isSelected: Bool) func scan() - func cleanSelected(candidateIDs: Set) + /// Clean the current selection. Takes no id parameter—there is one authoritative selection (design §8.1). + func clean() + func confirmPendingClean() + func cancelPendingClean() func cancelCurrentOperation() } +/// State machine + event-stream consumption + expiry clock + confirmation window. +/// +/// Four mechanisms kept as-is: +/// - **operationID generation**: each operation gets a new UUID; events and completion from stale operations are dropped, +/// so a "tail from the previous scan" cannot pollute the new round. +/// - **Throttled publish**: scan events arrive one-by-one but snapshots publish in ~250ms batches (AGENTS.md high-frequency-source exception), +/// otherwise every candidate would force a host rebuild. +/// - **Ring log cap**: keep at most 500 entries. +/// - **Single snapshot exit**: every state publish goes through `publish`; `removalMode` and `pendingPlan` +/// always derive from Controller private state, so no branch can forget a pending plan. @MainActor final class DiskCleanController: ObservableObject, DiskCleanControlling { - private static let scanLogFlushIntervalNanoseconds: UInt64 = 100_000_000 + /// Snapshot publish interval during scans (~250ms throttle from design §8.2). + private static let scanFlushIntervalNanoseconds: UInt64 = 250_000_000 + private static let maximumLogEntries = 500 + /// Confirmation window cap (design §8.4). Actual window is `min(60s, remaining expiry time)`. + static let confirmationWindow: TimeInterval = 60 var onStateChange: (() -> Void)? @@ -112,45 +198,162 @@ final class DiskCleanController: ObservableObject, DiskCleanControlling { } } - private let scanner: DiskCleanScanning - private let executor: DiskCleanExecuting + private let engine: any DiskCleanScanning + private let executor: any DiskCleanExecuting private let localization: PluginLocalization + private let clock: any DiskCleanClock + private let removalModeStore: any DiskCleanRemovalModeStoring + /// Look up target lock declarations when casting a plan. Inject the same catalog as `DiskCleanScanEngine`. + private let catalog: DiskCleanRuleCatalogV2 + private let cleanupReadiness: DiskCleanCleanupReadiness private var currentTask: Task? private var currentOperationID: UUID? - private var scanLogFlushTask: Task? + private var scanFlushTask: Task? + private var expiryTask: Task? + private var confirmationTask: Task? private var nextLogEntryID = 1 + private var removalMode: DiskCleanRemovalMode + /// Frozen pending plan. Must be nil outside the `confirming` phase. + private var pendingPlan: DiskCleanValidatedPlan? + /// Authoritative selection state (design §8.1). Views only receive the read-only projection derived in `publish`. + private var selection = DiskCleanSelectionModel() + + /// Authoritative candidate set while scanning. The snapshot is a projection; throttling only affects publish timing, not correctness. + private var liveCandidates: [DiskCleanCandidate] = [] + private var liveCandidateIndexByID: [DiskCleanCandidate.ID: Int] = [:] + private var pendingLogMessages: [DiskCleanScanLogMessage] = [] + private var needsScanFlush = false + init( - scanner: DiskCleanScanning = DiskCleanScanner(), - executor: DiskCleanExecuting = DiskCleanExecutor(), + engine: any DiskCleanScanning = DiskCleanScanEngine(), + executor: any DiskCleanExecuting = DiskCleanExecutor(), initialSnapshot: DiskCleanControllerSnapshot = .initial, - localization: PluginLocalization = PluginLocalization(bundle: .main) + localization: PluginLocalization = PluginLocalization(bundle: .main), + clock: any DiskCleanClock = DiskCleanSystemClock(), + removalModeStore: any DiskCleanRemovalModeStoring = UserDefaultsDiskCleanRemovalModeStore(), + catalog: DiskCleanRuleCatalogV2 = .current, + cleanupReadiness: DiskCleanCleanupReadiness = DiskCleanCleanupReadiness(isReady: true) ) { - self.scanner = scanner + self.engine = engine self.executor = executor self.localization = localization - snapshot = initialSnapshot + self.clock = clock + self.removalModeStore = removalModeStore + self.catalog = catalog + self.cleanupReadiness = cleanupReadiness + self.removalMode = removalModeStore.load() + snapshot = DiskCleanControllerSnapshot( + phase: initialSnapshot.phase, + scope: initialSnapshot.scope, + scanResult: initialSnapshot.scanResult, + executionResult: initialSnapshot.executionResult, + isResultStale: initialSnapshot.isResultStale, + isResultExpired: initialSnapshot.isResultExpired, + errorMessage: initialSnapshot.errorMessage, + scanLogEntries: initialSnapshot.scanLogEntries, + removalMode: removalModeStore.load(), + isCleanupReady: cleanupReadiness.ready + ) + } + + /// Called when startup reconciliation finishes so Clean re-evaluates readiness. + func refreshCleanupReadiness() { + publish( + phase: snapshot.phase, + scope: snapshot.scope, + scanResult: snapshot.scanResult, + executionResult: snapshot.executionResult, + isResultStale: snapshot.isResultStale, + isResultExpired: snapshot.isResultExpired, + errorMessage: snapshot.errorMessage, + scanLogEntries: snapshot.scanLogEntries + ) } deinit { currentTask?.cancel() + scanFlushTask?.cancel() + expiryTask?.cancel() + confirmationTask?.cancel() + } + + func setScope(_ scope: DiskCleanScanScope) { + guard scope != snapshot.scope else { return } + + // When scope changes, a frozen plan no longer matches what the user sees—invalidate it; do not silently keep it (§8.4). + discardPendingPlan() + + publish( + phase: snapshot.phase == .confirming ? .scanned : snapshot.phase, + scope: scope, + scanResult: snapshot.scanResult, + executionResult: snapshot.executionResult, + isResultStale: isStale(scanResult: snapshot.scanResult, scope: scope), + isResultExpired: snapshot.isResultExpired, + errorMessage: snapshot.errorMessage, + scanLogEntries: snapshot.scanLogEntries + ) } + /// Silently ignore under non-rules scopes: groups only exist for the rules segment; faking a `.rules` scope for them + /// would replace the whole segment's scan scope. func setChoice(_ choice: DiskCleanChoice, isSelected: Bool) { - var nextChoices = snapshot.selectedChoices + guard case var .rules(nextChoices) = snapshot.scope else { return } if isSelected { nextChoices.insert(choice) } else { nextChoices.remove(choice) } + setScope(.rules(choices: nextChoices)) + } - snapshot = DiskCleanControllerSnapshot( - phase: snapshot.phase, - selectedChoices: nextChoices, + func setCandidateSelected(_ candidateID: DiskCleanCandidate.ID, isSelected: Bool) { + guard let index = liveCandidateIndexByID[candidateID] else { return } + // Unselectable candidates are rejected by the model itself—UI disable is only a hint; the real gate is here. + guard selection.setCandidate(liveCandidates[index], isSelected: isSelected) else { return } + publishSelectionChange() + } + + func setCategorySelection(_ category: DiskCleanCategoryID, isSelected: Bool) { + selection.setCategory(category, isSelected: isSelected) + publishSelectionChange() + } + + /// Shared follow-up after selection changes: invalidate any frozen plan and republish the snapshot. + /// + /// A frozen plan matches the selection the user saw at confirm time; once selection changes it no longer matches any user intent (§8.4). + private func publishSelectionChange() { + let wasConfirming = snapshot.phase == .confirming + discardPendingPlan() + publish( + phase: wasConfirming ? .scanned : snapshot.phase, + scope: snapshot.scope, scanResult: snapshot.scanResult, executionResult: snapshot.executionResult, - isResultStale: isStale(scanResult: snapshot.scanResult, selectedChoices: nextChoices), + isResultStale: snapshot.isResultStale, + isResultExpired: snapshot.isResultExpired, + errorMessage: snapshot.errorMessage, + scanLogEntries: snapshot.scanLogEntries + ) + } + + func setRemovalMode(_ mode: DiskCleanRemovalMode) { + guard mode != removalMode else { return } + removalMode = mode + removalModeStore.save(mode) + + // Removal mode is one of the fields frozen into the plan; changing it must invalidate a pending plan (§8.4). + let wasConfirming = snapshot.phase == .confirming + discardPendingPlan() + publish( + phase: wasConfirming ? .scanned : snapshot.phase, + scope: snapshot.scope, + scanResult: snapshot.scanResult, + executionResult: snapshot.executionResult, + isResultStale: snapshot.isResultStale, + isResultExpired: snapshot.isResultExpired, errorMessage: snapshot.errorMessage, scanLogEntries: snapshot.scanLogEntries ) @@ -159,110 +362,235 @@ final class DiskCleanController: ObservableObject, DiskCleanControlling { func scan() { guard snapshot.canScan else { return } + // Rescans after expiry must bypass the cache, or "expired → rescan → hit old cache → still expired" loops forever (design §4.3). + let forceRefresh = snapshot.isResultExpired cancelTaskOnly() + discardPendingPlan() - let selectedChoices = snapshot.selectedChoices + let scope = snapshot.scope let operationID = UUID() currentOperationID = operationID nextLogEntryID = 1 - let scanLogBuffer = DiskCleanScanLogBuffer() - let initialLogEntries = [ - makeLogEntry( - DiskCleanScanLogMessage( - text: localization.format( - "scanLog.started", - defaultValue: "开始扫描:%@", - selectedChoiceTitleList(selectedChoices) - ), - tone: .info - ) - ) - ] - snapshot = DiskCleanControllerSnapshot( + liveCandidates = [] + liveCandidateIndexByID = [:] + pendingLogMessages = [] + needsScanFlush = false + // Candidate IDs are stable across scans; without reset, previous checks would silently carry into new results. + selection.reset() + + publish( phase: .scanning, - selectedChoices: selectedChoices, + scope: scope, scanResult: nil, executionResult: nil, isResultStale: false, + isResultExpired: false, errorMessage: nil, - scanLogEntries: initialLogEntries + scanLogEntries: [ + makeLogEntry( + DiskCleanScanLogMessage( + text: localization.format( + "scanLog.started", + defaultValue: "开始扫描:%@", + scopeDescription(scope) + ), + tone: .info + ) + ) + ] ) - startScanLogFlushLoop(operationID: operationID, buffer: scanLogBuffer) + startScanFlushLoop(operationID: operationID) + let stream = engine.scan(scope: scope, forceRefresh: forceRefresh) currentTask = Task { @MainActor [weak self] in guard let self else { return } do { - let result = try await scanner.scan(choices: selectedChoices) { message in - await scanLogBuffer.append(message) + for try await event in stream { + guard isCurrentOperation(operationID) else { return } + handle(event, operationID: operationID, scope: scope) } guard isCurrentOperation(operationID) else { return } - await flushScanLogEntriesIfCurrent(operationID, buffer: scanLogBuffer) - snapshot = DiskCleanControllerSnapshot( - phase: .scanned, - selectedChoices: selectedChoices, - scanResult: result, - executionResult: nil, - isResultStale: false, - errorMessage: nil, - scanLogEntries: snapshot.scanLogEntries - ) finishOperation(operationID) } catch is CancellationError { guard isCurrentOperation(operationID) else { return } - await flushScanLogEntriesIfCurrent(operationID, buffer: scanLogBuffer) - snapshot = DiskCleanControllerSnapshot( - phase: .idle, - selectedChoices: selectedChoices, - scanResult: nil, - executionResult: nil, - isResultStale: false, - errorMessage: nil, - scanLogEntries: snapshot.scanLogEntries - ) + publishCancelledScan(scope: scope) finishOperation(operationID) } catch { guard isCurrentOperation(operationID) else { return } - await flushScanLogEntriesIfCurrent(operationID, buffer: scanLogBuffer) - snapshot = DiskCleanControllerSnapshot( - phase: .idle, - selectedChoices: selectedChoices, - scanResult: nil, - executionResult: nil, - isResultStale: false, - errorMessage: Self.userFacingMessage(for: error), - scanLogEntries: scanLogEntries( - adding: [ - DiskCleanScanLogMessage( - text: localization.format( - "scanLog.failed", - defaultValue: "扫描失败:%@", - Self.userFacingMessage(for: error) - ), - tone: .error - ) - ], - to: snapshot.scanLogEntries - ) - ) + publishFailedScan(scope: scope, error: error) finishOperation(operationID) } } } - func cleanSelected(candidateIDs: Set) { + /// Cleanup entry. Cast plan → Trash executes immediately / permanent goes to confirmation (design §8.4). + /// + /// The cleanup set comes straight from the authoritative selection model: menu bar and detail page do not each hold their own idea of "what to clean". + func clean() { guard snapshot.canClean, let scanResult = snapshot.scanResult else { return } + // The Planner's only input is the scan artifact. No artifact (interrupted scan, stale projection) means no plan and no deletion. + guard let artifact = scanResult.artifact else { return } + + // First line of the §3.1 invariant: candidates with unresolved or partial size must **never** enter the cleanup set. + // The selection model already refuses to check them; this intersection is a backstop against projection/candidate drift. + // (Planner and executor each have another gate.) + let cleanableIDs = Set(scanResult.cleanableCandidates.map(\.id)) + let selectedIDs = cleanableIDs.intersection(snapshot.selection.selectedIDs) + guard !selectedIDs.isEmpty else { return } + + let plan: DiskCleanValidatedPlan + do { + plan = try DiskCleanPlanner.makePlan( + artifact: artifact, + selectedIDs: selectedIDs, + mode: removalMode, + now: clock.now, + catalog: catalog + ) + } catch { + // Cast failure = zero deletions. Report the reason honestly; do not degrade it into a partial cleanup. + publish( + phase: .scanned, + scope: snapshot.scope, + scanResult: scanResult, + executionResult: nil, + isResultStale: snapshot.isResultStale, + isResultExpired: isExpired(scanResult), + errorMessage: Self.userFacingMessage(for: error), + scanLogEntries: snapshot.scanLogEntries + ) + return + } + + switch plan.mode { + case .trash: + // The freeze primitive is recoverable, so Trash runs in one step with no second confirmation. + startExecution(plan: plan, scanResult: scanResult) + case .permanent: + enterConfirming(plan: plan, scanResult: scanResult) + } + } + + func confirmPendingClean() { + guard snapshot.phase == .confirming, + let plan = pendingPlan, + let scanResult = snapshot.scanResult else { return } + startExecution(plan: plan, scanResult: scanResult) + } + + func cancelPendingClean() { + guard snapshot.phase == .confirming else { return } + invalidatePendingPlan(errorMessage: nil) + } + func cancelCurrentOperation() { + let phase = snapshot.phase + let scope = snapshot.scope + let scanLogEntries = snapshot.scanLogEntries + + switch phase { + case .confirming: + invalidatePendingPlan(errorMessage: nil) + case .scanning: + cancelTaskOnly() + publish( + phase: .idle, + scope: scope, + scanResult: nil, + executionResult: nil, + isResultStale: false, + isResultExpired: false, + errorMessage: nil, + scanLogEntries: scanLogEntries + [ + makeLogEntry( + DiskCleanScanLogMessage( + text: localization.string("scanLog.stopped", defaultValue: "扫描已停止"), + tone: .warning + ) + ) + ] + ) + case .cleaning: + // Request cancel only. Keep `currentTask` / `currentOperationID` so the + // execution task can still publish `.scanned` / `.completed` when the current + // item finishes. Clearing the operation ID here would leave the UI stuck on + // `.cleaning` forever after the task returns. + currentTask?.cancel() + case .idle, .scanned, .completed: + cancelTaskOnly() + } + } + + // MARK: - Confirmation and execution + + private func enterConfirming(plan: DiskCleanValidatedPlan, scanResult: DiskCleanScanResult) { + pendingPlan = plan + publish( + phase: .confirming, + scope: snapshot.scope, + scanResult: scanResult, + executionResult: nil, + isResultStale: snapshot.isResultStale, + isResultExpired: false, + errorMessage: nil, + scanLogEntries: snapshot.scanLogEntries + ) + + // The confirmation window must not cross the expiry moment: cast at 299s + 60s confirm would land past the gate. + let deadline = min(clock.now.addingTimeInterval(Self.confirmationWindow), plan.expiryDeadline) + confirmationTask?.cancel() + confirmationTask = Task { @MainActor [weak self] in + guard let self else { return } + do { + try await clock.sleep(until: deadline) + } catch { + return + } + guard !Task.isCancelled, snapshot.phase == .confirming else { return } + invalidatePendingPlan( + errorMessage: localization.string( + "controller.error.confirmationExpired", + defaultValue: "确认已超时,请重新发起清理" + ) + ) + } + } + + /// Invalidate the pending plan and return to `scanned`. + private func invalidatePendingPlan(errorMessage: String?) { + discardPendingPlan() + publish( + phase: .scanned, + scope: snapshot.scope, + scanResult: snapshot.scanResult, + executionResult: nil, + isResultStale: snapshot.isResultStale, + isResultExpired: snapshot.scanResult.map(isExpired) ?? false, + errorMessage: errorMessage, + scanLogEntries: snapshot.scanLogEntries + ) + } + + private func discardPendingPlan() { + pendingPlan = nil + confirmationTask?.cancel() + confirmationTask = nil + } + + private func startExecution(plan: DiskCleanValidatedPlan, scanResult: DiskCleanScanResult) { cancelTaskOnly() + discardPendingPlan() - let selectedChoices = snapshot.selectedChoices + let scope = snapshot.scope let operationID = UUID() currentOperationID = operationID - snapshot = DiskCleanControllerSnapshot( + publish( phase: .cleaning, - selectedChoices: selectedChoices, + scope: scope, scanResult: scanResult, executionResult: nil, isResultStale: false, + isResultExpired: false, errorMessage: nil, scanLogEntries: snapshot.scanLogEntries ) @@ -270,155 +598,316 @@ final class DiskCleanController: ObservableObject, DiskCleanControlling { currentTask = Task { @MainActor [weak self] in guard let self else { return } do { - let executionResult = try await executor.clean( - candidates: scanResult.candidates, - selectedCandidateIDs: candidateIDs - ) + let executionResult = try await executor.execute(plan: plan) guard isCurrentOperation(operationID) else { return } - snapshot = DiskCleanControllerSnapshot( + publishCleaning( phase: .completed, - selectedChoices: selectedChoices, + scope: scope, scanResult: scanResult, executionResult: executionResult, - isResultStale: false, - errorMessage: nil, - scanLogEntries: snapshot.scanLogEntries + errorMessage: nil ) finishOperation(operationID) } catch is CancellationError { guard isCurrentOperation(operationID) else { return } - snapshot = DiskCleanControllerSnapshot( + publishCleaning( phase: .scanned, - selectedChoices: selectedChoices, + scope: scope, scanResult: scanResult, executionResult: nil, - isResultStale: false, - errorMessage: nil, - scanLogEntries: snapshot.scanLogEntries + errorMessage: nil ) finishOperation(operationID) } catch { + // Preflight failure means zero deletions; report the reason honestly (§7.1). guard isCurrentOperation(operationID) else { return } - snapshot = DiskCleanControllerSnapshot( + publishCleaning( phase: .scanned, - selectedChoices: selectedChoices, + scope: scope, scanResult: scanResult, executionResult: nil, - isResultStale: false, - errorMessage: Self.userFacingMessage(for: error), - scanLogEntries: snapshot.scanLogEntries + errorMessage: Self.userFacingMessage(for: error) ) finishOperation(operationID) } } } - func cancelCurrentOperation() { - let phase = snapshot.phase - let selectedChoices = snapshot.selectedChoices - let scanResult = snapshot.scanResult - let isResultStale = snapshot.isResultStale - let scanLogEntries = snapshot.scanLogEntries + // MARK: - Event consumption - cancelTaskOnly() - - switch phase { - case .scanning: - snapshot = DiskCleanControllerSnapshot( - phase: .idle, - selectedChoices: selectedChoices, - scanResult: nil, - executionResult: nil, - isResultStale: false, - errorMessage: nil, - scanLogEntries: scanLogEntries + [ - makeLogEntry( - DiskCleanScanLogMessage( - text: localization.string("scanLog.stopped", defaultValue: "扫描已停止"), - tone: .warning - ) - ) - ] - ) - case .cleaning: - snapshot = DiskCleanControllerSnapshot( - phase: .scanned, - selectedChoices: selectedChoices, - scanResult: scanResult, - executionResult: nil, - isResultStale: isResultStale, - errorMessage: nil, - scanLogEntries: scanLogEntries - ) - case .idle, .scanned, .completed: + private func handle( + _ event: DiskCleanScanEvent, + operationID: UUID, + scope: DiskCleanScanScope + ) { + switch event { + case let .log(message): + pendingLogMessages.append(message) + needsScanFlush = true + + case let .candidateFound(candidate): + liveCandidateIndexByID[candidate.id] = liveCandidates.count + liveCandidates.append(candidate) + needsScanFlush = true + + case let .candidateSized(candidateID, result): + guard let index = liveCandidateIndexByID[candidateID] else { return } + liveCandidates[index] = liveCandidates[index].applying(result) + needsScanFlush = true + + case .categoryFinished: + // Do not consume: per-item "calculating" badges already explain progress; adding a category-level spinner + // would only restate the same thing. Keep the event for P2 segments (§10) that finish per category. break - } - } - private func cancelTaskOnly() { - currentTask?.cancel() - currentTask = nil - currentOperationID = nil - scanLogFlushTask?.cancel() - scanLogFlushTask = nil - } - - private func finishOperation(_ operationID: UUID) { - guard isCurrentOperation(operationID) else { return } - currentTask = nil - currentOperationID = nil - scanLogFlushTask?.cancel() - scanLogFlushTask = nil + case let .finished(summary): + publishFinishedScan(summary: summary, scope: scope) + } } - private func startScanLogFlushLoop(operationID: UUID, buffer: DiskCleanScanLogBuffer) { - scanLogFlushTask?.cancel() - scanLogFlushTask = Task { [weak self] in + private func startScanFlushLoop(operationID: UUID) { + scanFlushTask?.cancel() + scanFlushTask = Task { @MainActor [weak self] in while !Task.isCancelled { do { - try await Task.sleep(nanoseconds: Self.scanLogFlushIntervalNanoseconds) + try await Task.sleep(nanoseconds: Self.scanFlushIntervalNanoseconds) } catch { - break + return } - await self?.flushScanLogEntriesIfCurrent(operationID, buffer: buffer) + guard let self, isCurrentOperation(operationID) else { return } + flushScanUpdates(operationID: operationID) } } } - private func flushScanLogEntriesIfCurrent( - _ operationID: UUID, - buffer: DiskCleanScanLogBuffer - ) async { - let messages = await buffer.drain() - guard isCurrentOperation(operationID) else { return } - appendScanLogs(messages) + private func flushScanUpdates(operationID: UUID) { + guard needsScanFlush, isCurrentOperation(operationID), snapshot.phase == .scanning else { return } + needsScanFlush = false + + publish( + phase: .scanning, + scope: snapshot.scope, + scanResult: DiskCleanScanResult( + scope: snapshot.scope, + candidates: liveCandidates, + scannedAt: clock.now + ), + executionResult: nil, + isResultStale: false, + isResultExpired: false, + errorMessage: nil, + scanLogEntries: scanLogEntries(adding: drainPendingLogMessages(), to: snapshot.scanLogEntries) + ) + } + + private func publishFinishedScan(summary: DiskCleanScanSummary, scope: DiskCleanScanScope) { + needsScanFlush = false + let artifact = summary.artifact + liveCandidates = artifact.candidates + liveCandidateIndexByID = Dictionary( + artifact.candidates.enumerated().map { ($0.element.id, $0.offset) }, + uniquingKeysWith: { first, _ in first } + ) + + let scanResult = DiskCleanScanResult( + scope: scope, + candidates: artifact.candidates, + scannedAt: artifact.finishedAt, + limitations: artifact.limitations, + artifact: artifact + ) + publish( + phase: .scanned, + scope: snapshot.scope, + scanResult: scanResult, + executionResult: nil, + isResultStale: isStale(scanResult: scanResult, scope: snapshot.scope), + isResultExpired: isExpired(scanResult), + errorMessage: nil, + scanLogEntries: scanLogEntries(adding: drainPendingLogMessages(), to: snapshot.scanLogEntries) + ) + scheduleExpiry(for: scanResult) + } + + private func publishCancelledScan(scope: DiskCleanScanScope) { + publish( + phase: .idle, + scope: scope, + scanResult: nil, + executionResult: nil, + isResultStale: false, + isResultExpired: false, + errorMessage: nil, + scanLogEntries: scanLogEntries(adding: drainPendingLogMessages(), to: snapshot.scanLogEntries) + ) + } + + private func publishFailedScan(scope: DiskCleanScanScope, error: Error) { + let message = Self.userFacingMessage(for: error) + publish( + phase: .idle, + scope: scope, + scanResult: nil, + executionResult: nil, + isResultStale: false, + isResultExpired: false, + errorMessage: message, + scanLogEntries: scanLogEntries( + adding: drainPendingLogMessages() + [ + DiskCleanScanLogMessage( + text: localization.format("scanLog.failed", defaultValue: "扫描失败:%@", message), + tone: .error + ) + ], + to: snapshot.scanLogEntries + ) + ) } - private func appendScanLogs(_ messages: [DiskCleanScanLogMessage]) { - guard !messages.isEmpty else { return } + private func publishCleaning( + phase: DiskCleanControllerPhase, + scope: DiskCleanScanScope, + scanResult: DiskCleanScanResult, + executionResult: DiskCleanExecutionResult?, + errorMessage: String? + ) { + publish( + phase: phase, + scope: scope, + scanResult: scanResult, + executionResult: executionResult, + isResultStale: false, + isResultExpired: phase == .scanned ? isExpired(scanResult) : false, + errorMessage: errorMessage, + scanLogEntries: snapshot.scanLogEntries + ) + // Returning to scanned means the result can be used again, so the expiry clock must be re-armed. + if phase == .scanned { + scheduleExpiry(for: scanResult) + } + } + // MARK: - Snapshot publish + + /// Single exit for snapshots. `removalMode`, `pendingPlan`, and `selection` always derive from private state. + /// + /// The selection projection is computed here (not passed in by callers), so it always matches this publish's candidate set— + /// no branch can publish new candidates while carrying an old selection. + private func publish( + phase: DiskCleanControllerPhase, + scope: DiskCleanScanScope, + scanResult: DiskCleanScanResult?, + executionResult: DiskCleanExecutionResult?, + isResultStale: Bool, + isResultExpired: Bool, + errorMessage: String?, + scanLogEntries: [DiskCleanScanLogEntry] + ) { snapshot = DiskCleanControllerSnapshot( - phase: snapshot.phase, - selectedChoices: snapshot.selectedChoices, + phase: phase, + scope: scope, + scanResult: scanResult, + executionResult: executionResult, + isResultStale: isResultStale, + isResultExpired: isResultExpired, + errorMessage: errorMessage, + scanLogEntries: scanLogEntries, + removalMode: removalMode, + pendingPlan: pendingPlan.map { + DiskCleanPendingPlanSummary( + itemCount: $0.itemCount, + totalEstimatedBytes: $0.totalEstimatedBytes, + mode: $0.mode + ) + }, + selection: selection.projection(for: scanResult?.candidates ?? []), + isCleanupReady: cleanupReadiness.ready + ) + } + + // MARK: - Expiry gate + + /// Time-driven expiry transition (design §4.4): fire `onStateChange` when the threshold hits, + /// so menu-bar buttons and the detail page grey out together without waiting for a user action. + private func scheduleExpiry(for scanResult: DiskCleanScanResult) { + expiryTask?.cancel() + expiryTask = nil + + guard let deadline = scanResult.expiryDeadline, clock.now < deadline else { return } + + expiryTask = Task { @MainActor [weak self] in + guard let self else { return } + do { + try await clock.sleep(until: deadline) + } catch { + return + } + guard !Task.isCancelled else { return } + markResultExpired() + } + } + + private func markResultExpired() { + // The confirm window never crosses expiry, but if both fire together order is undefined: if expiry wins, invalidate the plan here. + if snapshot.phase == .confirming { + discardPendingPlan() + } + guard snapshot.phase == .scanned || snapshot.phase == .confirming, !snapshot.isResultExpired else { return } + publish( + phase: .scanned, + scope: snapshot.scope, scanResult: snapshot.scanResult, executionResult: snapshot.executionResult, isResultStale: snapshot.isResultStale, + isResultExpired: true, errorMessage: snapshot.errorMessage, - scanLogEntries: scanLogEntries(adding: messages, to: snapshot.scanLogEntries) + scanLogEntries: snapshot.scanLogEntries ) } + private func isExpired(_ scanResult: DiskCleanScanResult) -> Bool { + guard let deadline = scanResult.expiryDeadline else { return false } + return clock.now >= deadline + } + + // MARK: - Teardown and helpers + + private func cancelTaskOnly() { + currentTask?.cancel() + currentTask = nil + currentOperationID = nil + scanFlushTask?.cancel() + scanFlushTask = nil + expiryTask?.cancel() + expiryTask = nil + } + + private func finishOperation(_ operationID: UUID) { + guard isCurrentOperation(operationID) else { return } + currentTask = nil + currentOperationID = nil + scanFlushTask?.cancel() + scanFlushTask = nil + } + + private func drainPendingLogMessages() -> [DiskCleanScanLogMessage] { + defer { pendingLogMessages.removeAll(keepingCapacity: true) } + return pendingLogMessages + } + private func scanLogEntries( adding messages: [DiskCleanScanLogMessage], to existingEntries: [DiskCleanScanLogEntry] ) -> [DiskCleanScanLogEntry] { + guard !messages.isEmpty else { return existingEntries } + var entries = existingEntries - entries.reserveCapacity(min(existingEntries.count + messages.count, 500)) + entries.reserveCapacity(min(existingEntries.count + messages.count, Self.maximumLogEntries)) for message in messages { entries.append(makeLogEntry(message)) } - if entries.count > 500 { - entries.removeFirst(entries.count - 500) + if entries.count > Self.maximumLogEntries { + entries.removeFirst(entries.count - Self.maximumLogEntries) } return entries } @@ -432,23 +921,34 @@ final class DiskCleanController: ObservableObject, DiskCleanControlling { ) } - private func selectedChoiceTitleList(_ choices: Set) -> String { - DiskCleanChoice.allCases - .filter { choices.contains($0) } - .map { $0.title(localization: localization) } - .joined(separator: localization.string("list.separator", defaultValue: "、")) + /// Scope description for the scan-started log line. + private func scopeDescription(_ scope: DiskCleanScanScope) -> String { + let separator = localization.string("list.separator", defaultValue: "、") + switch scope { + case let .rules(choices): + return DiskCleanChoice.allCases + .filter { choices.contains($0) } + .map { $0.title(localization: localization) } + .joined(separator: separator) + case let .developerArtifacts(roots): + return roots.joined(separator: separator) + case .installers: + return DiskCleanCategoryID.installers.title(localization: localization) + } } private func isCurrentOperation(_ operationID: UUID) -> Bool { currentOperationID == operationID } + /// Result no longer matches the current scope. The purge segment therefore also prompts "please rescan" after roots change— + /// same idea as changing groups on the rules segment. private func isStale( scanResult: DiskCleanScanResult?, - selectedChoices: Set + scope: DiskCleanScanScope ) -> Bool { guard let scanResult else { return false } - return scanResult.choices != selectedChoices + return scanResult.scope != scope } private static func userFacingMessage(for error: Error) -> String { @@ -459,16 +959,3 @@ final class DiskCleanController: ObservableObject, DiskCleanControlling { return error.localizedDescription } } - -private actor DiskCleanScanLogBuffer { - private var messages: [DiskCleanScanLogMessage] = [] - - func append(_ message: DiskCleanScanLogMessage) { - messages.append(message) - } - - func drain() -> [DiskCleanScanLogMessage] { - defer { messages.removeAll(keepingCapacity: true) } - return messages - } -} diff --git a/Plugins/DiskClean/Sources/DiskCleanDetailView.swift b/Plugins/DiskClean/Sources/DiskCleanDetailView.swift index 7098f6eb..8c274f7e 100644 --- a/Plugins/DiskClean/Sources/DiskCleanDetailView.swift +++ b/Plugins/DiskClean/Sources/DiskCleanDetailView.swift @@ -1,22 +1,53 @@ import SwiftUI import MacToolsPluginKit +/// Detail view (design §8.3). +/// +/// Top-to-bottom layout: FDA status card → limited-scan banner → scan scope & actions → category cards → execution result → +/// purge segment → leftover installers segment → settings (removal mode, cleanup history) → scan log. +/// Everything uses `PluginSettingsTheme`; visual baseline is `FanControlPresetManagerView`. +/// +/// The view holds no business state: checks, removal mode, and confirm all command the Controller; only the snapshot is read back. +/// The only local state is "which cards are expanded" and the history section's load cache—purely presentational. +/// +/// Each of the three segments has its own `DiskCleanController` with distinct scope and non-interfering state, but they **share the same type +/// and the same pipeline** (design §10): P2 candidates still go through sizing, completeness, Planner cast, and execution primitives— +/// there is no second deletion path. struct DiskCleanDetailView: View { @ObservedObject var controller: DiskCleanController + /// Purge segment. nil means the host did not wire it (e.g. tests that only care about the rules segment). + @ObservedObject private var developerArtifactsController: DiskCleanController + @ObservedObject private var installersController: DiskCleanController + @ObservedObject private var purgeRoots: DiskCleanPurgeRootsModel private let localization: PluginLocalization + private let historyProvider: (any DiskCleanCleanupHistoryProviding)? + private let fullDiskAccess: any DiskCleanFullDiskAccessProbing private let showsHeader: Bool private let contentPadding: CGFloat private let minimumContentHeight: CGFloat + @State private var expandedCategories: Set = [] + @State private var isScanLogExpanded = false + init( controller: DiskCleanController, + developerArtifactsController: DiskCleanController, + installersController: DiskCleanController, + purgeRoots: DiskCleanPurgeRootsModel, localization: PluginLocalization = PluginLocalization(bundle: .main), + historyProvider: (any DiskCleanCleanupHistoryProviding)? = nil, + fullDiskAccess: any DiskCleanFullDiskAccessProbing = DiskCleanFullDiskAccessProbe.shared, showsHeader: Bool = true, contentPadding: CGFloat = 20, minimumContentHeight: CGFloat = 420 ) { self.controller = controller + self.developerArtifactsController = developerArtifactsController + self.installersController = installersController + self.purgeRoots = purgeRoots self.localization = localization + self.historyProvider = historyProvider + self.fullDiskAccess = fullDiskAccess self.showsHeader = showsHeader self.contentPadding = contentPadding self.minimumContentHeight = minimumContentHeight @@ -27,125 +58,466 @@ struct DiskCleanDetailView: View { if showsHeader { header } - choiceControls - actionBar - scanLog - statusSummary - candidateList + fullDiskAccessCard + limitationsBanner + errorBanner + scopeSection + categorySection + executionResultSection + developerArtifactsSection + installersSection + settingsSection + scanLogSection } .padding(contentPadding) .frame(maxWidth: .infinity, minHeight: minimumContentHeight, alignment: .topLeading) + .confirmationDialog( + confirmationTitle, + isPresented: confirmationBinding, + titleVisibility: .visible + ) { + Button(role: .destructive) { + controller.confirmPendingClean() + } label: { + Text(localization.string("detail.confirm.confirm", defaultValue: "永久删除")) + } + Button(role: .cancel) { + controller.cancelPendingClean() + } label: { + Text(localization.string("detail.action.cancelClean", defaultValue: "取消")) + } + } message: { + Text( + localization.string( + "detail.confirm.message", + defaultValue: "永久删除不进废纸篓,无法恢复。" + ) + ) + } } private var snapshot: DiskCleanControllerSnapshot { controller.snapshot } + // MARK: - Page header + private var header: some View { - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.rowTitleDescription) { Text(localization.string("detail.title", defaultValue: "磁盘清理")) .font(PluginSettingsTheme.Typography.pageTitle) - - Text(snapshot.errorMessage ?? snapshot.subtitle(localization: localization)) - .font(PluginSettingsTheme.Typography.pageDescription.weight(.medium)) - .foregroundStyle(snapshot.errorMessage == nil ? Color.secondary : Color.red) + Text(snapshot.subtitle(localization: localization)) + .font(PluginSettingsTheme.Typography.pageDescription) + .foregroundStyle(.secondary) } } - private var choiceControls: some View { - HStack(spacing: 16) { - ForEach(DiskCleanChoice.allCases) { choice in - Toggle( - choice.title(localization: localization), - isOn: Binding( - get: { snapshot.selectedChoices.contains(choice) }, - set: { controller.setChoice(choice, isSelected: $0) } + // MARK: - Banners + + /// FDA status card (design §8.3 item 1, §9). + /// + /// Shown only when unauthorized, and **independent of whether a scan has run**—authorization is a pre-scan prerequisite; + /// explaining after the user has already scanned and missed a pile of system caches is too late. + /// + /// The footnote says "quit and reopen" rather than "takes effect later": FDA is bound at process launch and cannot be changed mid-run, + /// and the probe result is therefore cached for the process lifetime. + @ViewBuilder + private var fullDiskAccessCard: some View { + if !fullDiskAccess.hasFullDiskAccess { + DiskCleanBanner( + symbolName: "lock.shield", + tint: .orange, + title: localization.string( + "detail.fda.title", + defaultValue: "完全磁盘访问未开启——部分系统缓存将被跳过" + ), + lines: [ + localization.string( + "detail.fda.footnote", + defaultValue: "授权后请退出并重新打开 MacTools。" ) + ] + ) { + Button { + DiskCleanFullDiskAccessGuide.openSettings() + } label: { + Text(localization.string("detail.fda.openSettings", defaultValue: "前往授权")) + .font(PluginSettingsTheme.Typography.controlLabel) + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + } + + @ViewBuilder + private var limitationsBanner: some View { + let limitations = snapshot.scanResult?.limitations ?? [] + if !limitations.isEmpty { + DiskCleanBanner( + symbolName: "exclamationmark.triangle.fill", + tint: .orange, + title: localization.string("detail.limited.title", defaultValue: "本次扫描不完整"), + lines: limitations.map { DiskCleanFormat.limitation($0, localization: localization) } + ) + } + } + + @ViewBuilder + private var errorBanner: some View { + if let errorMessage = snapshot.errorMessage { + DiskCleanBanner( + symbolName: "xmark.octagon.fill", + tint: .red, + title: localization.string("detail.error.title", defaultValue: "操作未完成"), + lines: [errorMessage] + ) + } + } + + // MARK: - Scan scope and actions + + private var scopeSection: some View { + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.sectionHeaderContent) { + DiskCleanSectionHeader( + title: localization.string("detail.scope.title", defaultValue: "扫描范围"), + symbolName: "line.3.horizontal.decrease.circle" + ) + + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.rowContentControl) { + HStack(spacing: PluginSettingsTheme.Spacing.rowHorizontal) { + ForEach(DiskCleanChoice.allCases) { choice in + Toggle( + choice.title(localization: localization), + isOn: Binding( + get: { snapshot.selectedChoices.contains(choice) }, + set: { controller.setChoice(choice, isSelected: $0) } + ) + ) + .toggleStyle(.checkbox) + .disabled(snapshot.isBusy) + } + Spacer(minLength: 0) + } + + DiskCleanActionBar( + snapshot: snapshot, + localization: localization, + onScan: { controller.scan() }, + onClean: { controller.clean() }, + onCancel: { controller.cancelCurrentOperation() } ) - .toggleStyle(.checkbox) - .disabled(snapshot.isBusy) } + .pluginSettingsListRowPadding(interactive: true) + .pluginSettingsCardBackground(.plugin) + } + } + + // MARK: - Category cards + + @ViewBuilder + private var categorySection: some View { + let groups = DiskCleanCategoryGroup.groups( + candidates: snapshot.scanResult?.candidates ?? [], + selection: snapshot.selection + ) + + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.sectionHeaderContent) { + DiskCleanSectionHeader( + title: localization.string("detail.categories.title", defaultValue: "清理项目"), + symbolName: "square.stack.3d.up" + ) - Spacer() + if groups.isEmpty { + DiskCleanEmptyState( + symbolName: "internaldrive", + text: snapshot.scanResult == nil + ? localization.string("detail.categories.idle", defaultValue: "点击「扫描」查看可清理内容") + : localization.string("detail.candidates.empty", defaultValue: "没有发现可清理项目") + ) + } else { + DiskCleanCategoryListView( + groups: groups, + selection: snapshot.selection, + outcomesByCandidateID: outcomesByCandidateID, + localization: localization, + isInteractionEnabled: !snapshot.isBusy, + onToggleCandidate: { controller.setCandidateSelected($0, isSelected: $1) }, + onToggleCategory: { controller.setCategorySelection($0, isSelected: $1) }, + expandedCategories: $expandedCategories + ) + } } } - private var actionBar: some View { - HStack(spacing: 10) { - Button { - controller.scan() - } label: { - Label(localization.string("detail.action.scan", defaultValue: "扫描"), systemImage: "magnifyingglass") + private var outcomesByCandidateID: [DiskCleanCandidate.ID: DiskCleanExecutionItemResult.Outcome] { + guard let executionResult = snapshot.executionResult else { return [:] } + return Dictionary( + executionResult.itemResults.map { ($0.candidateID, $0.outcome) }, + uniquingKeysWith: { first, _ in first } + ) + } + + // MARK: - Execution result + + @ViewBuilder + private var executionResultSection: some View { + if let executionResult = snapshot.executionResult { + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.sectionHeaderContent) { + DiskCleanSectionHeader( + title: localization.string("detail.result.title", defaultValue: "清理结果"), + symbolName: "checkmark.seal" + ) + + VStack(spacing: 0) { + // Pin attention-needed terminal statuses (design §7.5, §13-M4-6): they leave staged objects on disk; + // burying them under dozens of successes is the same as not saying it. + ForEach(executionResult.attentionResults, id: \.candidateID) { item in + attentionRow(item) + PluginSettingsListDivider() + } + summaryRow(executionResult) + } + .pluginSettingsCardBackground(.plugin) } - .disabled(!snapshot.canScan) + } + } - Button { - controller.cleanSelected(candidateIDs: cleanableCandidateIDs) - } label: { - Label(localization.string("detail.action.clean", defaultValue: "清理"), systemImage: "trash") + private func attentionRow(_ item: DiskCleanExecutionItemResult) -> some View { + HStack(alignment: .top, spacing: PluginSettingsTheme.Spacing.rowContentControl) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + .frame(width: PluginSettingsTheme.Size.rowIcon) + + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.rowTitleDescription) { + Text(DiskCleanFormat.attentionTitle(item.outcome, localization: localization)) + .font(PluginSettingsTheme.Typography.rowTitle) + + Text(item.path) + .font(PluginSettingsTheme.Typography.monospacedValue) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.enabled) + + if let stagedName = DiskCleanFormat.stagedName(of: item.outcome) { + Text( + localization.format( + "detail.result.stagedName", + defaultValue: "暂存名:%@", + stagedName + ) + ) + .font(PluginSettingsTheme.Typography.monospacedValue) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.enabled) + } + + Text(DiskCleanFormat.attentionGuidance(item.outcome, localization: localization)) + .font(PluginSettingsTheme.Typography.rowDescription) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) } - .disabled(!snapshot.canClean) - if snapshot.isBusy { - Button { - controller.cancelCurrentOperation() - } label: { - Label(localization.string("detail.action.stop", defaultValue: "停止"), systemImage: "xmark.circle") + Spacer(minLength: 0) + } + .pluginSettingsListRowPadding() + } + + private func summaryRow(_ result: DiskCleanExecutionResult) -> some View { + HStack(spacing: PluginSettingsTheme.Spacing.rowHorizontal) { + metric( + title: result.mode == .trash + ? localization.string("detail.result.trashed", defaultValue: "已移到废纸篓") + : localization.string("detail.result.removed", defaultValue: "已删除"), + value: DiskCleanFormat.itemCount(result.removedCount, localization: localization) + ) + metric( + title: localization.string("detail.result.skipped", defaultValue: "已跳过"), + value: DiskCleanFormat.itemCount(result.skippedCount, localization: localization) + ) + metric( + title: localization.string("detail.result.failed", defaultValue: "未完成"), + value: DiskCleanFormat.itemCount(result.failedCount, localization: localization) + ) + metric( + // Trash mode must not say "reclaimed": objects are still in Trash, so space is not truly free yet (design §7.7). + title: result.mode == .trash + ? localization.string("detail.result.trashedBytes", defaultValue: "已移到废纸篓") + : localization.string("detail.result.reclaimedBytes", defaultValue: "已释放"), + value: DiskCleanFormat.approximateBytes(result.reclaimedBytes, localization: localization) + ) + Spacer(minLength: 0) + } + .pluginSettingsListRowPadding() + } + + private func metric(title: String, value: String) -> some View { + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.rowTitleDescription) { + Text(title) + .font(PluginSettingsTheme.Typography.statusBadge) + .foregroundStyle(.secondary) + Text(value) + .font(PluginSettingsTheme.Typography.monospacedValue) + } + .frame(minWidth: 96, alignment: .leading) + } + + // MARK: - P2 segments + + /// Purge sweep (design §10.1). Root management sits inside the segment—"what to scan" is a prerequisite of this segment; + /// burying it under generic settings would make users hunt between two places. + private var developerArtifactsSection: some View { + DiskCleanCleanupSectionView( + controller: developerArtifactsController, + title: DiskCleanCategoryID.developerArtifacts.title(localization: localization), + symbolName: DiskCleanCategoryID.developerArtifacts.symbolName, + localization: localization + ) { + DiskCleanPurgeRootsView(model: purgeRoots, localization: localization) + } + } + + private var installersSection: some View { + DiskCleanCleanupSectionView( + controller: installersController, + title: DiskCleanCategoryID.installers.title(localization: localization), + symbolName: DiskCleanCategoryID.installers.symbolName, + localization: localization + ) + } + + // MARK: - Settings section + + private var settingsSection: some View { + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.sectionHeaderContent) { + DiskCleanSectionHeader( + title: localization.string("detail.settings.title", defaultValue: "清理设置"), + symbolName: "gearshape" + ) + + VStack(spacing: 0) { + removalModeRow + if let historyProvider { + PluginSettingsListDivider() + DiskCleanCleanupHistorySection( + provider: historyProvider, + localization: localization + ) + .pluginSettingsListRowPadding(interactive: true) } } + .pluginSettingsCardBackground(.plugin) } } - private var scanLog: some View { - VStack(alignment: .leading, spacing: 8) { - HStack { + private var removalModeRow: some View { + HStack(alignment: .top, spacing: PluginSettingsTheme.Spacing.rowContentControl) { + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.rowTitleDescription) { + Text(localization.string("detail.removalMode.title", defaultValue: "删除方式")) + .font(PluginSettingsTheme.Typography.rowTitle) + Text(removalModeDescription) + .font(PluginSettingsTheme.Typography.rowDescription) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer(minLength: PluginSettingsTheme.Spacing.rowContentControl) + + Picker( + "", + selection: Binding( + get: { snapshot.removalMode }, + set: { controller.setRemovalMode($0) } + ) + ) { + Text(localization.string("detail.removalMode.trash", defaultValue: "废纸篓")) + .tag(DiskCleanRemovalMode.trash) + Text(localization.string("detail.removalMode.permanent", defaultValue: "永久删除")) + .tag(DiskCleanRemovalMode.permanent) + } + .pickerStyle(.segmented) + .labelsHidden() + .frame(width: 180) + .disabled(snapshot.isBusy) + } + .pluginSettingsListRowPadding(interactive: true) + } + + private var removalModeDescription: String { + switch snapshot.removalMode { + case .trash: + return localization.string( + "detail.removalMode.trash.description", + defaultValue: "移到废纸篓,清空废纸篓后才真正释放空间。放回时会落在暂存名下。" + ) + case .permanent: + return localization.string( + "detail.removalMode.permanent.description", + defaultValue: "直接删除,不进废纸篓,无法恢复。执行前需要再确认一次。" + ) + } + } + + // MARK: - Scan log + + private var scanLogSection: some View { + DisclosureGroup(isExpanded: $isScanLogExpanded) { + scanLogList + .padding(.top, PluginSettingsTheme.Spacing.sectionHeaderContent) + } label: { + HStack(spacing: PluginSettingsTheme.Spacing.controlCluster) { Text(localization.string("detail.scanLog.title", defaultValue: "扫描日志")) - .font(PluginSettingsTheme.Typography.sectionTitle) - Spacer() + .font(PluginSettingsTheme.Typography.rowTitle) if snapshot.phase == .scanning { ProgressView() .controlSize(.small) - .scaleEffect(0.75) + .scaleEffect(0.7) } + Spacer(minLength: 0) } + .contentShape(Rectangle()) + } + } - ScrollViewReader { proxy in - ScrollView { - LazyVStack(alignment: .leading, spacing: 6) { - if snapshot.scanLogEntries.isEmpty { - Text(localization.string("detail.scanLog.empty", defaultValue: "点击扫描后显示实时进度")) - .font(PluginSettingsTheme.Typography.secondaryLabel) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - } else { - ForEach(snapshot.scanLogEntries) { entry in - logRow(entry) - .id(entry.id) - } + private var scanLogList: some View { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.rowTitleDescription) { + if snapshot.scanLogEntries.isEmpty { + Text(localization.string("detail.scanLog.empty", defaultValue: "点击扫描后显示实时进度")) + .font(PluginSettingsTheme.Typography.rowDescription) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } else { + ForEach(snapshot.scanLogEntries) { entry in + logRow(entry) + .id(entry.id) } } - .padding(10) - } - .frame(minHeight: 118, maxHeight: 160) - .pluginSettingsCardBackground(.recessed) - .onChange(of: snapshot.scanLogEntries.last?.id) { - guard let id = snapshot.scanLogEntries.last?.id else { return } - proxy.scrollTo(id, anchor: .bottom) } + .padding(PluginSettingsTheme.Spacing.rowVertical) + } + .frame(minHeight: 118, maxHeight: 160) + .pluginSettingsCardBackground(.recessed) + .onChange(of: snapshot.scanLogEntries.last?.id) { + guard let id = snapshot.scanLogEntries.last?.id else { return } + proxy.scrollTo(id, anchor: .bottom) } } } private func logRow(_ entry: DiskCleanScanLogEntry) -> some View { - HStack(alignment: .top, spacing: 8) { - Image(systemName: logIconName(entry.tone)) + HStack(alignment: .top, spacing: PluginSettingsTheme.Spacing.controlCluster) { + Image(systemName: DiskCleanFormat.logSymbolName(entry.tone)) .font(PluginSettingsTheme.Typography.statusBadge) - .foregroundStyle(logColor(entry.tone)) + .foregroundStyle(DiskCleanFormat.logTint(entry.tone)) .frame(width: 14, height: 14) Text(entry.text) - .font(.system(size: 11, design: .monospaced)) - .foregroundStyle(.primary) + .font(PluginSettingsTheme.Typography.monospacedValue) .textSelection(.enabled) .lineLimit(3) @@ -154,155 +526,276 @@ struct DiskCleanDetailView: View { .frame(maxWidth: .infinity, alignment: .leading) } - @ViewBuilder - private var statusSummary: some View { - if let scanResult = snapshot.scanResult { - HStack(spacing: 16) { - summaryTile( - title: localization.string("detail.summary.cleanable", defaultValue: "可清理"), - value: itemCountText(scanResult.cleanableCandidates.count) - ) - summaryTile( - title: localization.string("detail.summary.estimatedRelease", defaultValue: "预计释放"), - value: byteText(scanResult.cleanableSizeBytes) - ) - summaryTile( - title: localization.string("detail.summary.protected", defaultValue: "已保护"), - value: itemCountText(scanResult.protectedCount) - ) + // MARK: - Confirmation dialog + + private var confirmationTitle: String { + DiskCleanFormat.confirmationTitle(snapshot, localization: localization) + } + + /// Closing the dialog cancels. The confirm button first advances state to `cleaning`, so the later setter callback hits + /// the `phase == .confirming` guard in `cancelPendingClean` and will not undo the just-submitted execution. + private var confirmationBinding: Binding { + Binding( + get: { snapshot.phase == .confirming }, + set: { isPresented in + guard !isPresented else { return } + controller.cancelPendingClean() } + ) + } +} + +// MARK: - Copy and formatting + +/// Formatting shared by the detail page and the history section. +/// +/// Bytes always carry "about" (estimated logical size ≠ actual free space, design §7.7); fixed-width numeric columns avoid layout jitter on refresh. +enum DiskCleanFormat { + /// Byte-column width. Fixed width keeps "about 1.2 GB" and "about 812 KB" aligned so refreshes do not jump sideways. + static let byteColumnWidth: CGFloat = 92 + + static func bytes(_ value: Int64) -> String { + let formatter = ByteCountFormatter() + formatter.countStyle = .file + return formatter.string(fromByteCount: value) + } + + static func approximateBytes(_ value: Int64, localization: PluginLocalization) -> String { + localization.format("detail.candidate.size", defaultValue: "约 %@", bytes(value)) + } + + static func itemCount(_ count: Int, localization: PluginLocalization) -> String { + localization.format("item.count", defaultValue: "%d 项", count) + } + + static func timestamp(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateStyle = .short + formatter.timeStyle = .short + return formatter.string(from: date) + } + + // MARK: Action copy (shared by all three segments) + + /// Cleanup button copy. The button states what will happen: Trash is one-step, so the button itself is the last explanation (design §8.4). + static func cleanActionTitle( + _ snapshot: DiskCleanControllerSnapshot, + localization: PluginLocalization + ) -> String { + let count = snapshot.selection.selectedCount + guard count > 0 else { + return snapshot.removalMode == .trash + ? localization.string("detail.action.trash", defaultValue: "移到废纸篓") + : localization.string("detail.action.clean", defaultValue: "清理") } - if let executionResult = snapshot.executionResult { - HStack(spacing: 16) { - summaryTile( - title: localization.string("detail.summary.removed", defaultValue: "已删除"), - value: itemCountText(executionResult.removedCount) - ) - summaryTile( - title: localization.string("detail.summary.skipped", defaultValue: "已跳过"), - value: itemCountText(executionResult.skippedCount) - ) - summaryTile( - title: localization.string("detail.summary.failed", defaultValue: "失败"), - value: itemCountText(executionResult.failedCount) - ) - summaryTile( - title: localization.string("detail.summary.released", defaultValue: "已释放"), - value: byteText(executionResult.reclaimedBytes) - ) - } + let byteText = bytes(snapshot.selection.selectedEstimatedBytes) + switch snapshot.removalMode { + case .trash: + return localization.format( + "detail.action.trashSelected", + defaultValue: "移到废纸篓 · %d 项 · 约 %@", + count, + byteText + ) + case .permanent: + return localization.format( + "detail.action.cleanSelected", + defaultValue: "清理 · %d 项 · 约 %@", + count, + byteText + ) } } - private func summaryTile(title: String, value: String) -> some View { - VStack(alignment: .leading, spacing: 4) { - Text(title) - .font(PluginSettingsTheme.Typography.secondaryLabel) - .foregroundStyle(.secondary) - Text(value) - .font(PluginSettingsTheme.Typography.pageDescription.weight(.semibold)) + static func selectionSummary( + _ snapshot: DiskCleanControllerSnapshot, + localization: PluginLocalization + ) -> String { + guard let result = snapshot.scanResult else { + return localization.string("detail.selection.idle", defaultValue: "尚未扫描") } - .frame(minWidth: 96, alignment: .leading) + if snapshot.isResultExpired { + return localization.string( + "detail.selection.expired", + defaultValue: "结果已过期,请重新扫描" + ) + } + if snapshot.isResultStale { + return localization.string("detail.selection.stale", defaultValue: "清理范围已变化,请重新扫描") + } + return localization.format( + "detail.selection.summary", + defaultValue: "可清理 %d 项 · 已选 %d 项 · 约 %@", + result.cleanableCandidates.count, + snapshot.selection.selectedCount, + bytes(snapshot.selection.selectedEstimatedBytes) + ) } - @ViewBuilder - private var candidateList: some View { - if let scanResult = snapshot.scanResult { - ScrollView { - LazyVStack(alignment: .leading, spacing: 0) { - ForEach(scanResult.candidates) { candidate in - candidateRow(candidate) - Divider() - } - } - } - .overlay { - if scanResult.candidates.isEmpty { - Text(localization.string("detail.candidates.empty", defaultValue: "没有发现可清理项目")) - .font(PluginSettingsTheme.Typography.rowTitle) - .foregroundStyle(.secondary) - } - } - } else { - EmptyView() + static func confirmationTitle( + _ snapshot: DiskCleanControllerSnapshot, + localization: PluginLocalization + ) -> String { + guard let pending = snapshot.pendingPlan else { + return localization.string("detail.confirm.fallbackTitle", defaultValue: "确认永久清理") } + return localization.format( + "detail.confirm.title", + defaultValue: "确认永久清理 %d 项 · 约 %@", + pending.itemCount, + bytes(pending.totalEstimatedBytes) + ) } - private func candidateRow(_ candidate: DiskCleanCandidate) -> some View { - HStack(alignment: .top, spacing: 12) { - Image(systemName: candidate.safety.isCleanable ? "checkmark.circle.fill" : "shield.fill") - .foregroundStyle(candidate.safety.isCleanable ? Color.green : Color.secondary) - .frame(width: 18) - - VStack(alignment: .leading, spacing: 4) { - HStack(alignment: .firstTextBaseline) { - Text(candidate.title) - .font(PluginSettingsTheme.Typography.emphasizedRowTitle) - Spacer(minLength: 12) - Text(byteText(candidate.sizeBytes)) - .font(PluginSettingsTheme.Typography.secondaryLabel) - .foregroundStyle(.secondary) + static func partialReasons( + _ reasons: Set, + localization: PluginLocalization + ) -> String { + let ordered: [DiskCleanScanCompleteness.PartialReason] = [ + .timedOut, .permissionDenied, .unsupportedVolume, .crossedMountPoint, .walkError + ] + return ordered + .filter(reasons.contains) + .map { reason in + switch reason { + case .timedOut: + return localization.string("partial.timedOut", defaultValue: "超时") + case .permissionDenied: + return localization.string("partial.permissionDenied", defaultValue: "权限不足") + case .unsupportedVolume: + return localization.string("partial.unsupportedVolume", defaultValue: "不支持的卷") + case .crossedMountPoint: + return localization.string("partial.crossedMountPoint", defaultValue: "含挂载点") + case .walkError: + return localization.string("partial.walkError", defaultValue: "读取异常") } + } + .joined(separator: localization.string("list.separator", defaultValue: "、")) + } - Text(candidate.path) - .font(.system(size: 11, design: .monospaced)) - .foregroundStyle(.secondary) - .lineLimit(2) - - Text(safetyText(candidate.safety)) - .font(PluginSettingsTheme.Typography.secondaryLabel) - .foregroundStyle(candidate.safety.isCleanable ? Color.green : Color.secondary) + /// Per-line copy for the limited-scan banner (design §4.5). + static func limitation( + _ limitation: DiskCleanScanLimitation, + localization: PluginLocalization + ) -> String { + switch limitation { + case let .fdaRestricted(skippedTargetIDs): + return localization.format( + "limitation.fdaRestricted", + defaultValue: "未开启完全磁盘访问,已跳过 %d 项系统缓存", + skippedTargetIDs.count + ) + case let .dynamicRuleFailed(targetID, reason): + return localization.format( + "limitation.dynamicRuleFailed", + defaultValue: "动态规则 %@ 未能运行:%@", + targetID, + reason + ) + case let .targetExpansionFailed(targetID, reason): + return localization.format( + "limitation.targetExpansionFailed", + defaultValue: "规则 %@ 展开失败:%@", + targetID, + reason + ) + case let .volumeSkipped(path): + return localization.format( + "limitation.volumeSkipped", + defaultValue: "已跳过非本地卷:%@", + path + ) + case let .scanRootUnreadable(path, reason): + guard reason == .permissionDenied else { + return localization.format( + "limitation.scanRootUnreadable", + defaultValue: "无法读取 %@(%@)", + path, + partialReasons([reason], localization: localization) + ) } + return localization.format( + "limitation.scanRootDenied", + defaultValue: "没有访问 %@ 的权限,其中的内容未被扫描", + path + ) + case .walkerCircuitBroken: + return localization.string( + "limitation.walkerCircuitBroken", + defaultValue: "扫描引擎已降级,本次不再计算大小,重启应用后恢复" + ) + case let .threadsAbandoned(count): + return localization.format( + "limitation.threadsAbandoned", + defaultValue: "有 %d 个扫描线程未能按时返回", + count + ) } - .padding(.vertical, 9) - } - - private var cleanableCandidateIDs: Set { - Set(snapshot.scanResult?.cleanableCandidates.map(\.id) ?? []) - } - - private func safetyText(_ safety: DiskCleanSafetyStatus) -> String { - switch safety { - case .allowed: - return localization.string("safety.allowed", defaultValue: "允许清理") - case let .whitelisted(rule): - return localization.format("safety.whitelisted", defaultValue: "白名单保护:%@", rule) - case let .protected(reason): - return localization.format("safety.protected", defaultValue: "敏感数据保护:%@", reason) - case let .invalid(reason): - return localization.format("safety.invalid", defaultValue: "路径安全保护:%@", reason) - case let .requiresAdmin(reason): - return localization.format("safety.requiresAdmin", defaultValue: "需要管理员权限:%@", reason) - case let .inUse(processName): - return localization.format("safety.inUse", defaultValue: "正在使用:%@", processName) + } + + static func stagedName(of outcome: DiskCleanExecutionItemResult.Outcome) -> String? { + switch outcome { + case let .trashed(_, stagedName), + let .partiallyDeleted(stagedName, _), + let .rollbackBlocked(stagedName, _): + return stagedName + case .removed, .skipped, .changedSinceScan, .failed: + return nil } } - private func itemCountText(_ count: Int) -> String { - localization.format("item.count", defaultValue: "%d 项", count) + static func attentionTitle( + _ outcome: DiskCleanExecutionItemResult.Outcome, + localization: PluginLocalization + ) -> String { + switch outcome { + case .partiallyDeleted: + return localization.string("detail.result.partiallyDeleted", defaultValue: "只删除了一部分") + case .rollbackBlocked: + return localization.string("detail.result.rollbackBlocked", defaultValue: "无法放回原处") + case .removed, .trashed, .skipped, .changedSinceScan, .failed: + return localization.string("detail.result.attention", defaultValue: "需要处理") + } } - private func byteText(_ bytes: Int64) -> String { - let formatter = ByteCountFormatter() - formatter.countStyle = .file - return formatter.string(fromByteCount: bytes) + /// Guidance must say "what the disk looks like now" and "what the user can do", not only a status name. + static func attentionGuidance( + _ outcome: DiskCleanExecutionItemResult.Outcome, + localization: PluginLocalization + ) -> String { + switch outcome { + case .partiallyDeleted: + return localization.string( + "detail.result.partiallyDeleted.guidance", + defaultValue: "删除中途失败,暂存对象里只剩一部分内容,不会被放回原路径。可在访达中按暂存名找到并自行删除。" + ) + case .rollbackBlocked: + return localization.string( + "detail.result.rollbackBlocked.guidance", + defaultValue: "原路径已被重新创建,暂存对象未覆盖它。可在访达中按暂存名找到并自行处理。" + ) + case let .failed(message): + return message + case .removed, .trashed, .skipped, .changedSinceScan: + return "" + } } - private func logIconName(_ tone: DiskCleanScanLogTone) -> String { + static func logSymbolName(_ tone: DiskCleanScanLogTone) -> String { switch tone { case .info: return "circle" case .success: return "checkmark.circle.fill" case .warning: - return "shield.fill" + return "exclamationmark.triangle.fill" case .error: return "xmark.octagon.fill" } } - private func logColor(_ tone: DiskCleanScanLogTone) -> Color { + static func logTint(_ tone: DiskCleanScanLogTone) -> Color { switch tone { case .info: return .secondary diff --git a/Plugins/DiskClean/Sources/DiskCleanDirectoryTreeWalker.swift b/Plugins/DiskClean/Sources/DiskCleanDirectoryTreeWalker.swift new file mode 100644 index 00000000..5fdd25e7 --- /dev/null +++ b/Plugins/DiskClean/Sources/DiskCleanDirectoryTreeWalker.swift @@ -0,0 +1,237 @@ +import Darwin +import Foundation + +struct DiskCleanPOSIXError: Error, Equatable { + let code: Int32 +} + +/// One fully resolved directory entry. +struct DiskCleanResolvedEntry: Equatable, Sendable { + /// NUL-terminated raw name bytes, passed straight back to `openat`/`fstatat`. + let nameBytes: [CChar] + let fileType: DiskCleanRootIdentity.FileType + let devid: UInt64 + let fileID: UInt64 + let linkCount: UInt32 + /// Logical size (equivalent to `st_size`). For symlinks, the length of the link target string. + let dataLength: Int64 +} + +enum DiskCleanWalkEntry: Equatable, Sendable { + case resolved(DiskCleanResolvedEntry) + /// Attributes missing and the per-entry `fstatat` fallback also failed. + case unresolved(code: Int32) +} + +/// Directory entry source. FastWalker uses `getattrlistbulk`; SlowWalker uses `readdir` + `fstatat`; +/// tests may inject a synthetic entry stream (mount-point crossing cannot be constructed on a real FS). +protocol DiskCleanDirectoryEntrySource: AnyObject { + /// Directory fd used by `openat` for descent. + var directoryFileDescriptor: Int32 { get } + /// Return entries in batches; nil means enumeration of this directory is finished. + func nextBatch() throws -> [DiskCleanWalkEntry]? + /// Release the underlying fd / DIR. + func close() +} + +protocol DiskCleanDirectoryEntrySourceFactory: Sendable { + /// On success, ownership of `fileDescriptor` transfers to the returned source (released by `source.close()`); + /// on **throw, ownership stays with the caller**, who must close it. + func makeSource(fileDescriptor: Int32) throws -> any DiskCleanDirectoryEntrySource +} + +/// Shared sizing skeleton for both walkers: root opener typing (§3.2) + explicit-stack iterative DFS (§3.3). +/// +/// The only difference between FastWalker and SlowWalker is the **directory entry source**; every other constraint—mount protection, hard-link dedupe, +/// no symlink following, EPERM skip, cancel/deadline checks per batch, root identity—is implemented here, +/// which is how design §3.5's "fallback walker reuses all §3.3 constraints" is realized. +struct DiskCleanDirectoryTreeWalker: Sendable { + private let opener: DiskCleanRootOpener + private let sourceFactory: any DiskCleanDirectoryEntrySourceFactory + + init(opener: DiskCleanRootOpener = DiskCleanRootOpener(), sourceFactory: any DiskCleanDirectoryEntrySourceFactory) { + self.opener = opener + self.sourceFactory = sourceFactory + } + + func size(ofItemAt path: String, context: DiskCleanSizingContext) -> DiskCleanSizeResult { + let observedAt = context.now() + + switch opener.open(path: path) { + case let .failed(reason): + return .unavailable(reasons: [reason], observedAt: observedAt) + + case let .resolved(bytes, identity): + guard context.admitDevice(identity.devid) else { + return .unavailable(reasons: [.unsupportedVolume], rootIdentity: identity, observedAt: observedAt) + } + return DiskCleanSizeResult( + estimatedBytes: bytes, + fileCount: 1, + completeness: .complete, + rootIdentity: identity, + observedAt: observedAt + ) + + case let .directory(fileDescriptor, identity): + guard context.admitDevice(identity.devid) else { + close(fileDescriptor) + return .unavailable(reasons: [.unsupportedVolume], rootIdentity: identity, observedAt: observedAt) + } + return walk( + rootFileDescriptor: fileDescriptor, + identity: identity, + context: context, + observedAt: observedAt + ) + } + } + + private func walk( + rootFileDescriptor: Int32, + identity: DiskCleanRootIdentity, + context: DiskCleanSizingContext, + observedAt: Date + ) -> DiskCleanSizeResult { + var accumulator = DiskCleanCompletenessAccumulator() + var totalBytes: Int64 = 0 + var fileCount = 0 + var countedHardLinks: Set = [] + var stack: [Frame] = [] + + func makeResult() -> DiskCleanSizeResult { + DiskCleanSizeResult( + estimatedBytes: totalBytes, + fileCount: fileCount, + completeness: accumulator.completeness, + rootIdentity: identity, + observedAt: observedAt + ) + } + + do { + stack.append(Frame(source: try sourceFactory.makeSource(fileDescriptor: rootFileDescriptor))) + } catch { + close(rootFileDescriptor) + accumulator.add(.walkError) + return makeResult() + } + defer { stack.forEach { $0.source.close() } } + + while let frame = stack.last { + // Check cancel and deadline between batches (and before each descent). + if context.shouldStop { + accumulator.add(.timedOut) + break + } + + // Drain already-discovered child directories first so open fd count stays on the order of tree depth, + // not the fan-out of one directory (the latter can hit EMFILE). + if let childName = frame.pendingChildDirectories.popLast() { + descend( + into: childName, + parent: frame, + stack: &stack, + accumulator: &accumulator + ) + continue + } + + if frame.isExhausted { + frame.source.close() + stack.removeLast() + continue + } + + let batch: [DiskCleanWalkEntry]? + do { + batch = try frame.source.nextBatch() + } catch let error as DiskCleanPOSIXError { + accumulator.add(errno: error.code) + batch = nil + } catch { + accumulator.add(.walkError) + batch = nil + } + + guard let batch else { + frame.isExhausted = true + continue + } + + for entry in batch { + switch entry { + case let .unresolved(code): + accumulator.add(errno: code) + + case let .resolved(resolved): + // Mount protection: do not descend into or count cross-device entries. + guard resolved.devid == identity.devid else { + accumulator.add(.crossedMountPoint) + continue + } + + guard resolved.fileType != .directory else { + frame.pendingChildDirectories.append(resolved.nameBytes) + continue + } + + // Hard links are deduped by (devid, fileID)—fileID is not unique across mounts, so devid must be in the key. + if resolved.linkCount > 1 { + let key = HardLinkKey(devid: resolved.devid, fileID: resolved.fileID) + guard countedHardLinks.insert(key).inserted else { continue } + } + totalBytes += max(resolved.dataLength, 0) + fileCount += 1 + } + } + } + + return makeResult() + } + + private func descend( + into name: [CChar], + parent: Frame, + stack: inout [Frame], + accumulator: inout DiskCleanCompletenessAccumulator + ) { + // name is a single path component and the parent is already fd-anchored, so O_NOFOLLOW is enough; never follow symlinks. + let childDescriptor = name.withUnsafeBufferPointer { buffer -> Int32 in + guard let base = buffer.baseAddress else { return -1 } + return openat( + parent.source.directoryFileDescriptor, + base, + O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_NONBLOCK + ) + } + guard childDescriptor >= 0 else { + accumulator.add(errno: errno) + return + } + + do { + stack.append(Frame(source: try sourceFactory.makeSource(fileDescriptor: childDescriptor))) + } catch { + close(childDescriptor) + accumulator.add(.walkError) + } + } + + private struct HardLinkKey: Hashable { + let devid: UInt64 + let fileID: UInt64 + } + + /// Stack frame. `pendingChildDirectories` only holds child names discovered in the **current batch**, + /// so both memory and fd usage stay bounded. + private final class Frame { + let source: any DiskCleanDirectoryEntrySource + var pendingChildDirectories: [[CChar]] = [] + var isExhausted = false + + init(source: any DiskCleanDirectoryEntrySource) { + self.source = source + } + } +} diff --git a/Plugins/DiskClean/Sources/DiskCleanDynamicRules.swift b/Plugins/DiskClean/Sources/DiskCleanDynamicRules.swift new file mode 100644 index 00000000..70d81d56 --- /dev/null +++ b/Plugins/DiskClean/Sources/DiskCleanDynamicRules.swift @@ -0,0 +1,381 @@ +import Darwin +import Foundation +import os + +// MARK: - Protocol + +/// Dynamic rule expansion (design §5.5). +/// +/// Semantic contract: +/// - **"Target does not exist" returns an empty array** (Xcode not installed, version dir missing)—not a failure. +/// - **Real failures `throw`**: subprocess timeout, malformed output. The caller (ScanEngine) turns throws into +/// a `dynamicRuleFailed` limitation + reserved prefix and continues scanning; dynamic rules must never block the whole scan. +/// - On failure **do not return partial results**: skip the whole block (with the target's `reservedRootPaths` for ancestor protection) +/// rather than hand half-expanded candidates off as a complete result. +protocol DiskCleanDynamicRuleProviding: Sendable { + func expand() async throws -> [DiskCleanFileItem] +} + +enum DiskCleanDynamicRuleError: Error, Equatable { + /// Subprocess output is not the expected structure (malformed JSON, missing fields, truncated). + case malformedOutput(command: String) + /// Directory is visible but cannot be listed (permissions, etc.). + case directoryUnreadable(path: String) +} + +// MARK: - Subprocess execution seam + +struct DiskCleanSubprocessResult: Equatable, Sendable { + let exitCode: Int32 + let standardOutput: Data +} + +enum DiskCleanSubprocessError: Error, Equatable { + /// Executable missing or not executable (e.g. command-line tools not installed). + case executableUnavailable(path: String) + case launchFailed(path: String, message: String) + case timedOut(path: String) +} + +/// Subprocess execution seam. Tests inject fakes to cover success/malformed/timeout/missing-command cases. +protocol DiskCleanSubprocessRunning: Sendable { + func run( + executablePath: String, + arguments: [String], + timeout: Duration + ) async throws -> DiskCleanSubprocessResult +} + +/// Real implementation: pipe drain races the timeout—timeout kills the process; process exit yields EOF so the reader returns. +struct LocalDiskCleanSubprocessRunner: DiskCleanSubprocessRunning { + /// Output cap. Stop reading past it so JSON parsing fails as `malformedOutput` instead of unbounded memory use. + static let maximumOutputBytes = 8 * 1024 * 1024 + + func run( + executablePath: String, + arguments: [String], + timeout: Duration + ) async throws -> DiskCleanSubprocessResult { + guard FileManager.default.isExecutableFile(atPath: executablePath) else { + throw DiskCleanSubprocessError.executableUnavailable(path: executablePath) + } + + let process = Process() + process.executableURL = URL(fileURLWithPath: executablePath) + process.arguments = arguments + let outputPipe = Pipe() + process.standardOutput = outputPipe + process.standardError = FileHandle.nullDevice + process.standardInput = FileHandle.nullDevice + + let box = ProcessBox(process: process) + do { + try process.run() + } catch { + throw DiskCleanSubprocessError.launchFailed( + path: executablePath, + message: error.localizedDescription + ) + } + + let readDescriptor = outputPipe.fileHandleForReading.fileDescriptor + let timeoutTask = Task { + try? await Task.sleep(for: timeout) + guard !Task.isCancelled else { return } + box.terminate(markingTimeout: true) + } + + // Caller cancel also terminates the subprocess: a blocked read(2) ignores task cancel; only process exit closes the pipe. + let output = await withTaskCancellationHandler { + await Self.drain(fileDescriptor: readDescriptor) + } onCancel: { + box.terminate(markingTimeout: false) + } + + box.waitUntilExit() + timeoutTask.cancel() + try? outputPipe.fileHandleForReading.close() + + if box.didTimeOut { + throw DiskCleanSubprocessError.timedOut(path: executablePath) + } + return DiskCleanSubprocessResult(exitCode: box.terminationStatus, standardOutput: output) + } + + private static func drain(fileDescriptor: Int32) async -> Data { + await withCheckedContinuation { continuation in + DispatchQueue.global(qos: .utility).async { + var output = Data() + var buffer = [UInt8](repeating: 0, count: 64 * 1024) + while output.count < maximumOutputBytes { + let count = buffer.withUnsafeMutableBytes { + read(fileDescriptor, $0.baseAddress, $0.count) + } + if count > 0 { + output.append(contentsOf: buffer[0.. Bool { + let count = max(lhs.components.count, rhs.components.count) + for index in 0.. [DiskCleanFileItem] { + var items: [DiskCleanFileItem] = [] + for containerGlob in containerGlobs { + let containers: [DiskCleanFileItem] + do { + containers = try fileSystem.expandPathPattern(containerGlob) + } catch { + throw DiskCleanDynamicRuleError.directoryUnreadable(path: containerGlob) + } + for container in containers where container.isDirectory && !container.isSymlink { + items += try obsoleteVersions(in: container.path) + } + } + return items + } + + private func obsoleteVersions(in containerPath: String) throws -> [DiskCleanFileItem] { + let children: [DiskCleanFileItem] + do { + children = try fileSystem.expandPathPattern(containerPath + "/*") + } catch { + throw DiskCleanDynamicRuleError.directoryUnreadable(path: containerPath) + } + + let pinnedNames = Set(children.compactMap(Self.pinnedVersionName)) + var versions: [(version: DiskCleanVersionNumber, item: DiskCleanFileItem)] = [] + for child in children where child.isDirectory && !child.isSymlink { + let name = (child.path as NSString).lastPathComponent + guard !pinnedNames.contains(name), let version = DiskCleanVersionNumber(name) else { continue } + versions.append((version, child)) + } + + guard versions.count > keepNewestCount else { return [] } + return versions + .sorted { $0.version > $1.version } + .dropFirst(keepNewestCount) + .map(\.item) + } + + /// Version name pinned by a sibling symlink (`Current -> 152.0.7933.0`). Relative and absolute targets both use only the last component. + private static func pinnedVersionName(for item: DiskCleanFileItem) -> String? { + guard item.isSymlink, let target = item.resolvedSymlinkTarget else { return nil } + let name = (target as NSString).lastPathComponent + return name.isEmpty ? nil : name + } +} + +// MARK: - Unavailable simulator provider + +/// Device directories from `xcrun simctl list devices -j` with `isAvailable == false` (design §5.5). +/// +/// Device paths are always built as `devicesRootPath + udid`; never trust `dataPath`/`logPath` from JSON— +/// candidate paths must fall under this target's declared reserved roots, not wherever an external command points. +struct DiskCleanUnavailableSimulatorRuleProvider: DiskCleanDynamicRuleProviding { + let devicesRootPath: String + let executablePath: String + let timeout: Duration + let subprocessRunner: any DiskCleanSubprocessRunning + let fileSystem: DiskCleanFileSystemProviding + + init( + devicesRootPath: String = "~/Library/Developer/CoreSimulator/Devices", + executablePath: String = "/usr/bin/xcrun", + timeout: Duration = DiskCleanDynamicRuleProviders.subprocessTimeout, + subprocessRunner: any DiskCleanSubprocessRunning = LocalDiskCleanSubprocessRunner(), + fileSystem: DiskCleanFileSystemProviding = LocalDiskCleanFileSystem() + ) { + self.devicesRootPath = devicesRootPath + self.executablePath = executablePath + self.timeout = timeout + self.subprocessRunner = subprocessRunner + self.fileSystem = fileSystem + } + + func expand() async throws -> [DiskCleanFileItem] { + let result: DiskCleanSubprocessResult + do { + result = try await subprocessRunner.run( + executablePath: executablePath, + arguments: ["simctl", "list", "devices", "-j"], + timeout: timeout + ) + } catch DiskCleanSubprocessError.executableUnavailable { + return [] + } + + // A non-zero exit usually means simctl is unavailable (without Xcode, xcrun says "unable to find utility"); + // by design treat that as "no simulators" and return empty, not a failure. + guard result.exitCode == 0 else { return [] } + + var items: [DiskCleanFileItem] = [] + for udid in try Self.unavailableDeviceUDIDs(from: result.standardOutput) { + guard let item = try? fileSystem.itemInfo(at: devicesRootPath + "/" + udid) else { + continue + } + items.append(item) + } + return items + } + + /// Parse UDIDs of devices with `isAvailable == false`. UDID must be a valid UUID to block path injection such as `../`. + static func unavailableDeviceUDIDs(from output: Data) throws -> [String] { + guard + let root = try? JSONSerialization.jsonObject(with: output) as? [String: Any], + let devicesByRuntime = root["devices"] as? [String: Any] + else { + throw DiskCleanDynamicRuleError.malformedOutput(command: "simctl list devices -j") + } + + var udids: [String] = [] + for runtime in devicesByRuntime.keys.sorted() { + guard let devices = devicesByRuntime[runtime] as? [[String: Any]] else { continue } + for device in devices { + guard device["isAvailable"] as? Bool == false else { continue } + guard let udid = device["udid"] as? String, UUID(uuidString: udid) != nil else { continue } + udids.append(udid) + } + } + return udids + } +} + +// MARK: - Default provider instances + +/// Default provider instances referenced by the rule catalog. Container paths must match each target's `reservedRootPaths`. +enum DiskCleanDynamicRuleProviders { + /// Shared subprocess timeout (design §5.5). + static let subprocessTimeout: Duration = .seconds(2) + + static let unavailableSimulators: any DiskCleanDynamicRuleProviding = + DiskCleanUnavailableSimulatorRuleProvider() + + /// JetBrains Toolbox: `apps//ch-/` (Toolbox 1.x) and `apps//` (newer layout). + /// Scan both layers; non-version intermediate dirs (`ch-0`) are naturally rejected by version parsing. + static let jetbrainsToolboxOldVersions: any DiskCleanDynamicRuleProviding = + DiskCleanVersionDirectoryRuleProvider(containerGlobs: [ + "~/Library/Application Support/JetBrains/Toolbox/apps/*/ch-*", + "~/Library/Application Support/JetBrains/Toolbox/apps/*" + ]) + + /// Multi-version install dirs for AI coding tools. Missing directories are silently skipped. + static let aiAgentOldVersions: any DiskCleanDynamicRuleProviding = + DiskCleanVersionDirectoryRuleProvider(containerGlobs: [ + "~/.local/share/claude/versions", + "~/.claude/versions", + "~/.codex/versions", + "~/.local/share/opencode/versions" + ]) + + /// Historical version dirs kept by Chromium-family updaters (`GoogleUpdater/` + `Current` symlink). + static let oldBrowserVersions: any DiskCleanDynamicRuleProviding = + DiskCleanVersionDirectoryRuleProvider(containerGlobs: [ + "~/Library/Application Support/Google/GoogleUpdater", + "~/Library/Application Support/Microsoft/EdgeUpdater", + "~/Library/Application Support/BraveSoftware/BraveUpdater" + ]) +} diff --git a/Plugins/DiskClean/Sources/DiskCleanExecutor.swift b/Plugins/DiskClean/Sources/DiskCleanExecutor.swift index d249cf27..b5ff7b63 100644 --- a/Plugins/DiskClean/Sources/DiskCleanExecutor.swift +++ b/Plugins/DiskClean/Sources/DiskCleanExecutor.swift @@ -1,129 +1,372 @@ import Foundation +// MARK: - Execution interface + +/// The executor only accepts `DiskCleanValidatedPlan` (design §6.1, §7). +/// +/// The type system carries a safety duty here: `ValidatedPlan.init` is fileprivate; the only cast site is +/// `DiskCleanPlanner.makePlan`. There is no "pass a list of paths and delete them" entry, so no path around validation. protocol DiskCleanExecuting: Sendable { - func clean( - candidates: [DiskCleanCandidate], - selectedCandidateIDs: Set - ) async throws -> DiskCleanExecutionResult + func execute(plan: DiskCleanValidatedPlan) async throws -> DiskCleanExecutionResult +} + +// MARK: - Plan-level failure + +/// Preflight failure (design §7.1). Any case aborts the **whole run with zero deletions**. +enum DiskCleanExecutionError: LocalizedError, Equatable { + case planExpired + case lockedDuringPreflight(path: String, processName: String) + case safetyRejected(path: String, reason: String) + + var errorDescription: String? { + switch self { + case .planExpired: + return "扫描结果已过期,请重新扫描" + case let .lockedDuringPreflight(path, processName): + return "\(processName) 正在使用 \(path),已取消本次清理" + case let .safetyRejected(path, reason): + return "\(path) 未通过安全校验(\(reason)),已取消本次清理" + } + } } +// MARK: - Execution result + struct DiskCleanExecutionItemResult: Equatable, Sendable { + /// Terminal status (design §7.5). `changedSinceScan` and `rollbackBlocked` are separate, not folded into skipped/failed: + /// the former should prompt a rescan; the latter needs a pinned alert in cleanup history—user actions differ entirely. enum Outcome: Equatable, Sendable { case removed(reclaimedBytes: Int64) + case trashed(reclaimedBytes: Int64, stagedName: String) case skipped(DiskCleanSafetyStatus) + case changedSinceScan case failed(message: String) + case partiallyDeleted(stagedName: String, message: String) + case rollbackBlocked(stagedName: String, message: String) } let candidateID: DiskCleanCandidate.ID let path: String let outcome: Outcome + /// Estimated reclaimed bytes. **Not actual free space** (APFS clones / sparse files / hard links outside the tree). + /// Copy always says "about X"; Trash mode must not say "reclaimed" (design §7.7). var reclaimedBytes: Int64 { - if case let .removed(reclaimedBytes) = outcome { + switch outcome { + case let .removed(reclaimedBytes): return max(reclaimedBytes, 0) + case let .trashed(reclaimedBytes, _): + return max(reclaimedBytes, 0) + case .skipped, .changedSinceScan, .failed, .partiallyDeleted, .rollbackBlocked: + return 0 + } + } + + /// Honest terminal statuses that need a pinned alert in cleanup history. + var needsAttention: Bool { + switch outcome { + case .partiallyDeleted, .rollbackBlocked: + return true + case .removed, .trashed, .skipped, .changedSinceScan, .failed: + return false } - return 0 } } struct DiskCleanExecutionResult: Equatable, Sendable { let itemResults: [DiskCleanExecutionItemResult] + let mode: DiskCleanRemovalMode + + init(itemResults: [DiskCleanExecutionItemResult], mode: DiskCleanRemovalMode = .trash) { + self.itemResults = itemResults + self.mode = mode + } + /// Disposed item count: permanent delete and move-to-Trash both count as success. var removedCount: Int { itemResults.filter { - if case .removed = $0.outcome { return true } - return false + switch $0.outcome { + case .removed, .trashed: + return true + case .skipped, .changedSinceScan, .failed, .partiallyDeleted, .rollbackBlocked: + return false + } }.count } + /// Skipped item count: safety-policy rejection and content changed since scan. Neither touches the object. var skippedCount: Int { itemResults.filter { - if case .skipped = $0.outcome { return true } - return false + switch $0.outcome { + case .skipped, .changedSinceScan: + return true + case .removed, .trashed, .failed, .partiallyDeleted, .rollbackBlocked: + return false + } }.count } + /// Items that did not finish cleanly. `partiallyDeleted` / `rollbackBlocked` count here— + /// they are not success; burying them under "cleaned" would be dishonest. var failedCount: Int { itemResults.filter { - if case .failed = $0.outcome { return true } - return false + switch $0.outcome { + case .failed, .partiallyDeleted, .rollbackBlocked: + return true + case .removed, .trashed, .skipped, .changedSinceScan: + return false + } }.count } + var changedSinceScanCount: Int { + itemResults.filter { $0.outcome == .changedSinceScan }.count + } + + var attentionResults: [DiskCleanExecutionItemResult] { + itemResults.filter(\.needsAttention) + } + var reclaimedBytes: Int64 { itemResults.reduce(0) { $0 + $1.reclaimedBytes } } } +// MARK: - Executor + +/// Plan executor (design §7.1, §7.2). +/// +/// Responsibility: this type owns **ordering and revalidation**; only `DiskCleanPlanItemRemoving` actually mutates files. +/// Three independent defenses: Planner validates at cast time; preflight and per-item revalidation run here; +/// the primitive authenticates identity on the fd. They do not share decision points, so a defect in one layer cannot collapse the whole chain. struct DiskCleanExecutor: DiskCleanExecuting { - let fileSystem: DiskCleanFileSystemProviding - let safetyPolicy: DiskCleanSafetyPolicy + private let primitive: any DiskCleanPlanItemRemoving + private let safetyPolicy: DiskCleanSafetyPolicy + private let runningAppLock: any DiskCleanRunningAppSnapshotting + private let auditLog: DiskCleanAuditLog + private let now: @Sendable () -> Date init( - fileSystem: DiskCleanFileSystemProviding = LocalDiskCleanFileSystem(), - safetyPolicy: DiskCleanSafetyPolicy = DiskCleanSafetyPolicy() + storageDirectory: URL = DiskCleanStorageLocation.fallbackDirectory, + journal: DiskCleanStagingJournal? = nil, + auditLog: DiskCleanAuditLog? = nil, + safetyPolicy: DiskCleanSafetyPolicy = DiskCleanSafetyPolicy(), + runningAppLock: any DiskCleanRunningAppSnapshotting = DiskCleanRunningAppLock(), + now: @escaping @Sendable () -> Date = Date.init ) { - self.fileSystem = fileSystem + let sharedJournal = journal ?? DiskCleanStagingJournal(directory: storageDirectory) + self.init( + primitive: DiskCleanRemovalPrimitive(journal: sharedJournal, now: now), + safetyPolicy: safetyPolicy, + runningAppLock: runningAppLock, + auditLog: auditLog ?? DiskCleanAuditLog(directory: storageDirectory), + now: now + ) + } + + init( + primitive: any DiskCleanPlanItemRemoving, + safetyPolicy: DiskCleanSafetyPolicy, + runningAppLock: any DiskCleanRunningAppSnapshotting, + auditLog: DiskCleanAuditLog, + now: @escaping @Sendable () -> Date = Date.init + ) { + self.primitive = primitive self.safetyPolicy = safetyPolicy + self.runningAppLock = runningAppLock + self.auditLog = auditLog + self.now = now } - func clean( - candidates: [DiskCleanCandidate], - selectedCandidateIDs: Set - ) async throws -> DiskCleanExecutionResult { + func execute(plan: DiskCleanValidatedPlan) async throws -> DiskCleanExecutionResult { + var snapshot = try await preflight(plan: plan) + var itemResults: [DiskCleanExecutionItemResult] = [] + itemResults.reserveCapacity(plan.items.count) - for candidate in candidates where selectedCandidateIDs.contains(candidate.id) { + for item in plan.items { try Task.checkCancellation() - itemResults.append(try clean(candidate)) + + // Per-item lock recheck: refresh bundle IDs; keep process names from the preflight snapshot (trade-off noted on the protocol). + snapshot = await runningAppLock.refreshingBundleIDs(in: snapshot) + if let processName = snapshot.lockingProcessName( + bundleIDs: item.lockedByBundleIDs, + processNames: item.skipWhenProcessIsRunning + ) { + itemResults.append( + record(item: item, outcome: .skipped(.inUse(processName: processName)), mode: plan.mode) + ) + continue + } + + // Second SafetyPolicy check: the whitelist may grow after the scan; staged-name protection also applies here. + // Check physical + logical so a redirected home cache still matches lexical whitelist rules. + let safety = safetyPolicy.safetyStatus( + for: item.path, + alsoChecking: item.safetyCheckPaths + ) + guard safety.isCleanable else { + itemResults.append(record(item: item, outcome: .skipped(safety), mode: plan.mode)) + continue + } + + let disposition = primitive.remove(item, mode: plan.mode) + itemResults.append( + record(item: item, outcome: Self.outcome(for: disposition, item: item), mode: plan.mode) + ) } - return DiskCleanExecutionResult(itemResults: itemResults) + return DiskCleanExecutionResult(itemResults: itemResults, mode: plan.mode) } - private func clean(_ candidate: DiskCleanCandidate) throws -> DiskCleanExecutionItemResult { - guard candidate.safety.isCleanable else { - return itemResult(for: candidate, outcome: .skipped(candidate.safety)) + // MARK: - preflight(§7.1) + + private func preflight(plan: DiskCleanValidatedPlan) async throws -> DiskCleanRunningAppSnapshot { + // 1. Expiry recheck. The confirm window must not cross expiry, but the clock is still evaluated at execution time. + guard now() < plan.expiryDeadline else { + throw DiskCleanExecutionError.planExpired } - guard let item = try fileSystem.itemInfo(at: candidate.path) else { - return itemResult( - for: candidate, - outcome: .skipped(.invalid(reason: "path no longer exists")) + // 2. Fresh snapshot + lock/safety precheck of every plan item. Any failure aborts the whole run. + let processNames = Array(Set(plan.items.flatMap(\.skipWhenProcessIsRunning))) + let snapshot = await runningAppLock.makeSnapshot(processNames: processNames) + for item in plan.items { + if let processName = snapshot.lockingProcessName( + bundleIDs: item.lockedByBundleIDs, + processNames: item.skipWhenProcessIsRunning + ) { + throw DiskCleanExecutionError.lockedDuringPreflight(path: item.path, processName: processName) + } + let safety = safetyPolicy.safetyStatus( + for: item.path, + alsoChecking: item.safetyCheckPaths ) + guard safety.isCleanable else { + throw DiskCleanExecutionError.safetyRejected( + path: item.path, + reason: Self.describe(safety) + ) + } } - let finalSafety = safetyPolicy.safetyStatus( - for: item.path, - isSymlink: item.isSymlink, - resolvedSymlinkTarget: item.resolvedSymlinkTarget + // 3. Re-run ancestor assertions with the plan's own evidence. Evidence is in-plan and independently recheckable—this layer guards against + // defects in the Planner itself; pure in-memory compare, negligible cost. + try DiskCleanPlanner.assertNoAncestorViolation( + plannedPaths: plan.items.map(\.path), + exclusionPaths: plan.exclusionPaths, + reservedPrefixes: plan.reservedPrefixes ) - guard finalSafety.isCleanable else { - return itemResult(for: candidate, outcome: .skipped(finalSafety)) - } - do { - try fileSystem.removeItem(at: item.path) - return itemResult( - for: candidate, - outcome: .removed(reclaimedBytes: candidate.sizeBytes) - ) - } catch { - return itemResult( - for: candidate, - outcome: .failed(message: error.localizedDescription) - ) + return snapshot + } + + // MARK: - Terminal-status mapping and audit + + private static func outcome( + for disposition: DiskCleanRemovalDisposition, + item: DiskCleanValidatedPlan.PlanItem + ) -> DiskCleanExecutionItemResult.Outcome { + switch disposition { + case .removed: + return .removed(reclaimedBytes: item.estimatedBytes) + case let .trashed(stagedName): + return .trashed(reclaimedBytes: item.estimatedBytes, stagedName: stagedName) + case .changedSinceScan: + return .changedSinceScan + case let .failed(reason): + return .failed(message: reason) + case let .partiallyDeleted(stagedName, reason): + return .partiallyDeleted(stagedName: stagedName, message: reason) + case let .rollbackBlocked(stagedName, reason): + return .rollbackBlocked(stagedName: stagedName, message: reason) } } - private func itemResult( - for candidate: DiskCleanCandidate, - outcome: DiskCleanExecutionItemResult.Outcome + private func record( + item: DiskCleanValidatedPlan.PlanItem, + outcome: DiskCleanExecutionItemResult.Outcome, + mode: DiskCleanRemovalMode ) -> DiskCleanExecutionItemResult { - DiskCleanExecutionItemResult( - candidateID: candidate.id, - path: candidate.path, + auditLog.append( + DiskCleanAuditLog.Record( + timestamp: now(), + action: mode == .trash ? .trash : .delete, + targetID: item.targetID, + legacyRuleID: item.legacyRuleID, + category: item.category.rawValue, + path: item.path, + stagedName: Self.stagedName(of: outcome), + estimatedBytes: item.estimatedBytes, + status: Self.status(of: outcome), + skipReason: Self.skipReason(of: outcome), + error: Self.errorMessage(of: outcome) + ) + ) + return DiskCleanExecutionItemResult( + candidateID: item.candidateID, + path: item.path, outcome: outcome ) } + + private static func stagedName(of outcome: DiskCleanExecutionItemResult.Outcome) -> String? { + switch outcome { + case let .trashed(_, stagedName), + let .partiallyDeleted(stagedName, _), + let .rollbackBlocked(stagedName, _): + return stagedName + case .removed, .skipped, .changedSinceScan, .failed: + return nil + } + } + + private static func status(of outcome: DiskCleanExecutionItemResult.Outcome) -> String { + switch outcome { + case .removed, .trashed: + return "ok" + case .skipped: + return "skipped" + case .changedSinceScan: + return "changedSinceScan" + case .failed: + return "failed" + case .partiallyDeleted: + return "partiallyDeleted" + case .rollbackBlocked: + return "rollbackBlocked" + } + } + + private static func skipReason(of outcome: DiskCleanExecutionItemResult.Outcome) -> String? { + guard case let .skipped(safety) = outcome else { return nil } + return describe(safety) + } + + private static func errorMessage(of outcome: DiskCleanExecutionItemResult.Outcome) -> String? { + switch outcome { + case let .failed(message), + let .partiallyDeleted(_, message), + let .rollbackBlocked(_, message): + return message + case .removed, .trashed, .skipped, .changedSinceScan: + return nil + } + } + + private static func describe(_ safety: DiskCleanSafetyStatus) -> String { + switch safety { + case .allowed: + return "allowed" + case let .whitelisted(rule): + return "whitelisted(\(rule))" + case let .protected(reason): + return "protected(\(reason))" + case let .invalid(reason): + return "invalid(\(reason))" + case let .requiresAdmin(reason): + return "requiresAdmin(\(reason))" + case let .inUse(processName): + return "inUse(\(processName))" + } + } } diff --git a/Plugins/DiskClean/Sources/DiskCleanExternalExpansion.swift b/Plugins/DiskClean/Sources/DiskCleanExternalExpansion.swift new file mode 100644 index 00000000..27eb0e66 --- /dev/null +++ b/Plugins/DiskClean/Sources/DiskCleanExternalExpansion.swift @@ -0,0 +1,234 @@ +import Foundation +import MacToolsPluginKit + +// MARK: - Expansion product + +/// Product of one non-rules expansion (design §10). +/// +/// Shape deliberately matches the rules-expansion stage—hits + reserved roots + limitations + logs—so the engine's later +/// ownership, safety, sizing, and artifact cast all run unchanged; P2 candidates cannot skip a step. +struct DiskCleanExternalExpansion: Sendable { + var hits: [DiskCleanTargetHit] = [] + var reservedRootPaths: [String] = [] + var limitations: [DiskCleanScanLimitation] = [] + var logMessages: [DiskCleanScanLogMessage] = [] + + init() {} +} + +/// Adapter seam from a specialized scanner into the unified pipeline. +/// +/// The engine only knows this protocol, so tests can feed arbitrary candidates into the full pipeline without touching the filesystem +/// (sizing → completeness → artifact → Planner → executor), validating the pipeline rather than the scanner. +protocol DiskCleanExternalExpanding: Sendable { + /// `catalog` resolves synthetic targets by targetID. Unknown targetIDs are dropped and logged— + /// a candidate that cannot attach to a target would be rejected at `makePlan` anyway; drop early and say so. + func expand( + scope: DiskCleanScanScope, + catalog: DiskCleanRuleCatalogV2, + localization: PluginLocalization + ) async -> DiskCleanExternalExpansion +} + +// MARK: - Purge / development products + +/// `DiskCleanPurgeScanner` → unified pipeline (design §10.1). +struct DiskCleanDeveloperArtifactExpansion: DiskCleanExternalExpanding { + private let scanner: DiskCleanPurgeScanner + + init(scanner: DiskCleanPurgeScanner = DiskCleanPurgeScanner()) { + self.scanner = scanner + } + + func expand( + scope: DiskCleanScanScope, + catalog: DiskCleanRuleCatalogV2, + localization: PluginLocalization + ) async -> DiskCleanExternalExpansion { + var expansion = DiskCleanExternalExpansion() + let roots = scope.developerArtifactRoots + guard !roots.isEmpty else { return expansion } + + // **Every configured root enters the reserved set**, whether or not traversal succeeded. + // + // Semantics: "the root itself is not a delete target, and any part of the root not covered by a candidate was never reviewed." Ancestor assertions therefore + // reject any plan path that has a root as a descendant (e.g. `~` or a root's parent), while candidates **inside** a root + // are unaffected—they are descendants of the root, not ancestors. Depth cap 6 and unexplored regions left by hit-and-prune + // are covered by the same rule. + expansion.reservedRootPaths = roots + + let result = await scanner.scan(roots: roots) + for report in result.reports { + switch report.status { + case let .unreadable(reason): + // A root that cannot open at all must be distinguished from "scanned with no candidates", or the user sees a dishonest empty list. + expansion.limitations.append( + .scanRootUnreadable(path: report.root, reason: reason) + ) + + case let .traversed(completeness): + // Partial subtree skips only affect discovery completeness, not deletability of already-found candidates (the root is already reserved). + // So only log; do not escalate to a limitation—the list is already in front of the user, and when a root is unreadable + // the user already has a stronger signal. + guard case let .partial(reasons) = completeness else { break } + expansion.logMessages.append( + DiskCleanScanLogMessage( + text: localization.format( + "scanLog.purge.partialRoot", + defaultValue: "%@ 有子目录未能读取(%@),可能漏报部分产物", + report.root, + DiskCleanFormat.partialReasons(reasons, localization: localization) + ), + tone: .warning + ) + ) + } + + for candidate in report.candidates { + guard let target = catalog.target(id: candidate.kind.targetID) else { + expansion.logMessages.append(Self.missingTargetLog(candidate.kind.targetID, localization: localization)) + continue + } + expansion.hits.append( + DiskCleanTargetHit( + target: target, + item: DiskCleanFileItem( + path: candidate.path, + isDirectory: true, + isSymlink: false, + resolvedSymlinkTarget: nil + ), + specificity: 0, + facts: Self.facts(for: candidate) + ) + ) + } + } + + return expansion + } + + /// Dirty repo (including check failure) → keep the target's medium risk, not selected by default; everything else drops to low and is selected by default. + static func facts(for candidate: DiskCleanPurgeCandidate) -> DiskCleanCandidateFacts { + var notes: [DiskCleanCandidateNote] = [ + .developerProject(path: candidate.projectPath, marker: candidate.projectMarker) + ] + if case let .dirty(repositoryPath, reason) = candidate.gitState { + notes.append(.repositoryHasChanges(repositoryPath: repositoryPath, reason: reason)) + } + return DiskCleanCandidateFacts( + risk: candidate.isSelectedByDefault ? .low : nil, + notes: notes + ) + } + + private static func missingTargetLog( + _ targetID: String, + localization: PluginLocalization + ) -> DiskCleanScanLogMessage { + DiskCleanScanLogMessage( + text: localization.format( + "scanLog.missingTarget", + defaultValue: "规则目录缺少 target %@,已跳过对应候选", + targetID + ), + tone: .error + ) + } +} + +// MARK: - Leftover installers + +/// `DiskCleanInstallerScanner` → unified pipeline (design §10.2). +struct DiskCleanInstallerExpansion: DiskCleanExternalExpanding { + private let scanner: DiskCleanInstallerScanner + + init(scanner: DiskCleanInstallerScanner = DiskCleanInstallerScanner()) { + self.scanner = scanner + } + + func expand( + scope: DiskCleanScanScope, + catalog: DiskCleanRuleCatalogV2, + localization: PluginLocalization + ) async -> DiskCleanExternalExpansion { + var expansion = DiskCleanExternalExpansion() + // Scope is fixed; reserved roots come from the synthetic target's own declaration—same source as rule targets. + // Five targets declare the same `~/Downloads`; after dedupe only one remains. + var seenRoots: Set = [] + expansion.reservedRootPaths = DiskCleanInstallerKind.allCases + .compactMap { catalog.target(id: $0.targetID) } + .flatMap { $0.expandedReservedRootPaths() } + .filter { seenRoots.insert($0).inserted } + + switch await scanInBackground() { + case let .denied(path): + // TCC denial (`~/Downloads` is the kind that prompts). The directory may hold tens of GB; + // reporting "nothing to clean" would be lying to the user. + expansion.limitations.append(.scanRootUnreadable(path: path, reason: .permissionDenied)) + + case let .unavailable(path, reason): + expansion.limitations.append(.scanRootUnreadable(path: path, reason: reason)) + + case let .scanned(candidates): + for candidate in candidates { + guard let target = catalog.target(id: candidate.kind.targetID) else { + expansion.logMessages.append( + DiskCleanScanLogMessage( + text: localization.format( + "scanLog.missingTarget", + defaultValue: "规则目录缺少 target %@,已跳过对应候选", + candidate.kind.targetID + ), + tone: .error + ) + ) + continue + } + expansion.hits.append( + DiskCleanTargetHit( + target: target, + item: DiskCleanFileItem( + path: candidate.path, + isDirectory: false, + isSymlink: false, + resolvedSymlinkTarget: nil + ), + specificity: 0, + facts: Self.facts(for: candidate) + ) + ) + } + } + + return expansion + } + + /// `.zip` and "downloaded within 7 days" keep the target's medium risk; everything else drops to low and is selected by default. + static func facts(for candidate: DiskCleanInstallerCandidate) -> DiskCleanCandidateFacts { + var notes: [DiskCleanCandidateNote] = [] + switch candidate.note { + case .mayNotBeInstaller: + notes.append(.mayNotBeInstaller) + case .recentlyModified: + notes.append(.recentlyDownloaded(modifiedAt: candidate.modifiedAt)) + case nil: + break + } + return DiskCleanCandidateFacts( + risk: candidate.isSelectedByDefault ? .low : nil, + notes: notes + ) + } + + /// The scan is blocking (one top-level `readdir` + per-entry `fstatat`) and must not occupy Swift concurrency's cooperative thread pool. + /// The same call may also trigger the `~/Downloads` TCC prompt, which must not happen on a cooperative thread either. + private func scanInBackground() async -> DiskCleanInstallerScanOutcome { + let scanner = self.scanner + return await withCheckedContinuation { continuation in + DispatchQueue.global(qos: .utility).async { + continuation.resume(returning: scanner.scan()) + } + } + } +} diff --git a/Plugins/DiskClean/Sources/DiskCleanFastWalker.swift b/Plugins/DiskClean/Sources/DiskCleanFastWalker.swift new file mode 100644 index 00000000..ac30f8e0 --- /dev/null +++ b/Plugins/DiskClean/Sources/DiskCleanFastWalker.swift @@ -0,0 +1,159 @@ +import Darwin +import Foundation + +/// Primary sizer: bulk directory attributes via `getattrlistbulk` (design §3.3). +/// +/// Traversal skeleton and all safety constraints come from `DiskCleanDirectoryTreeWalker`; this type only owns +/// "how to fetch a batch of entry attributes". +struct DiskCleanFastWalker: DiskCleanDirectorySizing { + private let core: DiskCleanDirectoryTreeWalker + + init( + opener: DiskCleanRootOpener = DiskCleanRootOpener(), + bufferSize: Int = 64 * 1024 + ) { + self.core = DiskCleanDirectoryTreeWalker( + opener: opener, + sourceFactory: DiskCleanBulkEntrySourceFactory(bufferSize: bufferSize) + ) + } + + func size(ofItemAt path: String, context: DiskCleanSizingContext) -> DiskCleanSizeResult { + core.size(ofItemAt: path, context: context) + } +} + +struct DiskCleanBulkEntrySourceFactory: DiskCleanDirectoryEntrySourceFactory { + let bufferSize: Int + + init(bufferSize: Int = 64 * 1024) { + // A directory entry is a few hundred bytes at worst; the 4KB floor guarantees a single entry always fits. + self.bufferSize = max(bufferSize, 4 * 1024) + } + + func makeSource(fileDescriptor: Int32) throws -> any DiskCleanDirectoryEntrySource { + DiskCleanBulkEntrySource(fileDescriptor: fileDescriptor, bufferSize: bufferSize) + } +} + +/// `getattrlistbulk` entry source. +final class DiskCleanBulkEntrySource: DiskCleanDirectoryEntrySource { + let directoryFileDescriptor: Int32 + + private var attributeList = DiskCleanBulkAttributeParser.makeAttributeList() + private let buffer: UnsafeMutableRawPointer + private let bufferSize: Int + private var isFinished = false + private var isClosed = false + + init(fileDescriptor: Int32, bufferSize: Int) { + self.directoryFileDescriptor = fileDescriptor + self.bufferSize = bufferSize + self.buffer = UnsafeMutableRawPointer.allocate(byteCount: bufferSize, alignment: 8) + } + + deinit { + if !isClosed { + buffer.deallocate() + Darwin.close(directoryFileDescriptor) + } + } + + func nextBatch() throws -> [DiskCleanWalkEntry]? { + guard !isFinished, !isClosed else { return nil } + + let returnedCount = getattrlistbulk( + directoryFileDescriptor, + &attributeList, + buffer, + bufferSize, + 0 + ) + if returnedCount < 0 { + throw DiskCleanPOSIXError(code: errno) + } + if returnedCount == 0 { + isFinished = true + return nil + } + + let parsed = DiskCleanBulkAttributeParser.parse( + buffer: UnsafeRawBufferPointer(start: buffer, count: bufferSize), + entryCount: Int(returnedCount) + ) + + var entries = parsed.entries.map(resolve(entry:)) + if parsed.isTruncated { + // Abnormal buffer structure: report honestly so the upper layer records walkError—never pretend the walk was complete. + entries.append(.unresolved(code: EIO)) + } + return entries + } + + func close() { + guard !isClosed else { return } + isClosed = true + buffer.deallocate() + Darwin.close(directoryFileDescriptor) + } + + /// Missing required attributes on one entry (including layout mismatch) → per-entry `fstatat(AT_SYMLINK_NOFOLLOW)` fallback (design §3.3). + private func resolve(entry: DiskCleanBulkAttributeEntry) -> DiskCleanWalkEntry { + guard let nameBytes = entry.nameBytes else { + // Without even a name there is no fallback and no descent. + return .unresolved(code: EIO) + } + + if entry.isFullyResolved, let fileType = entry.fileType, let devid = entry.devid, let fileID = entry.fileID { + return .resolved( + DiskCleanResolvedEntry( + nameBytes: nameBytes, + fileType: fileType, + devid: devid, + fileID: fileID, + // Directories omit ATTR_FILE_*; their linkCount/dataLength do not participate in counting. + linkCount: entry.linkCount ?? 1, + dataLength: entry.dataLength ?? 0 + ) + ) + } + + return DiskCleanEntryStatFallback.resolve( + nameBytes: nameBytes, + directoryFileDescriptor: directoryFileDescriptor + ) + } +} + +/// Per-entry `fstatat` resolution. Shared by Fast's fallback and Slow's main path so both stay semantically identical. +enum DiskCleanEntryStatFallback { + static func resolve(nameBytes: [CChar], directoryFileDescriptor: Int32) -> DiskCleanWalkEntry { + var status = stat() + var failureCode: Int32 = 0 + let succeeded = nameBytes.withUnsafeBufferPointer { pointer -> Bool in + guard let base = pointer.baseAddress else { + failureCode = EINVAL + return false + } + if fstatat(directoryFileDescriptor, base, &status, AT_SYMLINK_NOFOLLOW) != 0 { + failureCode = errno + return false + } + return true + } + guard succeeded else { + return .unresolved(code: failureCode) + } + + return .resolved( + DiskCleanResolvedEntry( + nameBytes: nameBytes, + fileType: DiskCleanRootIdentity.FileType(mode: status.st_mode), + devid: UInt64(UInt32(bitPattern: status.st_dev)), + fileID: status.st_ino, + linkCount: UInt32(status.st_nlink), + dataLength: max(status.st_size, 0) + ) + ) + } +} diff --git a/Plugins/DiskClean/Sources/DiskCleanFileSystem.swift b/Plugins/DiskClean/Sources/DiskCleanFileSystem.swift index 3ee4b2bf..e35b7c88 100644 --- a/Plugins/DiskClean/Sources/DiskCleanFileSystem.swift +++ b/Plugins/DiskClean/Sources/DiskCleanFileSystem.swift @@ -11,7 +11,9 @@ struct DiskCleanFileItem: Equatable, Sendable { protocol DiskCleanFileSystemProviding: Sendable { func expandPathPattern(_ pattern: String) throws -> [DiskCleanFileItem] func itemInfo(at path: String) throws -> DiskCleanFileItem? - func sizeOfItem(at path: String) throws -> Int64 + /// Direct children (**including hidden**). Ancestor decomposition (design §5.3) uses this, so a `*` glob must never replace it— + /// glob skips dot entries, so decomposition would miss a piece and silently shrink the scan scope. + func directChildren(of path: String) throws -> [DiskCleanFileItem] func removeItem(at path: String) throws func deduplicatedParentChildPaths(_ paths: [String]) -> [String] } @@ -59,31 +61,12 @@ struct LocalDiskCleanFileSystem: DiskCleanFileSystemProviding, @unchecked Sendab ) } - func sizeOfItem(at path: String) throws -> Int64 { + func directChildren(of path: String) throws -> [DiskCleanFileItem] { let expandedPath = Self.normalizeSlashes(expandHome(in: path)) - let attributes = try fileManager.attributesOfItem(atPath: expandedPath) - let fileType = attributes[.type] as? FileAttributeType - - if fileType != .typeDirectory || fileType == .typeSymbolicLink { - return Int64((attributes[.size] as? NSNumber)?.int64Value ?? 0) - } - - var total: Int64 = 0 - if let enumerator = fileManager.enumerator( - at: URL(fileURLWithPath: expandedPath), - includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey, .fileSizeKey, .totalFileAllocatedSizeKey], - options: [.skipsPackageDescendants] - ) { - for case let url as URL in enumerator { - let itemAttributes = try fileManager.attributesOfItem(atPath: url.path) - guard (itemAttributes[.type] as? FileAttributeType) != .typeDirectory else { - continue - } - total += Int64((itemAttributes[.size] as? NSNumber)?.int64Value ?? 0) - } + let names = try fileManager.contentsOfDirectory(atPath: expandedPath) + return names.sorted().compactMap { name in + try? itemInfo(at: expandedPath + "/" + name) } - - return total } func removeItem(at path: String) throws { @@ -194,3 +177,41 @@ struct LocalDiskCleanFileSystem: DiskCleanFileSystemProviding, @unchecked Sendab return normalized } } + +/// Physical path normalization (design §13-6). +/// +/// Paths fed to sizing and execution must not contain symlink ancestors, or `O_NOFOLLOW_ANY` fails with ELOOP +/// (`/var` and `/tmp` are themselves symlinks). +/// +/// **Only resolve the parent; keep the last component as-is**: the leaf may itself be a candidate symlink; `realpath` on the whole path +/// would replace it with its target—which means deleting something else. `URL.resolvingSymlinksInPath()` is also wrong; +/// it does not expand `/var`, the opposite semantics. +enum DiskCleanPhysicalPath { + static func resolve(_ path: String) -> String { + guard let location = ParentAnchoredPath(path: path) else { return path } + guard let physicalParent = realpath(of: location.parentPath) else { return path } + return physicalParent == "/" ? "/" + location.name : physicalParent + "/" + location.name + } + + static func realpath(of path: String) -> String? { + var buffer = [CChar](repeating: 0, count: Int(PATH_MAX)) + guard Darwin.realpath(path, &buffer) != nil else { return nil } + let bytes = buffer.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) } + return String(decoding: bytes, as: UTF8.self) + } +} + +/// Fixed directory prefix of a glob: the last complete path component before the first wildcard. +/// +/// Ownership (design §5.3 rule 1) uses its length to measure "which target is more specific": +/// `~/Library/Caches/com.apple.akd` is more specific than `~/Library/Caches/*`, so the same path belongs to the former. +enum DiskCleanGlobPrefix { + static func fixedPrefix(of glob: String) -> String { + guard let wildcardIndex = glob.firstIndex(where: { "*?[".contains($0) }) else { + return glob + } + let head = glob[glob.startIndex.. Bool +} + +/// `FileHandle(forReadingAtPath:)`: **open then immediately close; read no bytes**. +/// +/// Prefer `FileHandle` over `open(2)`: probe targets are TCC-protected files, and Foundation's +/// silent failure ("return nil if it cannot open") is exactly what we want—no throw, no log, +/// no user-visible side effect. +struct DiskCleanFileHandleReadabilityProbe: DiskCleanFileReadabilityProbing { + func canOpenForReading(atPath path: String) -> Bool { + guard let handle = FileHandle(forReadingAtPath: path) else { return false } + try? handle.close() + return true + } +} + +// MARK: - Capability probe + +/// Full Disk Access capability probe (design §9). +/// +/// **There is no FDA query API**, so try opening a known protected file: openable = has FDA. +/// The probe itself must never present a prompt—FDA-class protection is silent EPERM; only +/// Documents/Downloads/Desktop and sandbox containers (`kTCCServiceSystemPolicyAppData`) prompt, +/// and those are deliberately avoided here. +/// +/// **Process-local cache**: FDA is bound at process launch and does not change while running— +/// which is why the status card tells users to quit and reopen. The cache also ensures every +/// target in the same scan sees the same answer. +final class DiskCleanFullDiskAccessProbe: DiskCleanFullDiskAccessProbing, @unchecked Sendable { + /// Process-wide shared instance. Scan engine and detail page read the same result so they + /// never disagree ("engine says no, UI says yes"). + static let shared = DiskCleanFullDiskAccessProbe() + + /// Probe targets, tried in order; first successful open wins. + /// + /// - TCC.db: present on any account that has made a privacy decision; broadest coverage and + /// pure FDA-class protection. + /// - Safari bookmarks: fallback if TCC.db is missing (e.g. brand-new account). Same silent + /// EPERM class. + /// + /// If both fail to open, report unauthorized. Missing file and denial are treated the same: + /// neither proves FDA. A false positive would let the engine expand protected targets and + /// produce a pile of empty permissionDenied candidates. + static func defaultProbePaths(homeDirectory: String = NSHomeDirectory()) -> [String] { + [ + homeDirectory + "/Library/Application Support/com.apple.TCC/TCC.db", + homeDirectory + "/Library/Safari/Bookmarks.plist" + ] + } + + private let probePaths: [String] + private let readability: any DiskCleanFileReadabilityProbing + private let cachedResult = OSAllocatedUnfairLock(initialState: nil) + + init( + probePaths: [String] = DiskCleanFullDiskAccessProbe.defaultProbePaths(), + readability: any DiskCleanFileReadabilityProbing = DiskCleanFileHandleReadabilityProbe() + ) { + self.probePaths = probePaths + self.readability = readability + } + + var hasFullDiskAccess: Bool { + if let cached = cachedResult.withLock({ $0 }) { + return cached + } + // Evaluate outside the lock: probing is a filesystem call; holding the lock would make + // concurrent sizing threads queue on a single open. Concurrent first visits may probe + // once extra at most; results are identical and side-effect free. + let result = probePaths.contains { readability.canOpenForReading(atPath: $0) } + cachedResult.withLock { $0 = result } + return result + } +} + +// MARK: - Authorization guide + +/// Destination for "Open Settings" (design §9). +enum DiskCleanFullDiskAccessGuide { + /// System Settings → Privacy & Security → Full Disk Access. + static let settingsURLString = "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles" + + static var settingsURL: URL? { + URL(string: settingsURLString) + } + + /// Open System Settings. Silently return if URL construction fails—a no-op button is better than a crash. + @MainActor + static func openSettings() { + guard let settingsURL else { return } + NSWorkspace.shared.open(settingsURL) + } +} diff --git a/Plugins/DiskClean/Sources/DiskCleanInstallerScanner.swift b/Plugins/DiskClean/Sources/DiskCleanInstallerScanner.swift new file mode 100644 index 00000000..95baacf1 --- /dev/null +++ b/Plugins/DiskClean/Sources/DiskCleanInstallerScanner.swift @@ -0,0 +1,250 @@ +import Darwin +import Foundation + +// MARK: - Kind + +/// Leftover installer kind (design §10.2). Raw value is the lowercase extension; classification is extension-only. +enum DiskCleanInstallerKind: String, CaseIterable, Equatable, Sendable { + case diskImage = "dmg" + case installerPackage = "pkg" + case discImage = "iso" + case signedArchive = "xip" + /// A `.zip` may be an installer or a user archive. Never selected by default. + case zipArchive = "zip" + + var fileExtension: String { rawValue } + + /// Whether this format is almost certainly an installer. `.zip` is the only exception. + var isLikelyInstaller: Bool { self != .zipArchive } + + var displayName: String { + switch self { + case .diskImage: + return "磁盘映像" + case .installerPackage: + return "安装包" + case .discImage: + return "光盘映像" + case .signedArchive: + return "签名归档" + case .zipArchive: + return "压缩包" + } + } + + /// Stable synthetic target ID (`DiskCleanRuleCatalogV2` builds targets from this). + /// Written as-is into audit logs; changing it changes historical record semantics. + var targetID: String { + "installer." + rawValue + } + + static let byExtension: [String: DiskCleanInstallerKind] = Dictionary( + uniqueKeysWithValues: DiskCleanInstallerKind.allCases.map { ($0.rawValue, $0) } + ) +} + +// MARK: - Candidate + +struct DiskCleanInstallerCandidate: Identifiable, Equatable, Sendable { + /// Why the item is not selected by default. Selected candidates have no note. + enum Note: Equatable, Sendable { + /// Modified within 7 days; may still be in use (just downloaded, pending install). + case recentlyModified + /// A `.zip` is not necessarily an installer. + case mayNotBeInstaller + } + + /// Physical path (Downloads directory realpath-normalized, then filename appended; leaf not resolved). + let path: String + let kind: DiskCleanInstallerKind + /// Logical size for preview. **Authoritative size still comes from the unified sizing pipeline** + /// (design §3.2 typed path); this is only `st_size` captured at discovery for immediate list display. + let byteSize: Int64 + let modifiedAt: Date + let isSelectedByDefault: Bool + let note: Note? + + var id: String { path } + + var displayName: String { (path as NSString).lastPathComponent } +} + +// MARK: - Scan result + +/// Result of scanning `~/Downloads`. +/// +/// `denied` and `scanned(candidates: [])` **must stay distinct**: when TCC denies access the +/// directory may still hold tens of GB of installers, and showing "nothing to clean" would lie. +/// The former should guide authorization; the latter is truly empty. +enum DiskCleanInstallerScanOutcome: Equatable, Sendable { + case scanned(candidates: [DiskCleanInstallerCandidate]) + /// Permission denied (TCC not granted or directory permissions). + case denied(path: String) + /// Otherwise unavailable: missing path, not a directory, non-local volume. + case unavailable(path: String, reason: DiskCleanScanCompleteness.PartialReason) + + var candidates: [DiskCleanInstallerCandidate] { + if case let .scanned(candidates) = self { return candidates } + return [] + } +} + +// MARK: - Scanner + +/// Leftover-installer scan (design §10.2). +/// +/// `~/Downloads` is **top-level only, no recursion**: subdirectories are usually user-organized +/// material, and recursing for `.dmg` would surface already-archived content. +/// +/// Blocking, but only one top-level `readdir` plus one `fstatat` per entry; cost scales with +/// entry count and never descends, so WorkerPool abandon budgets are unnecessary—that machinery +/// is for recursive sizing that can hang forever. +struct DiskCleanInstallerScanner: Sendable { + /// Installers older than this age are selected by default. Fresh downloads may not be installed yet. + static let defaultStaleAge: TimeInterval = 7 * 24 * 60 * 60 + + /// Scan scope. Tilde-prefixed so the rule catalog can write it into `reservedRootPaths` + /// (`expandedReservedRootPaths()` expands it). + static let defaultDownloadsPath = "~/Downloads" + + private let downloadsPath: String + private let staleAge: TimeInterval + private let opener: DiskCleanRootOpener + private let sourceFactory: any DiskCleanDirectoryEntrySourceFactory + private let now: @Sendable () -> Date + + /// `downloadsPath` is the injection point: tests pass a temp directory and never touch real `~/Downloads`. + init( + downloadsPath: String = DiskCleanRuleTarget.expandHome( + in: DiskCleanInstallerScanner.defaultDownloadsPath, + homeDirectory: NSHomeDirectory() + ), + staleAge: TimeInterval = defaultStaleAge, + opener: DiskCleanRootOpener = DiskCleanRootOpener(), + sourceFactory: any DiskCleanDirectoryEntrySourceFactory = DiskCleanDirectoryStreamEntrySourceFactory(), + now: @escaping @Sendable () -> Date = { Date() } + ) { + self.downloadsPath = downloadsPath + self.staleAge = staleAge + self.opener = opener + self.sourceFactory = sourceFactory + self.now = now + } + + func scan() -> DiskCleanInstallerScanOutcome { + // Fully resolve the directory with realpath (user Downloads may be a symlink to an + // external volume). On failure, pass the original path to the opener so it reports the + // precise failure reason instead of guessing here. + let path = DiskCleanPhysicalPath.realpath(of: downloadsPath) ?? downloadsPath + + switch opener.open(path: path) { + case let .failed(reason): + return reason == .permissionDenied ? .denied(path: path) : .unavailable(path: path, reason: reason) + + case .resolved: + // Not a directory: Downloads was replaced by a file or symlink. + return .unavailable(path: path, reason: .walkError) + + case let .directory(fileDescriptor, _): + return collect(fileDescriptor: fileDescriptor, directoryPath: path) + } + } + + // MARK: Internals + + /// Takes ownership of `fileDescriptor`. + private func collect(fileDescriptor: Int32, directoryPath: String) -> DiskCleanInstallerScanOutcome { + let source: any DiskCleanDirectoryEntrySource + do { + source = try sourceFactory.makeSource(fileDescriptor: fileDescriptor) + } catch { + // Protocol contract: on makeSource throw, fd ownership remains with the caller. + close(fileDescriptor) + let code = (error as? DiskCleanPOSIXError)?.code ?? EIO + return code == EPERM || code == EACCES + ? .denied(path: directoryPath) + : .unavailable(path: directoryPath, reason: .walkError) + } + defer { source.close() } + + let observedAt = now() + var candidates: [DiskCleanInstallerCandidate] = [] + + do { + while let batch = try source.nextBatch() { + for entry in batch { + guard case let .resolved(resolved) = entry else { continue } + // Regular files only: do not follow symlinks (would delete the link, not the installer), + // and do not recurse into directories. + guard resolved.fileType == .regularFile else { continue } + guard + let name = Self.name(of: resolved), + let kind = DiskCleanInstallerKind.byExtension[(name as NSString).pathExtension.lowercased()], + let status = Self.status(name: name, directoryFileDescriptor: source.directoryFileDescriptor) + else { continue } + + let modifiedAt = DiskCleanRootIdentity.date(from: status.st_mtimespec) + candidates.append( + makeCandidate( + path: directoryPath + "/" + name, + kind: kind, + byteSize: max(status.st_size, 0), + modifiedAt: modifiedAt, + observedAt: observedAt + ) + ) + } + } + } catch { + // Never treat mid-stream readdir failure as a successful partial scan. + let code = (error as? DiskCleanPOSIXError)?.code ?? EIO + return code == EPERM || code == EACCES + ? .denied(path: directoryPath) + : .unavailable(path: directoryPath, reason: .walkError) + } + + // readdir order is filesystem-defined; sorting stabilizes both the list and tests. + return .scanned(candidates: candidates.sorted { $0.path < $1.path }) + } + + private func makeCandidate( + path: String, + kind: DiskCleanInstallerKind, + byteSize: Int64, + modifiedAt: Date, + observedAt: Date + ) -> DiskCleanInstallerCandidate { + let isStale = observedAt.timeIntervalSince(modifiedAt) > staleAge + let note: DiskCleanInstallerCandidate.Note? + if !kind.isLikelyInstaller { + note = .mayNotBeInstaller + } else if !isStale { + note = .recentlyModified + } else { + note = nil + } + + return DiskCleanInstallerCandidate( + path: path, + kind: kind, + byteSize: byteSize, + modifiedAt: modifiedAt, + isSelectedByDefault: kind.isLikelyInstaller && isStale, + note: note + ) + } + + /// Separate `fstatat` for mtime—directory entries carry type and size only, not timestamps. + /// Reuse the same call's `st_size` so size and time come from one observation. + private static func status(name: String, directoryFileDescriptor: Int32) -> stat? { + var status = stat() + guard fstatat(directoryFileDescriptor, name, &status, AT_SYMLINK_NOFOLLOW) == 0 else { return nil } + return status + } + + private static func name(of entry: DiskCleanResolvedEntry) -> String? { + let bytes = entry.nameBytes.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) } + // Invalid UTF-8 names cannot safely become candidate paths; prefer under-reporting. + return String(bytes: bytes, encoding: .utf8) + } +} diff --git a/Plugins/DiskClean/Sources/DiskCleanModels.swift b/Plugins/DiskClean/Sources/DiskCleanModels.swift index eb2c4849..29757331 100644 --- a/Plugins/DiskClean/Sources/DiskCleanModels.swift +++ b/Plugins/DiskClean/Sources/DiskCleanModels.swift @@ -1,6 +1,15 @@ import Foundation import MacToolsPluginKit +// MARK: - Panel groups + +/// The three scan-scope groups for the menu bar and detail page. +/// +/// Rules v2 already split into ten `DiskCleanCategoryID`s, but the panel still keeps v1's three +/// groups: category cannot own "scan scope"—`aiTools` holds both `cache.ai-assistants` and +/// `developer.ai-agent-old-versions`, and the `developer` category still has a target whose +/// legacy id is `browser.service-worker`. Scope is always decided by `legacyRuleID` prefix so +/// scan coverage stays entry-for-entry equivalent to v1 (M5 will introduce category selection). enum DiskCleanChoice: String, CaseIterable, Identifiable, Equatable, Sendable { case cache case developer @@ -8,6 +17,20 @@ enum DiskCleanChoice: String, CaseIterable, Identifiable, Equatable, Sendable { var id: String { rawValue } + /// v1 rule-id prefix → panel group. Sole decision point for v1/v2 scan-scope equivalence. + init?(legacyRuleID: String) { + switch legacyRuleID.prefix(while: { $0 != "." }) { + case "cache": + self = .cache + case "developer": + self = .developer + case "browser": + self = .browser + default: + return nil + } + } + var title: String { title() } @@ -24,6 +47,70 @@ enum DiskCleanChoice: String, CaseIterable, Identifiable, Equatable, Sendable { } } +// MARK: - Scan scope + +/// Scope of one scan (design §10). +/// +/// The three values map to the detail page's three independent sections. They share **one +/// pipeline**—same engine, sizing, Planner, and executor—differing only in where candidates come +/// from: rule-catalog expansion vs a dedicated scanner. Collapsing that difference into one enum +/// means the Controller does not need a second state machine for P2. +enum DiskCleanScanScope: Equatable, Sendable { + /// Rule-catalog expansion (v1's three panel groups). + case rules(choices: Set) + /// Developer-artifact purge (design §10.1). `roots` are user-configured scan roots, already + /// normalized to physical paths. + case developerArtifacts(roots: [String]) + /// Leftover installers (design §10.2). Scope is fixed to top-level `~/Downloads`; no parameters. + case installers + + /// Section identity (parameters excluded). Used where only "which section" matters—selection UI, + /// log prefixes, etc. + var section: DiskCleanScanSection { + switch self { + case .rules: + return .rules + case .developerArtifacts: + return .developerArtifacts + case .installers: + return .installers + } + } + + /// Empty scope = nothing to scan; the scan entry point should be disabled. + /// Rules section: no group checked. Developer-artifacts section: no folders added yet. + /// Both need guidance rather than a no-op spin. + var isEmpty: Bool { + switch self { + case let .rules(choices): + return choices.isEmpty + case let .developerArtifacts(roots): + return roots.isEmpty + case .installers: + return false + } + } + + /// Group set for the rules section. Other sections have no groups; returns empty. + var choices: Set { + guard case let .rules(choices) = self else { return [] } + return choices + } + + var developerArtifactRoots: [String] { + guard case let .developerArtifacts(roots) = self else { return [] } + return roots + } +} + +enum DiskCleanScanSection: String, CaseIterable, Identifiable, Hashable, Sendable { + case rules + case developerArtifacts + case installers + + var id: String { rawValue } +} + enum DiskCleanRisk: Equatable, Sendable { case low case medium @@ -46,6 +133,8 @@ enum DiskCleanSafetyStatus: Equatable, Sendable { } } +// MARK: - Scan log + enum DiskCleanScanLogTone: Equatable, Sendable { case info case success @@ -64,39 +153,319 @@ struct DiskCleanScanLogEntry: Identifiable, Equatable, Sendable { let tone: DiskCleanScanLogTone } +// MARK: - Scan-level limitations + +/// Completeness gaps for one scan (design §4.5). +/// +/// Menu-bar "(limited)" and detail banners always derive from this, **not** from "a +/// permissionDenied candidate happens to exist": wholly skipped targets produce no candidates, so +/// only an explicit limitation can surface that fact. +enum DiskCleanScanLimitation: Equatable, Sendable { + /// Full Disk Access not granted; targets skipped as a whole. + case fdaRestricted(skippedTargetIDs: [String]) + /// Dynamic provider failed (subprocess timeout, malformed output). + case dynamicRuleFailed(targetID: String, reason: String) + /// Glob expansion failed. Not listed in design §4.5: fixed prefixes of path-type targets can + /// also fail with EIO/EACCES; throwing the whole scan is worse than degrading, and reusing + /// `dynamicRuleFailed` would misreport the cause. + case targetExpansionFailed(targetID: String, reason: String) + /// Non-local volume / candidates left unsized after circuit break. + case volumeSkipped(path: String) + /// An entire P2 scan root could not be opened (design §10): deleted directory, TCC denial, or + /// not a directory. + /// + /// **Must stay distinct from "scanned but no candidates"**: when TCC denies `~/Downloads` it + /// may still hold tens of GB of installers, and showing "nothing to clean" would lie. + /// `reason` chooses guidance copy (authorize vs "folder no longer valid"). + case scanRootUnreadable(path: String, reason: DiskCleanScanCompleteness.PartialReason) + /// WorkerPool abandon budget exhausted; this process will not run further sizing. + case walkerCircuitBroken + /// Cumulative count of abandoned threads. + case threadsAbandoned(count: Int) +} + +// MARK: - Candidate notes + +/// Hints derived from candidate facts (design §10). +/// +/// Split from `safety`: "can it be deleted" belongs to `safety`; "what to know before deleting" +/// belongs here. A note **does not affect cleanability**; it only drives badges and default +/// selection—default selection follows `risk`, set by the expansion source from the same facts +/// (see `DiskCleanCandidateFacts`). Keeping them separate is intentional: letting badges decide +/// deletability would open a second decision point beyond `isCleanable`. +enum DiskCleanCandidateNote: Equatable, Sendable { + /// Owning git repo has uncommitted changes / unpushed commits / check failure (design §10.1; + /// failures treated as dirty). Carries structured reason, not finished copy: this type enters + /// the artifact; copy belongs in the view layer. + case repositoryHasChanges(repositoryPath: String, reason: DiskCleanPurgeGitState.DirtyReason) + /// Project root and matching project marker. `marker == nil` means unconditional hit (`__pycache__`). + case developerProject(path: String, marker: String?) + /// A `.zip` is not necessarily an installer (design §10.2). + case mayNotBeInstaller + /// Downloaded less than 7 days ago; may not be installed yet. + case recentlyDownloaded(modifiedAt: Date) +} + +/// Candidate attributes known only at expansion time. +/// +/// Rule-target risk is fixed in the catalog, but P2 synthetic targets cannot do that: under the +/// same `purge.artifacts`, a clean-repo `node_modules` should default-select while one with +/// uncommitted changes should not, and dirtiness is only known after git runs. Risk may therefore +/// be overridden by the expansion source—**this is the only source of `candidate.risk != +/// target.risk`**, and it only affects default selection (Planner and executor ignore risk). +struct DiskCleanCandidateFacts: Equatable, Sendable { + /// nil = inherit target risk. + var risk: DiskCleanRisk? + var notes: [DiskCleanCandidateNote] = [] + + static let inherited = DiskCleanCandidateFacts() +} + +// MARK: - Candidate + +/// One cleanup candidate. +/// +/// `sizeResult == nil` means "discovered, size unknown"—expansion streams entries first so the +/// user sees content in 1–2s, with sizing filled in asynchronously (design §4.2). Unsized and +/// non-complete candidates are never cleanable (§3.1 invariant). struct DiskCleanCandidate: Identifiable, Equatable, Sendable { + /// `targetID::physicalPath`. A path belongs to at most one target (§5.3 ownership). let id: String - let ruleID: String - let choice: DiskCleanChoice - let title: String + let targetID: String + /// v1 rule id. Audit, whitelist migration, and panel grouping all depend on it. + let legacyRuleID: String + let category: DiskCleanCategoryID + /// **Physical path** (no symlink ancestors; design §13-6). Deletion and sizing use this. let path: String - let sizeBytes: Int64 - let safety: DiskCleanSafetyStatus + /// All pre-physical path aliases that resolved to `path` (e.g. both `~/.cache/pip` and + /// `~/Library/Caches/pip` when they are the same object). Whitelist / sensitive checks + /// consult every alias so a redirected home cache cannot slip past a lexical allowlist. + let logicalPaths: [String] + /// Default-selection policy looks only here. Rule candidates take target risk; P2 candidates + /// may be overridden by the expansion source (see `DiskCleanCandidateFacts`). let risk: DiskCleanRisk + let safety: DiskCleanSafetyStatus + /// Display notes. Not part of cleanability. + let notes: [DiskCleanCandidateNote] + /// Size result. nil = not sized yet. + let sizeResult: DiskCleanSizeResult? + + /// Primary logical path (first alias, or the physical path when none differ). + var logicalPath: String { logicalPaths.first ?? path } + + init( + id: String, + targetID: String, + legacyRuleID: String, + category: DiskCleanCategoryID, + path: String, + logicalPath: String? = nil, + logicalPaths: [String]? = nil, + risk: DiskCleanRisk, + safety: DiskCleanSafetyStatus, + notes: [DiskCleanCandidateNote] = [], + sizeResult: DiskCleanSizeResult? = nil + ) { + self.id = id + self.targetID = targetID + self.legacyRuleID = legacyRuleID + self.category = category + self.path = path + if let logicalPaths { + self.logicalPaths = Self.uniquePaths(logicalPaths.isEmpty ? [path] : logicalPaths) + } else if let logicalPath { + self.logicalPaths = Self.uniquePaths([logicalPath]) + } else { + self.logicalPaths = [path] + } + self.risk = risk + self.safety = safety + self.notes = notes + self.sizeResult = sizeResult + } + + private static func uniquePaths(_ paths: [String]) -> [String] { + var seen = Set() + var result: [String] = [] + for path in paths where seen.insert(path).inserted { + result.append(path) + } + return result + } + + static func makeID(targetID: String, path: String) -> String { + "\(targetID)::\(path)" + } + + /// Panel group. Unrecognized legacyRuleID falls back to cache—never silently drop a candidate. + var choice: DiskCleanChoice { + DiskCleanChoice(legacyRuleID: legacyRuleID) ?? .cache + } + + var displayName: String { + (path as NSString).lastPathComponent + } + + /// Estimated logical size. 0 when unsized; UI should show "calculating" from + /// `sizeResult == nil`, not "0 bytes". + var estimatedBytes: Int64 { + max(sizeResult?.estimatedBytes ?? 0, 0) + } + + /// Whether the candidate is cleanable. + /// + /// Design §3.1's core invariant lives here: safety allows **and** size is known **and** + /// completeness is complete. `crossedMountPoint` needs no separate check—it always appears in + /// the `partial` reason set and is already blocked by `isComplete`. + var isCleanable: Bool { + guard safety.isCleanable, let sizeResult else { return false } + return sizeResult.completeness.isComplete && sizeResult.rootIdentity != nil + } + + /// Observation time. The expiry gate (§4.4) takes the minimum across selected items. + var observedAt: Date? { + sizeResult?.observedAt + } + + func applying(_ sizeResult: DiskCleanSizeResult) -> DiskCleanCandidate { + DiskCleanCandidate( + id: id, + targetID: targetID, + legacyRuleID: legacyRuleID, + category: category, + path: path, + logicalPaths: logicalPaths, + risk: risk, + safety: safety, + notes: notes, + sizeResult: sizeResult + ) + } + + /// Paths that must all be checked against lexical safety rules (every alias except the physical path itself). + var safetyCheckPaths: [String] { + logicalPaths.filter { $0 != path } + } +} + +// MARK: - Result freshness + +/// Result expiry gate (design §4.4). +/// +/// **Positioning: misuse protection** (against "scan, leave for hours, then clean"), **not TOCTOU +/// protection**—execution's real defenses are identity verification and the freeze primitive (§7). +/// No doc, comment, or user copy may claim this gate "prevents deleting unreviewed content". +enum DiskCleanScanFreshness { + /// Expiry window. Cache TTL must be strictly smaller (see `DiskCleanSizeCache.timeToLive`). + static let window: TimeInterval = 300 + + static func deadline(minObservedAt: Date) -> Date { + minObservedAt.addingTimeInterval(window) + } } +// MARK: - Scan events and artifact + +enum DiskCleanScanEvent: Sendable { + case log(DiskCleanScanLogMessage) + /// Size unknown; not selectable. + case candidateFound(DiskCleanCandidate) + case candidateSized(id: DiskCleanCandidate.ID, result: DiskCleanSizeResult) + case categoryFinished(DiskCleanCategoryID) + case finished(DiskCleanScanSummary) +} + +/// Immutable artifact of one scan; the **only** input to M4 `DiskCleanPlanner.makePlan` (design §6.1). +/// +/// Planner derives exclusion set and reserved prefixes from this; the Controller can only pass +/// `selectedIDs` as subtraction, so it cannot forge evidence or omit reserved prefixes. +struct DiskCleanScanArtifact: Equatable, Sendable { + let scope: DiskCleanScanScope + /// All candidates (including non-cleanable), with final sizeResult. + let candidates: [DiskCleanCandidate] + /// Reserved roots of skipped / failed targets (`~` already expanded). + /// + /// These subtrees "exist but were not scanned"; Planner must forbid deleting their ancestors + /// (§6.1 item 3). + let reservedRootPaths: [String] + let limitations: [DiskCleanScanLimitation] + let startedAt: Date + let finishedAt: Date + + var cleanableCandidates: [DiskCleanCandidate] { + candidates.filter(\.isCleanable) + } + + /// Paths of non-cleanable candidates. + /// + /// **Not** the Planner exclusion set: that is "all candidates not in the plan", which varies + /// with selection and is derived inside `makePlan` (§6.1 item 2 explicitly rejects caller-supplied + /// exclusions). This property is the selection-independent slice—locked / whitelisted / + /// protected / partial / unsized—used by scan-engine tests to assert incomplete candidates + /// really entered the exclusion set. + var exclusionPaths: [String] { + candidates.filter { !$0.isCleanable }.map(\.path) + } +} + +/// Scan summary (design §4.5). Stats always derive from the artifact so two number sources cannot disagree. +struct DiskCleanScanSummary: Equatable, Sendable { + let artifact: DiskCleanScanArtifact + + var limitations: [DiskCleanScanLimitation] { artifact.limitations } + var candidateCount: Int { artifact.candidates.count } + var cleanableCount: Int { artifact.cleanableCandidates.count } + var cleanableEstimatedBytes: Int64 { + artifact.cleanableCandidates.reduce(0) { $0 + $1.estimatedBytes } + } +} + +// MARK: - Controller-held scan result projection + +/// UI projection of scan results. Exists while scanning (`artifact == nil`); completed scans carry the artifact. struct DiskCleanScanResult: Equatable, Sendable { - let choices: Set + /// Scope that produced this result. Differing from the current scope means "result is stale" + /// (`isResultStale`)—rules section: groups changed; developer-artifacts section: roots added/removed; + /// same semantics either way. + let scope: DiskCleanScanScope let candidates: [DiskCleanCandidate] let scannedAt: Date + let limitations: [DiskCleanScanLimitation] + /// Present only after scan completion. Cleanup entry accepts only non-nil artifacts. + let artifact: DiskCleanScanArtifact? + + init( + scope: DiskCleanScanScope, + candidates: [DiskCleanCandidate], + scannedAt: Date, + limitations: [DiskCleanScanLimitation] = [], + artifact: DiskCleanScanArtifact? = nil + ) { + self.scope = scope + self.candidates = candidates + self.scannedAt = scannedAt + self.limitations = limitations + self.artifact = artifact + } var cleanableCandidates: [DiskCleanCandidate] { - candidates.filter { $0.safety.isCleanable } + candidates.filter(\.isCleanable) } var cleanableSizeBytes: Int64 { - cleanableCandidates.reduce(0) { $0 + max($1.sizeBytes, 0) } + cleanableCandidates.reduce(0) { $0 + $1.estimatedBytes } } - var protectedCount: Int { - candidates.filter { - if case .protected = $0.safety { - return true - } - if case .whitelisted = $0.safety { - return true - } - return false - }.count + var isLimited: Bool { + !limitations.isEmpty + } + + /// Expiry deadline: earliest observation among cleanable candidates + 300s. No cleanable + /// candidates means no expiry needed. + var expiryDeadline: Date? { + cleanableCandidates + .compactMap(\.observedAt) + .min() + .map(DiskCleanScanFreshness.deadline(minObservedAt:)) } } diff --git a/Plugins/DiskClean/Sources/DiskCleanPlan.swift b/Plugins/DiskClean/Sources/DiskCleanPlan.swift new file mode 100644 index 00000000..c18ff5a7 --- /dev/null +++ b/Plugins/DiskClean/Sources/DiskCleanPlan.swift @@ -0,0 +1,271 @@ +import Foundation + +// MARK: - Removal mode + +/// Removal mode (design §7.4). Both modes use the freeze primitive; they differ only in post-freeze disposition. +enum DiskCleanRemovalMode: String, CaseIterable, Equatable, Sendable { + /// Move to Trash (default). Recoverable; single-step execution. + case trash + /// Permanent delete. Irreversible; requires confirming two-step confirmation. + case permanent +} + +/// Persistence seam for removal mode. +protocol DiskCleanRemovalModeStoring: Sendable { + func load() -> DiskCleanRemovalMode + func save(_ mode: DiskCleanRemovalMode) +} + +/// `UserDefaults` is thread-safe but not marked Sendable; same treatment as the existing +/// `LocalDiskCleanFileSystem` in the repo. +struct UserDefaultsDiskCleanRemovalModeStore: DiskCleanRemovalModeStoring, @unchecked Sendable { + static let defaultsKey = "DiskClean.removalMode" + + private let defaults: UserDefaults + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + func load() -> DiskCleanRemovalMode { + defaults.string(forKey: Self.defaultsKey) + .flatMap(DiskCleanRemovalMode.init(rawValue:)) ?? .trash + } + + func save(_ mode: DiskCleanRemovalMode) { + defaults.set(mode.rawValue, forKey: Self.defaultsKey) + } +} + +// MARK: - Plan errors + +enum DiskCleanPlanError: LocalizedError, Equatable { + /// Selection includes a missing or non-cleanable candidate. + case invalidSelection(candidateID: String) + /// Selection is empty. + case emptySelection + /// Candidate's target is missing from the catalog (artifact/catalog version mismatch). + case unknownTarget(targetID: String) + /// Planned path is an ancestor of an exclusion / reserved prefix—deleting it would swallow an unreviewed subtree. + case ancestorViolation(plannedPath: String, protectedPath: String) + /// Result expired (§4.4 expiry gate). + case resultExpired + + var errorDescription: String? { + switch self { + case let .invalidSelection(candidateID): + return "选择包含不可清理的候选:\(candidateID)" + case .emptySelection: + return "没有可执行的清理项" + case let .unknownTarget(targetID): + return "规则目录缺少 target:\(targetID)" + case let .ancestorViolation(plannedPath, protectedPath): + return "计划路径 \(plannedPath) 覆盖受保护路径 \(protectedPath)" + case .resultExpired: + return "扫描结果已过期,请重新扫描" + } + } +} + +// MARK: - Validated plan + +/// Non-forgeable deletion plan (design §6.1). +/// +/// `init` is **fileprivate**: only same-file `DiskCleanPlanner.makePlan` can construct it +/// (ticket-mint pattern); the executor accepts only this type. Exclusion set and reserved prefixes +/// are **verification evidence carried by the plan**; executor preflight re-runs the ancestor +/// assertion independently from them (§7.1) and need not trust Planner to be defect-free. +struct DiskCleanValidatedPlan: Equatable, Sendable { + struct PlanItem: Equatable, Sendable { + let candidateID: DiskCleanCandidate.ID + /// Physical path (no symlink ancestors, §13-6). Deletion uses this. + let path: String + /// All pre-physical aliases preserved for lexical safety rechecks (whitelist / sensitive). + let logicalPaths: [String] + let rootIdentity: DiskCleanRootIdentity + let observedAt: Date + let targetID: String + let legacyRuleID: String + let category: DiskCleanCategoryID + let estimatedBytes: Int64 + /// Target declarations needed for lock checks, copied from the catalog at mint time so + /// the execution side need not re-query the catalog. + let lockedByBundleIDs: [String] + let skipWhenProcessIsRunning: [String] + + var parentPath: String { + (path as NSString).deletingLastPathComponent + } + + var name: String { + (path as NSString).lastPathComponent + } + + var safetyCheckPaths: [String] { + logicalPaths.filter { $0 != path } + } + } + + let items: [PlanItem] + /// Frozen removal mode. Mode changes during confirming invalidate the whole plan rather than rewrite this value. + let mode: DiskCleanRemovalMode + let totalEstimatedBytes: Int64 + /// Frozen earliest observation time. Execution preflight rechecks the expiry gate from this (§7.1 item 1). + let minObservedAt: Date + /// Verification evidence: paths of all unplanned candidates + locked/protected/whitelist/partial paths. + let exclusionPaths: [String] + /// Verification evidence: reserved roots of skipped / failed targets (unscanned subtrees treated as present). + let reservedPrefixes: [String] + let mintedAt: Date + + fileprivate init( + items: [PlanItem], + mode: DiskCleanRemovalMode, + minObservedAt: Date, + exclusionPaths: [String], + reservedPrefixes: [String], + mintedAt: Date + ) { + self.items = items + self.mode = mode + self.totalEstimatedBytes = items.reduce(0) { $0 + max($1.estimatedBytes, 0) } + self.minObservedAt = minObservedAt + self.exclusionPaths = exclusionPaths + self.reservedPrefixes = reservedPrefixes + self.mintedAt = mintedAt + } + + var itemCount: Int { items.count } + + var expiryDeadline: Date { + DiskCleanScanFreshness.deadline(minObservedAt: minObservedAt) + } +} + +/// UI-facing plan summary. Snapshots carry only this; the full plan stays in Controller private state. +struct DiskCleanPendingPlanSummary: Equatable, Sendable { + let itemCount: Int + let totalEstimatedBytes: Int64 + let mode: DiskCleanRemovalMode +} + +// MARK: - Planner + +/// Sole minting point for plans. +@MainActor +enum DiskCleanPlanner { + /// Mint a plan. Any validation failure `throw`s—no plan means zero deletions. + /// + /// Exclusion set and reserved prefixes always derive from `artifact`; the caller (Controller) + /// can only subtract via `selectedIDs`, cannot forge evidence, and cannot omit reserved prefixes. + static func makePlan( + artifact: DiskCleanScanArtifact, + selectedIDs: Set, + mode: DiskCleanRemovalMode, + now: Date, + catalog: DiskCleanRuleCatalogV2 = .current + ) throws -> DiskCleanValidatedPlan { + guard !selectedIDs.isEmpty else { + throw DiskCleanPlanError.emptySelection + } + + let targetsByID = Dictionary( + catalog.targets.map { ($0.id, $0) }, + uniquingKeysWith: { first, _ in first } + ) + + var items: [DiskCleanValidatedPlan.PlanItem] = [] + items.reserveCapacity(selectedIDs.count) + var minObservedAt = Date.distantFuture + + // Preserve artifact candidate order for selected items so execution and display stay consistent and reproducible. + for candidate in artifact.candidates where selectedIDs.contains(candidate.id) { + guard candidate.isCleanable, + let sizeResult = candidate.sizeResult, + let rootIdentity = sizeResult.rootIdentity else { + throw DiskCleanPlanError.invalidSelection(candidateID: candidate.id) + } + guard let target = targetsByID[candidate.targetID] else { + throw DiskCleanPlanError.unknownTarget(targetID: candidate.targetID) + } + + minObservedAt = min(minObservedAt, sizeResult.observedAt) + items.append( + DiskCleanValidatedPlan.PlanItem( + candidateID: candidate.id, + path: candidate.path, + logicalPaths: candidate.logicalPaths, + rootIdentity: rootIdentity, + observedAt: sizeResult.observedAt, + targetID: candidate.targetID, + legacyRuleID: candidate.legacyRuleID, + category: candidate.category, + estimatedBytes: candidate.estimatedBytes, + lockedByBundleIDs: target.lockedByBundleIDs, + skipWhenProcessIsRunning: target.skipWhenProcessIsRunning + ) + ) + } + + // Some submitted ids are not in the artifact → unauthorized selection; reject entirely. + // Compare sets, not counts: duplicate ids in the artifact could make counts match while + // still hiding a miss. + let plannedIDs = Set(items.map(\.candidateID)) + guard plannedIDs == selectedIDs else { + let unknownID = selectedIDs.first { !plannedIDs.contains($0) } ?? "?" + throw DiskCleanPlanError.invalidSelection(candidateID: unknownID) + } + + // Expiry check (§6.1 item 4). + guard now < DiskCleanScanFreshness.deadline(minObservedAt: minObservedAt) else { + throw DiskCleanPlanError.resultExpired + } + + // Exclusion set: paths of all unplanned candidates. Locked/protected/whitelist/partial are + // included naturally—they are not cleanable and thus never planned. + let exclusionPaths = artifact.candidates + .filter { !plannedIDs.contains($0.id) } + .map(\.path) + let reservedPrefixes = artifact.reservedRootPaths + + // Ancestor assertion (§6.1 item 3). + try assertNoAncestorViolation( + plannedPaths: items.map(\.path), + exclusionPaths: exclusionPaths, + reservedPrefixes: reservedPrefixes + ) + + return DiskCleanValidatedPlan( + items: items, + mode: mode, + minObservedAt: minObservedAt, + exclusionPaths: exclusionPaths, + reservedPrefixes: reservedPrefixes, + mintedAt: now + ) + } + + /// Ancestor assertion: no planned path may equal, or be an ancestor of, any protected path. + /// + /// Executor preflight re-runs the same assertion with plan-carried evidence (§7.1 item 3), + /// sharing this implementation. `nonisolated`: pure value-type computation; the executor + /// re-running it off the main thread should not be forced back onto the main actor. + nonisolated static func assertNoAncestorViolation( + plannedPaths: [String], + exclusionPaths: [String], + reservedPrefixes: [String] + ) throws { + let protectedPaths = exclusionPaths + reservedPrefixes + for planned in plannedPaths { + let prefix = planned.hasSuffix("/") ? planned : planned + "/" + for protectedPath in protectedPaths { + if protectedPath == planned || protectedPath.hasPrefix(prefix) { + throw DiskCleanPlanError.ancestorViolation( + plannedPath: planned, + protectedPath: protectedPath + ) + } + } + } + } +} diff --git a/Plugins/DiskClean/Sources/DiskCleanPlugin.swift b/Plugins/DiskClean/Sources/DiskCleanPlugin.swift index 3ae7421d..74cc266a 100644 --- a/Plugins/DiskClean/Sources/DiskCleanPlugin.swift +++ b/Plugins/DiskClean/Sources/DiskCleanPlugin.swift @@ -14,20 +14,63 @@ private struct DiskCleanPluginProvider: PluginProvider { func makePlugins() -> [any MacToolsPlugin] { let localization = PluginLocalization(bundle: context.resourceBundle) - let controller = DiskCleanController( - scanner: DiskCleanScanner(localization: localization), - executor: DiskCleanExecutor(), - localization: localization + // Journal and audit logs both land here; fall back to the same convention when the host + // provides no support directory. + let storageDirectory = DiskCleanStorageLocation.resolve(supportDirectory: context.supportDirectory) + let engine = DiskCleanScanEngine(localization: localization) + // Shared journal + audit across sections and startup reconciliation so live cleanup and + // recovery cannot race with separate instance-local locks. + let journal = DiskCleanStagingJournal(directory: storageDirectory) + let auditLog = DiskCleanAuditLog(directory: storageDirectory) + let cleanupReadiness = DiskCleanCleanupReadiness(isReady: false) + let executor = DiskCleanExecutor( + storageDirectory: storageDirectory, + journal: journal, + auditLog: auditLog ) - return [DiskCleanPlugin(controller: controller, localization: localization)] + + func makeController(scope: DiskCleanScanScope) -> DiskCleanController { + DiskCleanController( + engine: engine, + executor: executor, + initialSnapshot: DiskCleanControllerSnapshot( + phase: .idle, + scope: scope, + scanResult: nil, + executionResult: nil, + isResultStale: false, + errorMessage: nil, + isCleanupReady: false + ), + localization: localization, + cleanupReadiness: cleanupReadiness + ) + } + + let purgeRoots = DiskCleanPurgeRootsModel() + return [ + DiskCleanPlugin( + controller: makeController(scope: .rules(choices: Set(DiskCleanChoice.allCases))), + developerArtifactsController: makeController(scope: purgeRoots.scope), + installersController: makeController(scope: .installers), + purgeRoots: purgeRoots, + localization: localization, + storageDirectory: storageDirectory, + journal: journal, + auditLog: auditLog, + cleanupReadiness: cleanupReadiness + ) + ] } } @MainActor -final class DiskCleanPlugin: MacToolsPlugin, PluginPrimaryPanel { +final class DiskCleanPlugin: MacToolsPlugin, PluginPrimaryPanel, PluginConfigurationPresenting { enum ControlID { static let scan = "disk-clean-scan" static let clean = "disk-clean-clean" + static let confirmClean = "disk-clean-confirm-clean" + static let cancelClean = "disk-clean-cancel-clean" static let openDetails = "disk-clean-open-details" } @@ -41,17 +84,48 @@ final class DiskCleanPlugin: MacToolsPlugin, PluginPrimaryPanel { var onStateChange: (() -> Void)? var requestPermissionGuidance: ((String) -> Void)? var shortcutBindingResolver: ((String) -> ShortcutBinding?)? + /// Host injection: switch Settings to this plugin's configuration page (destination of "Open Details"). + var requestConfigurationPresentation: (() -> Void)? private let controller: DiskCleanControlling + /// Controllers for the two P2 sections on the detail page (design §10). + /// + /// They only appear in Settings and **do not wire `onStateChange`**: the menu-bar panel reflects + /// the rules section only, so rebuilding the host menu on every P2 candidate stream would be + /// pure overhead. + private let developerArtifactsController: DiskCleanController + private let installersController: DiskCleanController + private let purgeRoots: DiskCleanPurgeRootsModel private let localization: PluginLocalization + private let storageDirectory: URL + private let journal: DiskCleanStagingJournal + private let auditLog: DiskCleanAuditLog + private let cleanupReadiness: DiskCleanCleanupReadiness + private let reconciler: any DiskCleanStagingReconciling private var isExpanded = false init( controller: DiskCleanControlling = DiskCleanController(), - localization: PluginLocalization = PluginLocalization(bundle: .main) + developerArtifactsController: DiskCleanController = DiskCleanController(), + installersController: DiskCleanController = DiskCleanController(), + purgeRoots: DiskCleanPurgeRootsModel = DiskCleanPurgeRootsModel(), + localization: PluginLocalization = PluginLocalization(bundle: .main), + storageDirectory: URL = DiskCleanStorageLocation.fallbackDirectory, + journal: DiskCleanStagingJournal? = nil, + auditLog: DiskCleanAuditLog? = nil, + cleanupReadiness: DiskCleanCleanupReadiness = DiskCleanCleanupReadiness(isReady: true), + reconciler: any DiskCleanStagingReconciling = DiskCleanStagingReconciler() ) { self.controller = controller + self.developerArtifactsController = developerArtifactsController + self.installersController = installersController + self.purgeRoots = purgeRoots self.localization = localization + self.storageDirectory = storageDirectory + self.journal = journal ?? DiskCleanStagingJournal(directory: storageDirectory) + self.auditLog = auditLog ?? DiskCleanAuditLog(directory: storageDirectory) + self.cleanupReadiness = cleanupReadiness + self.reconciler = reconciler self.metadata = PluginMetadata( id: "disk-clean", title: localization.string("metadata.title", defaultValue: "磁盘清理"), @@ -60,12 +134,18 @@ final class DiskCleanPlugin: MacToolsPlugin, PluginPrimaryPanel { order: 90, defaultDescription: localization.string( "metadata.description", - defaultValue: "缓存、开发者缓存和浏览器缓存清理" + defaultValue: "扫描系统缓存、开发产物与残留安装包,默认移到废纸篓,执行前校验路径安全" ) ) self.controller.onStateChange = { [weak self] in self?.onStateChange?() } + // When scan roots change, push the new scope to the developer-artifacts section: if scope + // and result diverge the Controller marks "rescan required". If this wire breaks, users + // can clean with stale results against a folder they just removed. + self.purgeRoots.onRootsChange = { [weak developerArtifactsController] roots in + developerArtifactsController?.setScope(.developerArtifacts(roots: roots)) + } } var primaryPanelState: PluginPanelState { @@ -91,10 +171,18 @@ final class DiskCleanPlugin: MacToolsPlugin, PluginPrimaryPanel { } let localization = localization + let historyProvider = DiskCleanAuditLogHistoryProvider(directory: storageDirectory) + let developerArtifactsController = developerArtifactsController + let installersController = installersController + let purgeRoots = purgeRoots return PluginConfiguration(description: metadata.defaultDescription) { _ in DiskCleanDetailView( controller: controller, + developerArtifactsController: developerArtifactsController, + installersController: installersController, + purgeRoots: purgeRoots, localization: localization, + historyProvider: historyProvider, showsHeader: false, contentPadding: 0, minimumContentHeight: 0 @@ -104,8 +192,41 @@ final class DiskCleanPlugin: MacToolsPlugin, PluginPrimaryPanel { func refresh() {} + /// Startup reconciliation (design §7.6). + /// + /// If the previous run crashed between "renamed to staging name" and "disposition finished", + /// only the journal still knows the orphan's original name. Recover it here: SafetyPolicy + /// protects these objects from every scan, and reconciliation is the only other path that can + /// touch them. Cleanup stays disabled until this finishes so a live clean cannot race recovery. + func activate(context: PluginRuntimeContext) { + let journal = journal + let auditLog = auditLog + let cleanupReadiness = cleanupReadiness + let reconciler = reconciler + let rulesController = controller as? DiskCleanController + let developerController = developerArtifactsController + let installersController = installersController + Task.detached(priority: .utility) { + // Shared journal instance is required: separate journals have separate locks and can + // compact a live begin record while rename is still in flight. + if let concrete = reconciler as? DiskCleanStagingReconciler { + _ = concrete.reconcile(journal: journal, auditLog: auditLog) + } else { + await reconciler.reconcile(storageDirectory: journal.directory) + } + cleanupReadiness.markReady() + await MainActor.run { + rulesController?.refreshCleanupReadiness() + developerController.refreshCleanupReadiness() + installersController.refreshCleanupReadiness() + } + } + } + func deactivate(reason: PluginDeactivationReason) { controller.cancelCurrentOperation() + developerArtifactsController.cancelCurrentOperation() + installersController.cancelCurrentOperation() } func handleAction(_ action: PluginPanelAction) { @@ -138,9 +259,13 @@ final class DiskCleanPlugin: MacToolsPlugin, PluginPrimaryPanel { case ControlID.scan: controller.scan() case ControlID.clean: - controller.cleanSelected(candidateIDs: cleanableCandidateIDs) + controller.clean() + case ControlID.confirmClean: + controller.confirmPendingClean() + case ControlID.cancelClean: + controller.cancelPendingClean() case ControlID.openDetails: - break + requestConfigurationPresentation?() default: break } @@ -172,7 +297,7 @@ final class DiskCleanPlugin: MacToolsPlugin, PluginPrimaryPanel { displayedComponents: nil, datePickerStyle: nil, sectionTitle: nil, - actionTitle: localization.string("panel.action.clean", defaultValue: "清理"), + actionTitle: cleanActionTitle(for: snapshot), actionIconSystemName: "trash", showsLeadingDivider: true, isEnabled: snapshot.canClean @@ -194,29 +319,136 @@ final class DiskCleanPlugin: MacToolsPlugin, PluginPrimaryPanel { isEnabled: true ) + // Permanent delete is two-step: during confirmation swap "Clean" for a Confirm/Cancel pair + // so one button does not carry two meanings. Full styling (detail confirmationDialog, risk + // colors) is left to M5. + let actionControls = snapshot.phase == .confirming + ? confirmationControls(for: snapshot) + : [cleanControl] + return PluginPanelDetail( - primaryControls: [scanControl, cleanControl, openDetailsControl], + primaryControls: [scanControl] + actionControls + [openDetailsControl], secondaryPanel: nil ) } + /// Clean-button title. Include selection count and estimated bytes, and state plainly what will + /// happen for the chosen removal mode—trash mode is single-step, so the button itself is the + /// last explanation (design §8.4). + private func cleanActionTitle(for snapshot: DiskCleanControllerSnapshot) -> String { + let count = snapshot.selection.selectedCount + guard count > 0 else { + return snapshot.removalMode == .trash + ? localization.string("panel.action.trash", defaultValue: "移到废纸篓") + : localization.string("panel.action.clean", defaultValue: "清理") + } + + let bytes = byteText(snapshot.selection.selectedEstimatedBytes) + switch snapshot.removalMode { + case .trash: + return localization.format( + "panel.action.trashSelected", + defaultValue: "移到废纸篓 · %d 项 · 约 %@", + count, + bytes + ) + case .permanent: + return localization.format( + "panel.action.cleanSelected", + defaultValue: "清理 · %d 项 · 约 %@", + count, + bytes + ) + } + } + + private func confirmationControls(for snapshot: DiskCleanControllerSnapshot) -> [PluginPanelControl] { + let confirmTitle = snapshot.pendingPlan.map { plan in + localization.format( + "panel.action.confirmClean", + defaultValue: "确认永久清理 %d 项 · 约 %@", + plan.itemCount, + byteText(plan.totalEstimatedBytes) + ) + } ?? localization.string("panel.action.confirmCleanFallback", defaultValue: "确认永久清理") + + let confirmControl = PluginPanelControl( + id: ControlID.confirmClean, + kind: .actionRow, + options: [], + selectedOptionID: nil, + dateValue: nil, + minimumDate: nil, + displayedComponents: nil, + datePickerStyle: nil, + sectionTitle: nil, + actionTitle: confirmTitle, + actionIconSystemName: "exclamationmark.triangle", + showsLeadingDivider: true, + isEnabled: true + ) + + let cancelControl = PluginPanelControl( + id: ControlID.cancelClean, + kind: .actionRow, + options: [], + selectedOptionID: nil, + dateValue: nil, + minimumDate: nil, + displayedComponents: nil, + datePickerStyle: nil, + sectionTitle: nil, + actionTitle: localization.string("panel.action.cancelClean", defaultValue: "取消"), + actionIconSystemName: "xmark", + isEnabled: true + ) + + return [confirmControl, cancelControl] + } + private func subtitle(for snapshot: DiskCleanControllerSnapshot) -> String { + // While scanning, accumulate reclaimable estimate live (host publishes snapshots ~250ms-throttled). + if snapshot.phase == .scanning, let result = snapshot.scanResult, !result.candidates.isEmpty { + return localization.format( + "panel.subtitle.scanning", + defaultValue: "正在扫描 · %d 项,约 %@", + result.cleanableCandidates.count, + byteText(result.cleanableSizeBytes) + ) + } + if snapshot.phase == .scanned, !snapshot.isResultStale, + !snapshot.isResultExpired, let result = snapshot.scanResult { - return localization.format( + // "(limited)" always derives from limitations (design §4.5, §8.2), not from "some + // protected candidate happened to be scanned". + let base = localization.format( "panel.subtitle.scanned", - defaultValue: "%d 项,%@", + defaultValue: "%d 项,约 %@", result.cleanableCandidates.count, byteText(result.cleanableSizeBytes) ) + guard result.isLimited else { return base } + return base + localization.string("panel.subtitle.limitedSuffix", defaultValue: "(受限)") + } + + if snapshot.phase == .confirming, let plan = snapshot.pendingPlan { + return localization.format( + "panel.subtitle.confirming", + defaultValue: "确认永久清理 %d 项 · 约 %@", + plan.itemCount, + byteText(plan.totalEstimatedBytes) + ) } if snapshot.phase == .completed, let result = snapshot.executionResult { + // Trash mode never says "reclaimed": objects still sit in Trash, so space is not truly freed (design §7.7). + let defaultValue = result.mode == .trash ? "已移到废纸篓约 %@" : "已清理约 %@" return localization.format( - "panel.subtitle.completed", - defaultValue: "已清理 %@", + result.mode == .trash ? "panel.subtitle.trashed" : "panel.subtitle.completed", + defaultValue: defaultValue, byteText(result.reclaimedBytes) ) } @@ -224,10 +456,6 @@ final class DiskCleanPlugin: MacToolsPlugin, PluginPrimaryPanel { return snapshot.subtitle(localization: localization) } - private var cleanableCandidateIDs: Set { - Set(controller.snapshot.scanResult?.cleanableCandidates.map(\.id) ?? []) - } - private func byteText(_ bytes: Int64) -> String { let formatter = ByteCountFormatter() formatter.countStyle = .file diff --git a/Plugins/DiskClean/Sources/DiskCleanPurgeRootsModel.swift b/Plugins/DiskClean/Sources/DiskCleanPurgeRootsModel.swift new file mode 100644 index 00000000..fa897859 --- /dev/null +++ b/Plugins/DiskClean/Sources/DiskCleanPurgeRootsModel.swift @@ -0,0 +1,62 @@ +import Foundation + +/// UI state for developer-artifact scan roots (design §10.1 settings area). +/// +/// Collapses "storage + rejection feedback + scan-scope change notification" into one place: +/// the view only issues commands and reads state, while rejection reasons (unresolvable / +/// duplicate / covered by ancestor) need somewhere to live until the next render— +/// after `DiskCleanPurgeRootsStore.add` returns nobody remembers them, and users would think the +/// folder they just chose vanished. +@MainActor +final class DiskCleanPurgeRootsModel: ObservableObject { + /// Normalized physical paths, in add order. + @Published private(set) var roots: [String] + /// Entries rejected by the most recent add/remove. Cleared by any successful operation. + @Published private(set) var rejections: [DiskCleanPurgeRootRejection] + + /// Callback when the root set changes. Used to push the new scope to the developer-artifacts + /// Controller—once scope changes the result must be marked stale; this wire must not break. + var onRootsChange: (([String]) -> Void)? + + private let store: DiskCleanPurgeRootsStore + + init(store: DiskCleanPurgeRootsStore = DiskCleanPurgeRootsStore()) { + self.store = store + self.roots = store.roots() + self.rejections = [] + } + + var scope: DiskCleanScanScope { + .developerArtifacts(roots: roots) + } + + var isEmpty: Bool { roots.isEmpty } + + func add(_ path: String) { + apply(store.add(path)) + } + + func remove(_ path: String) { + // Remove never produces rejections: if the path is not in the table the result is unchanged, + // with nothing to explain. + update(roots: store.remove(path), rejections: []) + } + + /// User dismisses the reason after reading it. Do not auto-timeout—rejection means the user's + /// intent was not met, so they must confirm they saw the message. + func dismissRejections() { + guard !rejections.isEmpty else { return } + rejections = [] + } + + private func apply(_ update: DiskCleanPurgeRootsUpdate) { + self.update(roots: update.roots, rejections: update.rejections) + } + + private func update(roots: [String], rejections: [DiskCleanPurgeRootRejection]) { + self.rejections = rejections + guard roots != self.roots else { return } + self.roots = roots + onRootsChange?(roots) + } +} diff --git a/Plugins/DiskClean/Sources/DiskCleanPurgeRootsStore.swift b/Plugins/DiskClean/Sources/DiskCleanPurgeRootsStore.swift new file mode 100644 index 00000000..1c954896 --- /dev/null +++ b/Plugins/DiskClean/Sources/DiskCleanPurgeRootsStore.swift @@ -0,0 +1,269 @@ +import Foundation + +// MARK: - Normalization result + +/// Why a scan root was rejected. UI surfaces a concrete hint instead of silently dropping the folder just chosen. +enum DiskCleanPurgeRootRejection: Equatable, Sendable { + /// Not an absolute path, or `realpath(3)` failed (missing, no permission, path too long). + case unresolvable(path: String) + /// Normalized form duplicates an existing root (e.g. different symlinks to the same directory). + case duplicate(path: String) + /// Covered by another root in the list: scanning an ancestor always covers descendants, so + /// keeping the descendant would report the same candidates twice. + case coveredByAncestor(path: String, ancestor: String) + /// Home, system, or other top-level locations are too broad for developer-artifact discovery. + case tooBroad(path: String) +} + +/// Hard denylist for developer-artifact scan roots. +/// +/// Purge discovery walks up to 6 levels and may default-select marker matches. Using Home, +/// `/Applications`, or other top-level locations as a root makes accidental mass selection too easy. +/// Project subfolders under Documents/Desktop remain allowed. +enum DiskCleanPurgeRootPolicy { + /// Exact system / volume roots that must never be scan roots. + private static let exactRestrictedRoots: Set = [ + "/", + "/Applications", + "/System", + "/Library", + "/Users", + "/home", + "/Volumes", + "/Network", + "/private", + "/var", + "/usr", + "/bin", + "/sbin", + "/etc", + "/opt", + "/cores", + ] + + /// Well-known top-level folders under the user’s home that are too broad as roots. + private static let restrictedHomeFolderNames: Set = [ + "Desktop", + "Documents", + "Downloads", + "Library", + "Movies", + "Music", + "Pictures", + "Public", + ] + + /// `path` must already be a physical, trailing-slash-trimmed absolute path. + static func isTooBroad(_ path: String) -> Bool { + if exactRestrictedRoots.contains(path) { + return true + } + + let home = trimTrailingSlashes(NSHomeDirectory()) + if path == home { + return true + } + if restrictedHomeFolderNames.contains(where: { path == home + "/" + $0 }) { + return true + } + + // Another user’s home (`/Users/name`) — and its top-level personal folders — is equally too broad. + let components = path.split(separator: "/", omittingEmptySubsequences: true) + guard components.count >= 2, components[0] == "Users" || components[0] == "home" else { + return false + } + if components.count == 2 { + return true + } + if components.count == 3, restrictedHomeFolderNames.contains(String(components[2])) { + return true + } + + return false + } + + private static func trimTrailingSlashes(_ path: String) -> String { + var trimmed = path + while trimmed.count > 1, trimmed.hasSuffix("/") { + trimmed.removeLast() + } + return trimmed + } +} + +struct DiskCleanPurgeRootsUpdate: Equatable, Sendable { + /// Normalized physical paths, preserving user add order. + let roots: [String] + let rejections: [DiskCleanPurgeRootRejection] +} + +/// Scan-root normalization (design §10.1 + §13-6). +/// +/// Three pure, side-effect-free jobs (`realpath` injected via `resolvePhysicalPath`): +/// 1. **Physical-path conversion**: paths fed to sizing / removal primitives must not contain +/// symlink ancestors, or `O_NOFOLLOW_ANY` rejects the whole tree with ELOOP (`/var` and `/tmp` +/// are themselves symlinks). Normalization must use `realpath(3)`. +/// 2. **Dedup**: two spellings may point at the same directory; only post-normalization shows that. +/// 3. **Ancestor adjudication—keep ancestor, drop descendant**: scanning both would report +/// descendant candidates twice; the reverse ("keep descendant, drop ancestor") would silently +/// shrink a scan range the user explicitly asked for. Adjudication looks at the full normalized +/// set only and is independent of add order: adding `~/Code/app` then `~/Code` still keeps only +/// `~/Code`. +enum DiskCleanPurgeRootNormalizer { + static func normalize( + _ paths: [String], + resolvePhysicalPath: (String) -> String? + ) -> DiskCleanPurgeRootsUpdate { + var resolved: [(original: String, physical: String)] = [] + var rejections: [DiskCleanPurgeRootRejection] = [] + var seen: Set = [] + + for path in paths { + let expanded = expandTilde(in: path) + guard expanded.hasPrefix("/"), let physical = resolvePhysicalPath(expanded) else { + rejections.append(.unresolvable(path: path)) + continue + } + let trimmed = trimTrailingSlashes(physical) + if DiskCleanPurgeRootPolicy.isTooBroad(trimmed) { + rejections.append(.tooBroad(path: path)) + continue + } + guard seen.insert(trimmed).inserted else { + rejections.append(.duplicate(path: path)) + continue + } + resolved.append((original: path, physical: trimmed)) + } + + var roots: [String] = [] + for entry in resolved { + if let ancestor = resolved.first(where: { isStrictAncestor($0.physical, of: entry.physical) }) { + rejections.append(.coveredByAncestor(path: entry.original, ancestor: ancestor.physical)) + continue + } + roots.append(entry.physical) + } + + return DiskCleanPurgeRootsUpdate(roots: roots, rejections: rejections) + } + + /// Strict ancestor: `/a` is an ancestor of `/a/b`, but not of `/a` itself or of `/ab`. + /// Both sides already have trailing slashes stripped; `/` is special-cased (join would yield `//`). + static func isStrictAncestor(_ ancestor: String, of path: String) -> Bool { + guard ancestor != path else { return false } + if ancestor == "/" { return path.hasPrefix("/") } + return path.hasPrefix(ancestor + "/") + } + + private static func expandTilde(in path: String) -> String { + guard path == "~" || path.hasPrefix("~/") else { return path } + return NSHomeDirectory() + path.dropFirst(1) + } + + private static func trimTrailingSlashes(_ path: String) -> String { + var trimmed = path + while trimmed.count > 1, trimmed.hasSuffix("/") { + trimmed.removeLast() + } + return trimmed + } +} + +// MARK: - Persistence seam + +/// Raw persistence for scan roots. Same shape as `DiskCleanRemovalModeStoring`: store/load only, no semantics. +protocol DiskCleanPurgeRootsPersisting: Sendable { + func loadRoots() -> [String] + func saveRoots(_ roots: [String]) +} + +/// `UserDefaults` is thread-safe but not marked Sendable; same treatment as existing +/// `UserDefaultsDiskCleanRemovalModeStore`. +struct UserDefaultsDiskCleanPurgeRootsPersistence: DiskCleanPurgeRootsPersisting, @unchecked Sendable { + static let defaultsKey = "DiskClean.purgeRoots" + + private let defaults: UserDefaults + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + func loadRoots() -> [String] { + defaults.stringArray(forKey: Self.defaultsKey) ?? [] + } + + func saveRoots(_ roots: [String]) { + defaults.set(roots, forKey: Self.defaultsKey) + } +} + +// MARK: - Storage + +/// User-configured developer-artifact scan roots (design §10.1). Empty by default—never whole-disk +/// scan; only directories the user explicitly names. +/// +/// Writes always pass through `DiskCleanPurgeRootNormalizer`, so **stored paths are always physical**. +/// Reads drop denylisted roots but do not re-run `realpath`: a directory may have been deleted or +/// replaced, and a failed `realpath` would make a still-valid entry vanish. Letting the scanner +/// fail on `O_NOFOLLOW_ANY` open and report honestly is the more truthful degradation. +struct DiskCleanPurgeRootsStore: Sendable { + private let persistence: any DiskCleanPurgeRootsPersisting + private let resolvePhysicalPath: @Sendable (String) -> String? + + init( + persistence: any DiskCleanPurgeRootsPersisting = UserDefaultsDiskCleanPurgeRootsPersistence(), + resolvePhysicalPath: @escaping @Sendable (String) -> String? = { DiskCleanPhysicalPath.realpath(of: $0) } + ) { + self.persistence = persistence + self.resolvePhysicalPath = resolvePhysicalPath + } + + func roots() -> [String] { + sanitizePersistedRoots() + } + + /// Drop any persisted roots that are now denylisted, then return the allowed set. + /// Keeps older installs / injected UserDefaults values from becoming scan roots. + @discardableResult + func sanitizePersistedRoots() -> [String] { + let loaded = persistence.loadRoots() + let allowed = allowedRoots(from: loaded) + if allowed != loaded { + persistence.saveRoots(allowed) + } + return allowed + } + + /// Append a root and persist. Return value includes rejections for the UI to explain. + /// + /// Filters denylisted entries out of the existing table without writing them back—`replaceAll` + /// below is the single write for this call, so a rejected addition never persists twice. + @discardableResult + func add(_ path: String) -> DiskCleanPurgeRootsUpdate { + replaceAll(with: allowedRoots(from: persistence.loadRoots()) + [path]) + } + + private func allowedRoots(from paths: [String]) -> [String] { + paths.filter { !DiskCleanPurgeRootPolicy.isTooBroad($0) } + } + + /// Remove a root. Match both the original spelling and the normalized form: if the directory + /// no longer exists `realpath` fails, and matching only the normalized form would leave the + /// user unable to delete the entry forever. + @discardableResult + func remove(_ path: String) -> [String] { + let physical = resolvePhysicalPath(path) + let remaining = persistence.loadRoots().filter { $0 != path && $0 != physical } + persistence.saveRoots(remaining) + return remaining + } + + /// Replace the whole table (settings bulk edit, migrating old storage). + @discardableResult + func replaceAll(with paths: [String]) -> DiskCleanPurgeRootsUpdate { + let update = DiskCleanPurgeRootNormalizer.normalize(paths, resolvePhysicalPath: resolvePhysicalPath) + persistence.saveRoots(update.roots) + return update + } +} diff --git a/Plugins/DiskClean/Sources/DiskCleanPurgeRootsView.swift b/Plugins/DiskClean/Sources/DiskCleanPurgeRootsView.swift new file mode 100644 index 00000000..c0628111 --- /dev/null +++ b/Plugins/DiskClean/Sources/DiskCleanPurgeRootsView.swift @@ -0,0 +1,174 @@ +import AppKit +import SwiftUI +import MacToolsPluginKit + +/// Developer-artifact scan-root management (design §10.1 settings area). +/// +/// Empty by default; only scans directories the user explicitly names—never whole-disk scanning +/// is a product premise, not a performance optimization. +/// +/// Adding uses `NSOpenPanel`, not a text field: paths are handed to removal primitives; hand-typed +/// strings are error-prone and cannot obtain the system’s user-intent authorization for directories +/// like Documents / Desktop (panel selection is itself that authorization). +struct DiskCleanPurgeRootsView: View { + @ObservedObject var model: DiskCleanPurgeRootsModel + let localization: PluginLocalization + + var body: some View { + VStack(spacing: 0) { + header + if !model.roots.isEmpty { + PluginSettingsListDivider() + rootRows + } + if !model.rejections.isEmpty { + PluginSettingsListDivider() + rejectionRow + } + } + .pluginSettingsCardBackground(.plugin) + } + + private var header: some View { + HStack(alignment: .top, spacing: PluginSettingsTheme.Spacing.rowContentControl) { + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.rowTitleDescription) { + Text(localization.string("detail.purgeRoots.title", defaultValue: "扫描文件夹")) + .font(PluginSettingsTheme.Typography.rowTitle) + Text( + localization.string( + "detail.purgeRoots.description", + defaultValue: "只扫描这些文件夹,最多向下 6 层。请添加具体工程目录,不要选择个人文件夹或系统目录。" + ) + ) + .font(PluginSettingsTheme.Typography.rowDescription) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer(minLength: PluginSettingsTheme.Spacing.rowContentControl) + + Button(action: chooseFolder) { + Label( + localization.string("detail.purgeRoots.add", defaultValue: "添加文件夹"), + systemImage: "plus" + ) + .font(PluginSettingsTheme.Typography.controlLabel) + } + .buttonStyle(.bordered) + .controlSize(.small) + } + .pluginSettingsListRowPadding(interactive: true) + } + + private var rootRows: some View { + VStack(spacing: 0) { + ForEach(model.roots, id: \.self) { root in + HStack(spacing: PluginSettingsTheme.Spacing.rowContentControl) { + Image(systemName: "folder") + .pluginSettingsRowIconStyle() + + Text(root) + .font(PluginSettingsTheme.Typography.monospacedValue) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.enabled) + + Spacer(minLength: PluginSettingsTheme.Spacing.rowContentControl) + + Button { + model.remove(root) + } label: { + Image(systemName: "minus.circle") + .font(PluginSettingsTheme.Typography.rowIcon) + } + .buttonStyle(.borderless) + .help(localization.string("detail.purgeRoots.remove", defaultValue: "移除此文件夹")) + } + .pluginSettingsListRowPadding() + + if root != model.roots.last { + PluginSettingsListDivider() + } + } + } + } + + /// Show rejection reasons one by one. Silently dropping a just-chosen folder is the worst + /// outcome—users think they mis-clicked and choose again. + private var rejectionRow: some View { + HStack(alignment: .top, spacing: PluginSettingsTheme.Spacing.rowContentControl) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + .frame(width: PluginSettingsTheme.Size.rowIcon) + + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.rowTitleDescription) { + ForEach(Array(model.rejections.enumerated()), id: \.offset) { _, rejection in + Text(Self.rejectionText(rejection, localization: localization)) + .font(PluginSettingsTheme.Typography.rowDescription) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + Spacer(minLength: PluginSettingsTheme.Spacing.rowContentControl) + + Button { + model.dismissRejections() + } label: { + Text(localization.string("detail.purgeRoots.dismiss", defaultValue: "知道了")) + .font(PluginSettingsTheme.Typography.controlLabel) + } + .buttonStyle(.borderless) + } + .pluginSettingsListRowPadding() + } + + static func rejectionText( + _ rejection: DiskCleanPurgeRootRejection, + localization: PluginLocalization + ) -> String { + switch rejection { + case let .unresolvable(path): + return localization.format( + "detail.purgeRoots.rejected.unresolvable", + defaultValue: "%@ 无法解析,可能已被移除或没有访问权限。", + path + ) + case let .duplicate(path): + return localization.format( + "detail.purgeRoots.rejected.duplicate", + defaultValue: "%@ 已经在列表里了。", + path + ) + case let .coveredByAncestor(path, ancestor): + return localization.format( + "detail.purgeRoots.rejected.covered", + defaultValue: "%@ 已包含在 %@ 之内,无需重复添加。", + path, + ancestor + ) + case let .tooBroad(path): + return localization.format( + "detail.purgeRoots.rejected.tooBroad", + defaultValue: "%@ 范围过大,请选择具体的工程目录。", + path + ) + } + } + + private func chooseFolder() { + let panel = NSOpenPanel() + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.allowsMultipleSelection = true + panel.prompt = localization.string("detail.purgeRoots.choose", defaultValue: "选择") + panel.message = localization.string( + "detail.purgeRoots.chooseMessage", + defaultValue: "选择具体的工程文件夹(不要选个人文件夹或系统目录)" + ) + guard panel.runModal() == .OK else { return } + for url in panel.urls { + model.add(url.path) + } + } +} diff --git a/Plugins/DiskClean/Sources/DiskCleanPurgeScanner.swift b/Plugins/DiskClean/Sources/DiskCleanPurgeScanner.swift new file mode 100644 index 00000000..c138d2e6 --- /dev/null +++ b/Plugins/DiskClean/Sources/DiskCleanPurgeScanner.swift @@ -0,0 +1,566 @@ +import Darwin +import Foundation + +// MARK: - Target kinds + +/// Developer-artifact kind (design §10.1). +/// +/// Each kind binds a set of **project markers**: only a same-level marker file counts as a hit. +/// That is the only false-positive defense—`~/Documents/build` may well be a photo folder, and +/// so may `~/Pictures/dist`; a wrong delete costs far more than a miss. +/// `__pycache__` is the only unconditional kind: CPython owns that name exclusively, so there is +/// no ambiguity. +enum DiskCleanPurgeKind: String, CaseIterable, Equatable, Sendable { + case nodeModules + case rustTarget + case buildOutput + case distOutput + case pythonCache + + /// Directory name. Classification looks only at this name, not the rest of the path. + var directoryName: String { + switch self { + case .nodeModules: + return "node_modules" + case .rustTarget: + return "target" + case .buildOutput: + return "build" + case .distOutput: + return "dist" + case .pythonCache: + return "__pycache__" + } + } + + /// Sibling project-marker candidates: any one match is enough. Empty array means unconditional. + var projectMarkers: [String] { + switch self { + case .nodeModules: + return ["package.json"] + case .rustTarget: + return ["Cargo.toml"] + case .buildOutput, .distOutput: + return ["package.json", "setup.py", "pyproject.toml"] + case .pythonCache: + return [] + } + } + + var displayName: String { + switch self { + case .nodeModules: + return "Node 依赖" + case .rustTarget: + return "Rust 编译产物" + case .buildOutput: + return "构建输出" + case .distOutput: + return "打包输出" + case .pythonCache: + return "Python 字节码缓存" + } + } + + /// Stable synthetic target ID (`DiskCleanRuleCatalogV2` builds targets from this). + /// Written as-is into audit logs; changing it changes historical record semantics. + var targetID: String { + "purge." + directoryName + } + + /// Directory name → kind. Names are unique, so use a dictionary rather than sequential compares. + static let byDirectoryName: [String: DiskCleanPurgeKind] = Dictionary( + uniqueKeysWithValues: DiskCleanPurgeKind.allCases.map { ($0.directoryName, $0) } + ) +} + +// MARK: - Git state + +/// Git state of the repository containing the candidate (design §10.1 git warning). +/// +/// **Fail-safe direction is fixed**: if inspection fails, treat as dirty. A false "dirty" only +/// skips default selection (user can still check); a false "clean" may let users unknowingly +/// delete workspace state that uncommitted build artifacts depend on. +enum DiskCleanPurgeGitState: Equatable, Sendable { + /// No `.git` found within the root boundary. No badge; treat as a normal candidate. + case notInRepository + case clean(repositoryPath: String) + case dirty(repositoryPath: String, reason: DirtyReason) + + enum DirtyReason: Equatable, Sendable { + /// `git status --porcelain -unormal` produced output. + case uncommittedChanges + /// `git log --branches --not --remotes` produced output. + case unpushedCommits + /// git missing, timed out (2s), or non-zero exit—treat as dirty. + case inspectionFailed(String) + } + + var isDirty: Bool { + if case .dirty = self { return true } + return false + } + + var repositoryPath: String? { + switch self { + case .notInRepository: + return nil + case let .clean(repositoryPath): + return repositoryPath + case let .dirty(repositoryPath, _): + return repositoryPath + } + } +} + +// MARK: - Discovery results + +/// One hit from the traversal phase. Git check and sizing are not done yet—both are filled in later. +struct DiskCleanPurgeDiscoveredItem: Equatable, Sendable { + /// **Physical path**: root is `realpath`-normalized and each level is opened no-follow, so the + /// joined result has no symlink ancestors and can go straight to sizing and removal primitives + /// (design §13-6). + let path: String + let kind: DiskCleanPurgeKind + /// Project-marker filename that matched. `__pycache__` is unconditional, so nil. + let projectMarker: String? + /// Directory containing the marker—the project root. + let projectPath: String + /// Nearest `.git` ancestor (including the scan root itself; never past the root boundary). + /// nil = not in a repository. + let repositoryPath: String? +} + +/// Traversal status of a single scan root. +enum DiskCleanPurgeRootStatus: Equatable, Sendable { + /// Never opened—directory deleted, TCC denied, or non-local volume. UI must distinguish this + /// from "scanned but no candidates". + case unreadable(reason: DiskCleanScanCompleteness.PartialReason) + /// Traversed. `completeness` records subtrees skipped along the way (permission, mount point; + /// depth truncation is not counted). + case traversed(completeness: DiskCleanScanCompleteness) +} + +struct DiskCleanPurgeDiscoveryReport: Equatable, Sendable { + let root: String + let status: DiskCleanPurgeRootStatus + let items: [DiskCleanPurgeDiscoveredItem] +} + +/// Full candidate description with git state. Stage two mints a unified-pipeline `DiskCleanCandidate` from this. +struct DiskCleanPurgeCandidate: Identifiable, Equatable, Sendable { + let item: DiskCleanPurgeDiscoveredItem + let gitState: DiskCleanPurgeGitState + + var id: String { item.path } + var path: String { item.path } + var kind: DiskCleanPurgeKind { item.kind } + var projectMarker: String? { item.projectMarker } + var projectPath: String { item.projectPath } + + /// Default-selection policy: not selected when the repo is dirty (including inspection failure); otherwise selected. + var isSelectedByDefault: Bool { !gitState.isDirty } +} + +struct DiskCleanPurgeRootReport: Equatable, Sendable { + let root: String + let status: DiskCleanPurgeRootStatus + let candidates: [DiskCleanPurgeCandidate] +} + +struct DiskCleanPurgeScanResult: Equatable, Sendable { + let reports: [DiskCleanPurgeRootReport] + + var candidates: [DiskCleanPurgeCandidate] { + reports.flatMap(\.candidates) + } + + /// Roots that could not be opened at all. UI uses this for "folder removed or no access" rather than an empty list. + var unreadableRoots: [String] { + reports.compactMap { report in + if case .unreadable = report.status { return report.root } + return nil + } + } +} + +// MARK: - Traversal + +/// Developer-artifact discovery walk (design §10.1). **Blocking**; called by `DiskCleanPurgeScanner` +/// on a background queue. +/// +/// Uses fd-relative `readdir` (reuses SlowWalker's entry source) rather than `FileManager.enumerator`: +/// the latter has no no-follow or device constraints and will follow symlinks out of the scan root +/// and silently cross mount points. This is discovery only and deletes nothing, but produced paths +/// go straight to removal primitives, so they must be trustworthy physical paths. +/// +/// Depth cap is 6 levels; prune on hit: `node_modules` contains thousands of nested `node_modules`, +/// and reporting any from the second level on is pointless—deleting the outer one already deletes them. +struct DiskCleanPurgeDiscovery: Sendable { + static let defaultMaximumDepth = 6 + + /// Maximum depth at which candidates can match (root is 0). Directories at the cap still + /// participate in matching but are not enumerated further. + let maximumDepth: Int + private let opener: DiskCleanRootOpener + private let sourceFactory: any DiskCleanDirectoryEntrySourceFactory + + init( + maximumDepth: Int = defaultMaximumDepth, + opener: DiskCleanRootOpener = DiskCleanRootOpener(), + sourceFactory: any DiskCleanDirectoryEntrySourceFactory = DiskCleanDirectoryStreamEntrySourceFactory() + ) { + self.maximumDepth = max(maximumDepth, 1) + self.opener = opener + self.sourceFactory = sourceFactory + } + + func discover(root: String, isCancelled: () -> Bool = { false }) -> DiskCleanPurgeDiscoveryReport { + switch opener.open(path: root) { + case let .failed(reason): + return DiskCleanPurgeDiscoveryReport(root: root, status: .unreadable(reason: reason), items: []) + + case .resolved: + // Root is not a directory (file / symlink / socket). Nothing to traverse; report unreadable honestly. + return DiskCleanPurgeDiscoveryReport(root: root, status: .unreadable(reason: .walkError), items: []) + + case let .directory(fileDescriptor, identity): + var state = TraversalState(rootDevice: identity.devid) + let rootRepository = Self.containsGitEntry(directoryFileDescriptor: fileDescriptor) ? root : nil + visit( + fileDescriptor: fileDescriptor, + path: root, + depth: 0, + repositoryPath: rootRepository, + isCancelled: isCancelled, + state: &state + ) + // readdir order is filesystem-defined; sorting stabilizes both the list and tests. + return DiskCleanPurgeDiscoveryReport( + root: root, + status: .traversed(completeness: state.accumulator.completeness), + items: state.items.sorted { $0.path < $1.path } + ) + } + } + + // MARK: Internals + + private struct TraversalState { + let rootDevice: UInt64 + var items: [DiskCleanPurgeDiscoveredItem] = [] + var accumulator = DiskCleanCompletenessAccumulator() + } + + /// Recursion rather than an explicit stack: with depth cap 6, stack and fd use stay constant- + /// scale and the readability is worth it (executor delete prewalk caps at 128 and must use an + /// explicit stack there). + /// + /// Takes ownership of `fileDescriptor`. + private func visit( + fileDescriptor: Int32, + path: String, + depth: Int, + repositoryPath: String?, + isCancelled: () -> Bool, + state: inout TraversalState + ) { + let source: any DiskCleanDirectoryEntrySource + do { + source = try sourceFactory.makeSource(fileDescriptor: fileDescriptor) + } catch { + // Protocol contract: on makeSource throw, fd ownership remains with the caller. + close(fileDescriptor) + state.accumulator.add(errno: (error as? DiskCleanPOSIXError)?.code ?? EIO) + return + } + defer { source.close() } + + while true { + guard !isCancelled() else { + // Same cancel exit as sizing: mark result incomplete so the caller knows this is not "finished empty". + state.accumulator.add(.timedOut) + return + } + + let batch: [DiskCleanWalkEntry]? + do { + batch = try source.nextBatch() + } catch { + state.accumulator.add(errno: (error as? DiskCleanPOSIXError)?.code ?? EIO) + return + } + guard let batch else { return } + + for entry in batch { + guard case let .resolved(resolved) = entry else { + state.accumulator.add(.walkError) + continue + } + // Never follow or report symlinks: following leaves the scan root; reporting would make users think the target is deleted. + guard resolved.fileType == .directory else { continue } + guard let name = Self.name(of: resolved) else { + // Filename is not valid UTF-8 and cannot safely become a candidate path. Skip one tree; fail-safe. + state.accumulator.add(.walkError) + continue + } + + let childPath = path + "/" + name + let childDepth = depth + 1 + + if let kind = DiskCleanPurgeKind.byDirectoryName[name], + let match = Self.projectMarker(for: kind, directoryFileDescriptor: source.directoryFileDescriptor) { + state.items.append( + DiskCleanPurgeDiscoveredItem( + path: childPath, + kind: kind, + projectMarker: match.markerName, + projectPath: path, + repositoryPath: repositoryPath + ) + ) + continue // prune on hit + } + + guard childDepth < maximumDepth else { continue } + guard resolved.devid == state.rootDevice else { + // Do not descend into mount points: another volume has its own free space and permission model; scan-root authorization does not cover it. + state.accumulator.add(.crossedMountPoint) + continue + } + + descend( + name: resolved.nameBytes, + parentFileDescriptor: source.directoryFileDescriptor, + childPath: childPath, + depth: childDepth, + inheritedRepositoryPath: repositoryPath, + isCancelled: isCancelled, + state: &state + ) + } + } + } + + private func descend( + name: [CChar], + parentFileDescriptor: Int32, + childPath: String, + depth: Int, + inheritedRepositoryPath: String?, + isCancelled: () -> Bool, + state: inout TraversalState + ) { + // Single-component `openat` + `O_NOFOLLOW`: if the entry becomes a symlink between readdir and open, fail with ELOOP. + let childDescriptor = name.withUnsafeBufferPointer { buffer -> Int32 in + guard let base = buffer.baseAddress else { return -1 } + return openat(parentFileDescriptor, base, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_NONBLOCK) + } + guard childDescriptor >= 0 else { + state.accumulator.add(errno: errno) + return + } + + // Nearest `.git` overrides the inherited value: nested repos (submodules, independent monorepo repos) use the nearest. + let repositoryPath = Self.containsGitEntry(directoryFileDescriptor: childDescriptor) + ? childPath + : inheritedRepositoryPath + + visit( + fileDescriptor: childDescriptor, + path: childPath, + depth: depth, + repositoryPath: repositoryPath, + isCancelled: isCancelled, + state: &state + ) + } + + /// Project-marker match result. `unconditional` must be distinct from "matched a marker": + /// the former has no marker basis to show in the candidate description, not "marker is empty string". + private enum MarkerMatch { + case unconditional + case matched(String) + + var markerName: String? { + if case let .matched(name) = self { return name } + return nil + } + } + + /// Returns nil = no match, not a candidate. + private static func projectMarker( + for kind: DiskCleanPurgeKind, + directoryFileDescriptor: Int32 + ) -> MarkerMatch? { + guard !kind.projectMarkers.isEmpty else { return .unconditional } + for marker in kind.projectMarkers + where existsAsFile(name: marker, directoryFileDescriptor: directoryFileDescriptor) { + return .matched(marker) + } + return nil + } + + /// Marker must be a file or symlink, not a directory: in monorepos `package.json` may be a + /// symlink, but a **directory** named `package.json` is only a coincidence, not a project root. + private static func existsAsFile(name: String, directoryFileDescriptor: Int32) -> Bool { + var status = stat() + guard fstatat(directoryFileDescriptor, name, &status, AT_SYMLINK_NOFOLLOW) == 0 else { return false } + return DiskCleanRootIdentity.FileType(mode: status.st_mode) != .directory + } + + /// `.git` may be a directory (normal repo) or a file (worktree / submodule gitdir pointer); both count. + private static func containsGitEntry(directoryFileDescriptor: Int32) -> Bool { + var status = stat() + return fstatat(directoryFileDescriptor, ".git", &status, AT_SYMLINK_NOFOLLOW) == 0 + } + + private static func name(of entry: DiskCleanResolvedEntry) -> String? { + let bytes = entry.nameBytes.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) } + // Prefer String(bytes:encoding:) over String(decoding:): the latter replaces invalid bytes + // with U+FFFD, producing a path to a non-existent file that is useless to removal primitives. + return String(bytes: bytes, encoding: .utf8) + } +} + +// MARK: - Git inspection + +/// Repository dirty-state check. Both commands use the `DiskCleanSubprocessRunning` seam with a 2s timeout (design §10.1). +struct DiskCleanGitStatusInspector: Sendable { + static let executablePath = "/usr/bin/git" + static let timeout = Duration.seconds(2) + + private let runner: any DiskCleanSubprocessRunning + + init(runner: any DiskCleanSubprocessRunning = LocalDiskCleanSubprocessRunner()) { + self.runner = runner + } + + func inspect(repositoryPath: String) async -> DiskCleanPurgeGitState { + do { + let status = try await run( + arguments: ["-C", repositoryPath, "status", "--porcelain", "-unormal"] + ) + guard status.exitCode == 0 else { + return .dirty(repositoryPath: repositoryPath, reason: .inspectionFailed("status 退出码 \(status.exitCode)")) + } + if !Self.isBlank(status.standardOutput) { + return .dirty(repositoryPath: repositoryPath, reason: .uncommittedChanges) + } + + // Unpushed commits: commits on local branches not on any remote. One is enough, hence -n 1. + let unpushed = try await run( + arguments: ["-C", repositoryPath, "log", "--branches", "--not", "--remotes", "-n", "1"] + ) + guard unpushed.exitCode == 0 else { + return .dirty(repositoryPath: repositoryPath, reason: .inspectionFailed("log 退出码 \(unpushed.exitCode)")) + } + if !Self.isBlank(unpushed.standardOutput) { + return .dirty(repositoryPath: repositoryPath, reason: .unpushedCommits) + } + return .clean(repositoryPath: repositoryPath) + } catch { + return .dirty(repositoryPath: repositoryPath, reason: .inspectionFailed(Self.describe(error))) + } + } + + private func run(arguments: [String]) async throws -> DiskCleanSubprocessResult { + try await runner.run( + executablePath: Self.executablePath, + arguments: arguments, + timeout: Self.timeout + ) + } + + /// Only blank output counts as clean: git emits empty string with no changes; shell-wrapped paths may add a newline. + private static func isBlank(_ output: Data) -> Bool { + String(decoding: output, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + private static func describe(_ error: Error) -> String { + switch error { + case DiskCleanSubprocessError.timedOut: + return "git 检查超时" + case DiskCleanSubprocessError.executableUnavailable: + return "未找到 git" + case let DiskCleanSubprocessError.launchFailed(_, message): + return "git 启动失败:\(message)" + default: + return "git 检查失败" + } + } +} + +// MARK: - Scanner + +/// Developer-artifact purge scanner (design §10.1). +/// +/// Orchestrates two jobs: blocking traversal on a background thread, and one git inspection per +/// repository (a single repo often has dozens of `node_modules`; spawning per item would multiply +/// the 2s timeout by dozens). +struct DiskCleanPurgeScanner: Sendable { + private let discovery: DiskCleanPurgeDiscovery + private let inspector: DiskCleanGitStatusInspector + + init( + discovery: DiskCleanPurgeDiscovery = DiskCleanPurgeDiscovery(), + inspector: DiskCleanGitStatusInspector = DiskCleanGitStatusInspector() + ) { + self.discovery = discovery + self.inspector = inspector + } + + func scan(roots: [String]) async -> DiskCleanPurgeScanResult { + var reports: [DiskCleanPurgeRootReport] = [] + var inspectedRepositories: [String: DiskCleanPurgeGitState] = [:] + + for root in roots { + let discovered = await discoverInBackground(root: root) + var candidates: [DiskCleanPurgeCandidate] = [] + candidates.reserveCapacity(discovered.items.count) + + for item in discovered.items { + guard let repository = item.repositoryPath else { + candidates.append(DiskCleanPurgeCandidate(item: item, gitState: .notInRepository)) + continue + } + let state: DiskCleanPurgeGitState + if let cached = inspectedRepositories[repository] { + state = cached + } else { + state = await inspector.inspect(repositoryPath: repository) + inspectedRepositories[repository] = state + } + candidates.append(DiskCleanPurgeCandidate(item: item, gitState: state)) + } + + reports.append( + DiskCleanPurgeRootReport(root: root, status: discovered.status, candidates: candidates) + ) + } + + return DiskCleanPurgeScanResult(reports: reports) + } + + /// Traversal blocks in `readdir`/`openat` and must not occupy Swift concurrency's cooperative + /// thread pool. Depth cap 6 bounds the cost, so WorkerPool abandon budgets are unnecessary— + /// that machinery is for sizing that can hang forever. + /// + /// Blocking traversal does not observe `Task.isCancelled`; cancel sets `DiskCleanCancellationFlag` + /// (same as WorkerPool) and traversal polls it per batch. + private func discoverInBackground(root: String) async -> DiskCleanPurgeDiscoveryReport { + let flag = DiskCleanCancellationFlag() + let discovery = self.discovery + return await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + DispatchQueue.global(qos: .utility).async { + continuation.resume( + returning: discovery.discover(root: root, isCancelled: { flag.isSet }) + ) + } + } + } onCancel: { + flag.set() + } + } +} diff --git a/Plugins/DiskClean/Sources/DiskCleanRemovalPrimitive.swift b/Plugins/DiskClean/Sources/DiskCleanRemovalPrimitive.swift new file mode 100644 index 00000000..0467b2fa --- /dev/null +++ b/Plugins/DiskClean/Sources/DiskCleanRemovalPrimitive.swift @@ -0,0 +1,646 @@ +import Darwin +import Foundation + +// MARK: - Terminal disposition + +/// Terminal disposition for one item (design §7.5). +/// +/// Every case is an **honest** record: mid-delete failure is not reported as success; blocked +/// rollback is not reported as rolled back. +enum DiskCleanRemovalDisposition: Equatable, Sendable { + /// Permanent delete finished; staged object no longer exists. + case removed + /// Moved to Trash. Restoring will land under the staged name, so that name is returned too + /// (design §7.4 trade-off). + case trashed(stagedName: String) + /// Identity recheck mismatch: object was replaced or changed after the scan. Original object + /// was **not touched**. + case changedSinceScan + /// Failed before freeze, or failed after freeze and rolled back successfully. Original path object intact. + case failed(reason: String) + /// Mid-delete I/O failure; tree already half-deleted. Rollback is meaningless; debris stays under the staged name. + case partiallyDeleted(stagedName: String, reason: String) + /// Post-freeze disposition failed and rollback was blocked (original path already rebuilt). + /// Staged object retained for reconciliation. + case rollbackBlocked(stagedName: String, reason: String) +} + +/// Seam for the verify-freeze-delete primitive. Executor depends only on this protocol; tests inject fakes here. +protocol DiskCleanPlanItemRemoving: Sendable { + func remove( + _ item: DiskCleanValidatedPlan.PlanItem, + mode: DiskCleanRemovalMode + ) -> DiskCleanRemovalDisposition +} + +// MARK: - Injection seams + +/// Trash seam. +/// +/// Separate protocol not only for testability: the real implementation puts objects in the user's +/// Trash, and the repo hard-requires "filesystem tests must never touch real user directories", so +/// tests must be able to block this step. +protocol DiskCleanTrashing: Sendable { + func trashItem(atPath path: String) throws +} + +struct DiskCleanSystemTrash: DiskCleanTrashing { + func trashItem(atPath path: String) throws { + try FileManager.default.trashItem(at: URL(fileURLWithPath: path), resultingItemURL: nil) + } +} + +/// Source of entry device IDs for prewalk (design §7.4 "whole-tree devid consistency"). +/// +/// Mount crossings cannot be constructed in a real-filesystem temp directory—mounting needs root. +/// Device-ID checks therefore need an injection point; otherwise this safety branch is only +/// reviewable, never testable. Same reason M1 left an entry-source seam on the walker. +protocol DiskCleanStagedEntryDeviceResolving: Sendable { + func deviceID(ofEntry nameBytes: [CChar], statResult: stat) -> UInt64 +} + +struct DiskCleanRealStagedEntryDeviceResolver: DiskCleanStagedEntryDeviceResolving { + func deviceID(ofEntry nameBytes: [CChar], statResult: stat) -> UInt64 { + UInt64(UInt32(bitPattern: statResult.st_dev)) + } +} + +// MARK: - Primitive + +/// Verify-freeze-delete primitive (design §7.3, §7.4). The safety core. +/// +/// Order is not interchangeable; each step closes a window: +/// 1. Open parent with `O_NOFOLLOW_ANY` + `fstatfs` local-volume check—any path component replaced +/// by a symlink fails here. +/// 2. `fstatat(AT_SYMLINK_NOFOLLOW)` rechecks `(devid, fileID, fileType, mtime)` (regular files also +/// compare size)—objects replaced after the scan are stopped here; never mis-delete. +/// 3. Journal is written and **fsync succeeds** before rename—the reverse would leave "unlogged +/// staged objects" that SafetyPolicy protects from scans while nobody knows their original name, +/// so user data effectively vanishes. +/// 4. `renameatx_np(RENAME_EXCL)` atomically renames to the staged name—object leaves the original +/// path and the path-replacement window closes; `RENAME_EXCL` never overwrites an existing object. +/// 5. Disposition (trash / recursive delete) always acts on the **staged path**. +/// +/// Residual risk (design §7.7): deleting a directory deletes its full contents **at execution time**. +/// Root identity match ≠ deep contents match the scan—root mtime only reflects direct-child +/// add/remove. Acceptable semantics for cache directories. +struct DiskCleanRemovalPrimitive: DiskCleanPlanItemRemoving { + /// Staged-name prefix. SafetyPolicy uses it to keep staged objects out of every scan's candidates (§7.6). + static let stagedNamePrefix = ".mactools-staged-" + /// Depth cap for prewalk and recursive delete: each level holds a directory fd; uncapped would + /// hit EMFILE or blow the stack. Hitting the cap fails closed—do not risk continuing. + static let maximumTreeDepth = 128 + + private let journal: DiskCleanStagingJournal + private let trash: any DiskCleanTrashing + private let deviceResolver: any DiskCleanStagedEntryDeviceResolving + private let stagedNameFactory: @Sendable () -> String + private let now: @Sendable () -> Date + + init( + journal: DiskCleanStagingJournal, + trash: any DiskCleanTrashing = DiskCleanSystemTrash(), + deviceResolver: any DiskCleanStagedEntryDeviceResolving = DiskCleanRealStagedEntryDeviceResolver(), + stagedNameFactory: @escaping @Sendable () -> String = { + DiskCleanRemovalPrimitive.stagedNamePrefix + UUID().uuidString + }, + now: @escaping @Sendable () -> Date = Date.init + ) { + self.journal = journal + self.trash = trash + self.deviceResolver = deviceResolver + self.stagedNameFactory = stagedNameFactory + self.now = now + } + + func remove( + _ item: DiskCleanValidatedPlan.PlanItem, + mode: DiskCleanRemovalMode + ) -> DiskCleanRemovalDisposition { + guard let location = ParentAnchoredPath(path: item.path) else { + return .failed(reason: "无法解析父目录:\(item.path)") + } + + // Reject symlinks along the whole chain: any intermediate component replaced by a link elsewhere fails here. + let parentDescriptor = Darwin.open( + location.parentPath, + O_RDONLY | O_DIRECTORY | O_NOFOLLOW_ANY | O_NONBLOCK + ) + guard parentDescriptor >= 0 else { + let code = errno + // Parent gone → object cannot still be at the original place; treat as changed, not failed. + return code == ENOENT ? .changedSinceScan : .failed(reason: Self.describe(code, doing: "打开父目录")) + } + defer { close(parentDescriptor) } + + guard Self.isLocalVolume(fileDescriptor: parentDescriptor) else { + return .failed(reason: "父目录不在本地卷上") + } + + switch verifyIdentity(parentDescriptor: parentDescriptor, name: location.name, item: item) { + case .mismatch: + return .changedSinceScan + case let .failure(reason): + return .failed(reason: reason) + case .match: + break + } + + let staged: StagedObject + switch stage(parentDescriptor: parentDescriptor, location: location, mode: mode) { + case let .failure(disposition): + return disposition + case let .success(object): + staged = object + } + + // Post-freeze recheck: the object under the staged name must still be the same one. + // Under RENAME_EXCL this step "should never fail"—and "should never" is exactly the kind + // of assertion safety code must verify explicitly. + guard identityMatches(parentDescriptor: parentDescriptor, name: staged.name, expected: item.rootIdentity) else { + return rollback( + parentDescriptor: parentDescriptor, + staged: staged, + originalName: location.name, + reason: "暂存后身份复核不符", + dispositionAfterRollback: .changedSinceScan + ) + } + + switch mode { + case .trash: + return moveToTrash( + parentDescriptor: parentDescriptor, + staged: staged, + location: location + ) + case .permanent: + return deletePermanently( + parentDescriptor: parentDescriptor, + staged: staged, + location: location, + item: item + ) + } + } + + // MARK: - Identity verification (§7.3) + + private enum IdentityOutcome { + case match + case mismatch + case failure(reason: String) + } + + private func verifyIdentity( + parentDescriptor: Int32, + name: String, + item: DiskCleanValidatedPlan.PlanItem + ) -> IdentityOutcome { + var status = stat() + guard fstatat(parentDescriptor, name, &status, AT_SYMLINK_NOFOLLOW) == 0 else { + let code = errno + return code == ENOENT ? .mismatch : .failure(reason: Self.describe(code, doing: "复核对象身份")) + } + + // (devid, fileID, fileType) exact match + mtime compare. Replacement by a same-named new + // directory or symlink shows up here as a fileID or fileType difference. + guard DiskCleanRootIdentity(stat: status) == item.rootIdentity else { + return .mismatch + } + // Regular files also compare size: when content is rewritten but mtime is preserved, size is the second evidence. + if item.rootIdentity.fileType == .regularFile, status.st_size != item.estimatedBytes { + return .mismatch + } + return .match + } + + private func identityMatches( + parentDescriptor: Int32, + name: String, + expected: DiskCleanRootIdentity + ) -> Bool { + var status = stat() + guard fstatat(parentDescriptor, name, &status, AT_SYMLINK_NOFOLLOW) == 0 else { return false } + return DiskCleanRootIdentity(stat: status) == expected + } + + // MARK: - Freeze (§7.4 steps 1–2) + + private struct StagedObject { + let entryID: String + let name: String + } + + private enum StageOutcome { + case success(StagedObject) + case failure(DiskCleanRemovalDisposition) + } + + private func stage( + parentDescriptor: Int32, + location: ParentAnchoredPath, + mode: DiskCleanRemovalMode + ) -> StageOutcome { + // UUID collision probability is negligible, but RENAME_EXCL EEXIST can also mean another + // process happened to take the name—retry once with a new name; if still conflicting, give up + // (no infinite retries). + for attempt in 0..<2 { + let stagedName = stagedNameFactory() + let entry = DiskCleanStagingJournal.Entry( + id: UUID().uuidString, + timestamp: now(), + parentPath: location.parentPath, + originalName: location.name, + stagedName: stagedName, + mode: mode.rawValue + ) + + // Hard ordering rule: rename only after begin + fsync returns successfully. If the journal cannot be written, do not touch the file. + do { + try journal.begin(entry) + } catch { + return .failure(.failed(reason: "无法写入暂存日志:\(error.localizedDescription)")) + } + + let status = renameatx_np( + parentDescriptor, + location.name, + parentDescriptor, + stagedName, + UInt32(RENAME_EXCL) + ) + if status == 0 { + return .success(StagedObject(entryID: entry.id, name: stagedName)) + } + + let code = errno + // rename did not happen → no staged object; complete the entry immediately so reconciliation does not spin on it. + journal.complete(entryID: entry.id, status: "abortedBeforeRename", at: now()) + + if code == EEXIST, attempt == 0 { + continue + } + if code == ENOENT { + return .failure(.changedSinceScan) + } + return .failure(.failed(reason: Self.describe(code, doing: "暂存改名"))) + } + return .failure(.failed(reason: "暂存名连续冲突")) + } + + /// Rollback: `RENAME_EXCL` rename back to the original name. + /// + /// **Original path being rebuilt is common for cache processes** (apps rebuild their own cache + /// directories while running). Never overwrite in that case: keep the staged object, return + /// `rollbackBlocked`, leave the journal entry **incomplete**, and hand off to startup + /// reconciliation or the user. + private func rollback( + parentDescriptor: Int32, + staged: StagedObject, + originalName: String, + reason: String, + dispositionAfterRollback: DiskCleanRemovalDisposition + ) -> DiskCleanRemovalDisposition { + let status = renameatx_np( + parentDescriptor, + staged.name, + parentDescriptor, + originalName, + UInt32(RENAME_EXCL) + ) + guard status == 0 else { + let code = errno + // Leave the journal incomplete for recovery, but release the in-process active + // mark so same-process reconciliation can still pick the entry up. + journal.releaseActive(entryID: staged.entryID) + return .rollbackBlocked( + stagedName: staged.name, + reason: "\(reason);回滚失败:\(Self.describe(code, doing: "改回原名"))" + ) + } + journal.complete(entryID: staged.entryID, status: "rolledBack", at: now()) + return dispositionAfterRollback + } + + // MARK: - Trash (§7.4 step 3) + + private func moveToTrash( + parentDescriptor: Int32, + staged: StagedObject, + location: ParentAnchoredPath + ) -> DiskCleanRemovalDisposition { + // Act on the **staged path**: `trashItem` re-resolves by path; calling it on the original + // path reopens the replacement window §7.3 just closed. + let stagedPath = Self.join(location.parentPath, staged.name) + do { + try trash.trashItem(atPath: stagedPath) + } catch { + let reason = "移入废纸篓失败:\(error.localizedDescription)" + return rollback( + parentDescriptor: parentDescriptor, + staged: staged, + originalName: location.name, + reason: reason, + dispositionAfterRollback: .failed(reason: reason) + ) + } + journal.complete(entryID: staged.entryID, status: "trashed", at: now()) + return .trashed(stagedName: staged.name) + } + + // MARK: - Permanent delete (§7.4 step 3) + + private func deletePermanently( + parentDescriptor: Int32, + staged: StagedObject, + location: ParentAnchoredPath, + item: DiskCleanValidatedPlan.PlanItem + ) -> DiskCleanRemovalDisposition { + // Non-directory: single unlinkat, no prewalk. For symlinks, delete the link itself; never follow. + guard item.rootIdentity.fileType == .directory else { + guard unlinkat(parentDescriptor, staged.name, 0) == 0 else { + let reason = Self.describe(errno, doing: "删除对象") + return rollback( + parentDescriptor: parentDescriptor, + staged: staged, + originalName: location.name, + reason: reason, + dispositionAfterRollback: .failed(reason: reason) + ) + } + journal.complete(entryID: staged.entryID, status: "removed", at: now()) + return .removed + } + + // Non-destructive prewalk: walk the whole tree read-only first to confirm no mount + // crossings and no abnormal entries. Fail → rollback. Discovering problems mid-delete + // means the tree is already missing a piece, and rollback is then meaningless. + switch prewalk( + parentDescriptor: parentDescriptor, + name: staged.name, + expectedDevice: item.rootIdentity.devid, + depth: 0 + ) { + case .ok: + break + case .crossedMountPoint: + let reason = "暂存树内发现挂载点,已放弃删除" + return rollback( + parentDescriptor: parentDescriptor, + staged: staged, + originalName: location.name, + reason: reason, + dispositionAfterRollback: .failed(reason: reason) + ) + case let .failure(walkReason): + let reason = "删除前检查失败:\(walkReason)" + return rollback( + parentDescriptor: parentDescriptor, + staged: staged, + originalName: location.name, + reason: reason, + dispositionAfterRollback: .failed(reason: reason) + ) + } + + if let reason = deleteTree(parentDescriptor: parentDescriptor, name: staged.name, depth: 0) { + // **Design deviation**: §7.4 says "keep the journal entry on partiallyDeleted"; here + // we complete it explicitly. Keeping the entry would let next startup's reconciliation + // rename this **half-deleted broken tree** back to the original path—apps would treat + // it as a healthy cache. Leaving debris under the staged name, surfaced by audit and + // clean history, is more honest than auto-"restoring" a broken directory. + journal.complete(entryID: staged.entryID, status: "partiallyDeleted", at: now()) + return .partiallyDeleted(stagedName: staged.name, reason: reason) + } + journal.complete(entryID: staged.entryID, status: "removed", at: now()) + return .removed + } + + // MARK: - fd-relative tree walk + + private enum TreeOutcome { + case ok + case crossedMountPoint + case failure(reason: String) + } + + /// Read-only prewalk: fully fd-relative addressing; per-entry `fstatat(AT_SYMLINK_NOFOLLOW)` device-ID check. + private func prewalk( + parentDescriptor: Int32, + name: String, + expectedDevice: UInt64, + depth: Int + ) -> TreeOutcome { + guard depth < Self.maximumTreeDepth else { + return .failure(reason: "目录层级超过 \(Self.maximumTreeDepth) 层") + } + guard let directory = OpenDirectory(parentDescriptor: parentDescriptor, name: name) else { + let code = errno + return .failure(reason: Self.describe(code, doing: "打开暂存目录")) + } + defer { directory.close() } + + var childDirectories: [[CChar]] = [] + while let entry = directory.next() { + var status = stat() + let statResult = entry.nameBytes.withUnsafeBufferPointer { buffer -> Int32 in + guard let base = buffer.baseAddress else { return -1 } + return fstatat(directory.descriptor, base, &status, AT_SYMLINK_NOFOLLOW) + } + guard statResult == 0 else { + return .failure(reason: Self.describe(errno, doing: "读取条目属性")) + } + guard deviceResolver.deviceID(ofEntry: entry.nameBytes, statResult: status) == expectedDevice else { + return .crossedMountPoint + } + if DiskCleanRootIdentity.FileType(mode: status.st_mode) == .directory { + childDirectories.append(entry.nameBytes) + } + } + if let code = directory.readError { + return .failure(reason: Self.describe(code, doing: "枚举暂存目录")) + } + + for childName in childDirectories { + let outcome = childName.withUnsafeBufferPointer { buffer -> TreeOutcome in + guard let base = buffer.baseAddress else { + return .failure(reason: "条目名为空") + } + return prewalk( + parentDescriptor: directory.descriptor, + name: String(cString: base), + expectedDevice: expectedDevice, + depth: depth + 1 + ) + } + guard case .ok = outcome else { return outcome } + } + return .ok + } + + /// fd recursive delete. Returns nil when the whole tree is gone; otherwise the first failure reason. + /// + /// Entry names stay as raw `[CChar]` bytes with no `String` round-trip: illegal UTF-8 names + /// converted to String and back lose bytes and would point at a different (or missing) object on delete. + private func deleteTree(parentDescriptor: Int32, name: String, depth: Int) -> String? { + guard depth < Self.maximumTreeDepth else { + return "目录层级超过 \(Self.maximumTreeDepth) 层" + } + guard let directory = OpenDirectory(parentDescriptor: parentDescriptor, name: name) else { + return Self.describe(errno, doing: "打开暂存目录") + } + + // Read the whole directory before deleting: interleaving readdir and unlink makes stream position semantics implementation-dependent. + var entries: [(nameBytes: [CChar], isDirectory: Bool)] = [] + while let entry = directory.next() { + var status = stat() + let statResult = entry.nameBytes.withUnsafeBufferPointer { buffer -> Int32 in + guard let base = buffer.baseAddress else { return -1 } + return fstatat(directory.descriptor, base, &status, AT_SYMLINK_NOFOLLOW) + } + guard statResult == 0 else { + directory.close() + return Self.describe(errno, doing: "读取条目属性") + } + entries.append((entry.nameBytes, DiskCleanRootIdentity.FileType(mode: status.st_mode) == .directory)) + } + if let code = directory.readError { + directory.close() + return Self.describe(code, doing: "枚举暂存目录") + } + + var failureReason: String? + for entry in entries where failureReason == nil { + if entry.isDirectory { + let childName = entry.nameBytes.withUnsafeBufferPointer { buffer -> String? in + buffer.baseAddress.map { String(cString: $0) } + } + guard let childName else { + failureReason = "条目名为空" + continue + } + failureReason = deleteTree( + parentDescriptor: directory.descriptor, + name: childName, + depth: depth + 1 + ) + } else { + let result = entry.nameBytes.withUnsafeBufferPointer { buffer -> Int32 in + guard let base = buffer.baseAddress else { return -1 } + return unlinkat(directory.descriptor, base, 0) + } + if result != 0, errno != ENOENT { + failureReason = Self.describe(errno, doing: "删除文件") + } + } + } + + // Release the directory fd before rmdir of itself; otherwise unlinkat fails more easily on busy mount points. + directory.close() + if let failureReason { + return failureReason + } + if unlinkat(parentDescriptor, name, AT_REMOVEDIR) != 0, errno != ENOENT { + return Self.describe(errno, doing: "删除目录") + } + return nil + } + + // MARK: - Utilities + + private static func isLocalVolume(fileDescriptor: Int32) -> Bool { + var fileSystem = statfs() + guard fstatfs(fileDescriptor, &fileSystem) == 0 else { return false } + return fileSystem.f_flags & UInt32(MNT_LOCAL) != 0 + } + + static func join(_ parentPath: String, _ name: String) -> String { + parentPath == "/" ? "/" + name : parentPath + "/" + name + } + + private static func describe(_ code: Int32, doing action: String) -> String { + "\(action)失败(\(String(cString: strerror(code))))" + } +} + +// MARK: - Directory stream + +/// RAII wrapper around `openat` + `fdopendir`. +/// +/// `closedir` also closes the underlying fd, so fd ownership has exactly one owner throughout: this type. +private final class OpenDirectory { + struct Entry { + /// NUL-terminated raw byte name, passed straight back to `fstatat` / `unlinkat`. + let nameBytes: [CChar] + } + + let descriptor: Int32 + private let stream: UnsafeMutablePointer + private var isClosed = false + /// `readdir` error code. nil means enumeration finished normally. + private(set) var readError: Int32? + + init?(parentDescriptor: Int32, name: String) { + // Single component + parent already fd-anchored, so O_NOFOLLOW is enough; never follow symlinks. + let descriptor = openat(parentDescriptor, name, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_NONBLOCK) + guard descriptor >= 0 else { return nil } + guard let stream = fdopendir(descriptor) else { + let code = errno + // This type has its own close(); without the module qualifier it would resolve to the instance method. + Darwin.close(descriptor) + errno = code + return nil + } + self.descriptor = descriptor + self.stream = stream + } + + deinit { + close() + } + + func next() -> Entry? { + guard !isClosed else { return nil } + while true { + errno = 0 + guard let directoryEntry = readdir(stream) else { + let code = errno + if code != 0 { + readError = code + } + return nil + } + guard let nameBytes = Self.nameBytes(of: directoryEntry), !Self.isDotEntry(nameBytes) else { + continue + } + return Entry(nameBytes: nameBytes) + } + } + + func close() { + guard !isClosed else { return } + isClosed = true + closedir(stream) + } + + private static func nameBytes(of entry: UnsafeMutablePointer) -> [CChar]? { + let length = Int(entry.pointee.d_namlen) + guard length > 0 else { return nil } + return withUnsafePointer(to: entry.pointee.d_name) { tuplePointer in + let characters = UnsafeRawPointer(tuplePointer).assumingMemoryBound(to: CChar.self) + var bytes = Array(UnsafeBufferPointer(start: characters, count: length)) + bytes.append(0) + return bytes + } + } + + private static func isDotEntry(_ nameBytes: [CChar]) -> Bool { + let dot = CChar(UInt8(ascii: ".")) + switch nameBytes.count { + case 2: return nameBytes[0] == dot + case 3: return nameBytes[0] == dot && nameBytes[1] == dot + default: return false + } + } +} diff --git a/Plugins/DiskClean/Sources/DiskCleanRootOpener.swift b/Plugins/DiskClean/Sources/DiskCleanRootOpener.swift new file mode 100644 index 00000000..0a8e886a --- /dev/null +++ b/Plugins/DiskClean/Sources/DiskCleanRootOpener.swift @@ -0,0 +1,133 @@ +import Darwin +import Foundation + +/// Root open result (design §3.2 typing). +enum DiskCleanRootOpenOutcome: Equatable { + /// Directory: must be walked. **fd ownership transfers to the caller**; must close. + case directory(fileDescriptor: Int32, identity: DiskCleanRootIdentity) + /// Regular file / symlink / other non-directory: size and identity already known; no walk needed. + case resolved(bytes: Int64, identity: DiskCleanRootIdentity) + /// Could not open or not accepted. + case failed(reason: DiskCleanScanCompleteness.PartialReason) +} + +/// Unified entry for all sizing and execution. +/// +/// **Callers must pass a physical path** (no symlink ancestors). `O_NOFOLLOW_ANY` rejects a symlink +/// at any path component, and on macOS `/var -> private/var` and `/tmp -> private/tmp` are themselves +/// symlinks: `/var/folders/...` fails with ELOOP and degrades to `partial([.walkError])`, so the +/// candidate is not cleanable. Rule globs that touch these locations must write `/private/var/...`. +/// Normalize with `realpath(3)`—`URL.resolvingSymlinksInPath()` **does not** expand `/var`. +/// +/// Key constraints (design §3.2): +/// - `O_NOFOLLOW_ANY` rejects a symlink at **any path component**, not just the leaf—blocks +/// "middle directory replaced by a symlink into Documents". +/// - `O_NONBLOCK` is required, not optional: `O_RDONLY` on a FIFO with no writer blocks forever, +/// and FIFOs/sockets can appear in cache directories; a hang would burn the WorkerPool abandon budget. +/// - After open, `fstatfs` checks `MNT_LOCAL`; non-local volumes are rejected. +struct DiskCleanRootOpener: Sendable { + init() {} + + func open(path: String) -> DiskCleanRootOpenOutcome { + let descriptor = Darwin.open(path, O_RDONLY | O_NOFOLLOW_ANY | O_NONBLOCK) + guard descriptor >= 0 else { + let code = errno + // ELOOP has two causes: leaf is a symlink (legitimate candidate; count the link itself), + // or some intermediate component is a symlink (must reject). Must distinguish; see resolveLink. + if code == ELOOP { + return resolveLink(path: path) + } + return .failed(reason: code == EPERM || code == EACCES ? .permissionDenied : .walkError) + } + + guard isLocalVolume(fileDescriptor: descriptor) else { + close(descriptor) + return .failed(reason: .unsupportedVolume) + } + + var status = stat() + guard fstat(descriptor, &status) == 0 else { + let code = errno + close(descriptor) + return .failed(reason: code == EPERM || code == EACCES ? .permissionDenied : .walkError) + } + + let identity = DiskCleanRootIdentity(stat: status) + guard identity.fileType == .directory else { + close(descriptor) + return .resolved(bytes: max(status.st_size, 0), identity: identity) + } + return .directory(fileDescriptor: descriptor, identity: identity) + } + + /// Handle open returning ELOOP. + /// + /// Design §3.2 says "switch to the lstat path and take the link itself", but a naive `lstat` + /// follows intermediate symlinks: with layout `dirlink -> realdir` + `realdir/link -> target`, + /// `lstat("dirlink/link")` succeeds and reports symlink, so intermediate-replacement attacks + /// slip through—exactly what `O_NOFOLLOW_ANY` is meant to stop. Therefore use **parent-directory + /// fd anchoring**: open the parent with `O_NOFOLLOW_ANY` (any intermediate symlink fails there), + /// then `fstatat(AT_SYMLINK_NOFOLLOW)` to confirm the leaf is the link itself. Semantics match + /// the design while truly delivering "reject symlink at any component". + private func resolveLink(path: String) -> DiskCleanRootOpenOutcome { + guard let location = ParentAnchoredPath(path: path) else { + return .failed(reason: .walkError) + } + + let parentDescriptor = Darwin.open( + location.parentPath, + O_RDONLY | O_DIRECTORY | O_NOFOLLOW_ANY | O_NONBLOCK + ) + guard parentDescriptor >= 0 else { + let code = errno + return .failed(reason: code == EPERM || code == EACCES ? .permissionDenied : .walkError) + } + defer { close(parentDescriptor) } + + guard isLocalVolume(fileDescriptor: parentDescriptor) else { + return .failed(reason: .unsupportedVolume) + } + + var status = stat() + guard fstatat(parentDescriptor, location.name, &status, AT_SYMLINK_NOFOLLOW) == 0 else { + let code = errno + return .failed(reason: code == EPERM || code == EACCES ? .permissionDenied : .walkError) + } + + let identity = DiskCleanRootIdentity(stat: status) + // Leaf is not a symlink yet open failed → ELOOP can only come from an intermediate component; reject. + guard identity.fileType == .symlink else { + return .failed(reason: .walkError) + } + return .resolved(bytes: max(status.st_size, 0), identity: identity) + } + + private func isLocalVolume(fileDescriptor: Int32) -> Bool { + var fileSystem = statfs() + guard fstatfs(fileDescriptor, &fileSystem) == 0 else { return false } + return fileSystem.f_flags & UInt32(MNT_LOCAL) != 0 + } +} + +/// Split an absolute path into "parent directory + leaf component" for fd-anchored addressing. +struct ParentAnchoredPath: Equatable { + let parentPath: String + let name: String + + init?(path: String) { + var trimmed = path + while trimmed.count > 1, trimmed.hasSuffix("/") { + trimmed.removeLast() + } + // Root has no parent and can never be a candidate. + guard trimmed != "/", !trimmed.isEmpty else { return nil } + + guard let separatorIndex = trimmed.lastIndex(of: "/") else { return nil } + let name = String(trimmed[trimmed.index(after: separatorIndex)...]) + guard !name.isEmpty, name != ".", name != ".." else { return nil } + + let parent = String(trimmed[.. [DiskCleanRuleTarget] { + targets.filter { $0.category == category } + } + + func target(id: String) -> DiskCleanRuleTarget? { + targets.first { $0.id == id } + } + + /// All targets split from one v1 rule. Audit and whitelist migration group by legacyRuleID. + func targets(legacyRuleID: String) -> [DiskCleanRuleTarget] { + targets.filter { $0.legacyRuleID == legacyRuleID } + } + + /// Display order: categories by `DiskCleanCategoryID.displayOrder` (risk low → high); within a category by target risk then id. + var targetsInDisplayOrder: [DiskCleanRuleTarget] { + DiskCleanCategoryID.displayOrder.flatMap { category in + targets(in: category).sorted { + $0.risk == $1.risk ? $0.id < $1.id : $0.risk < $1.risk + } + } + } + + // MARK: - User and app caches (v1 `cache.*` rules) + + private static let userAndAppTargets: [DiskCleanRuleTarget] = [ + DiskCleanRuleTarget( + id: "cache.user-essentials.caches", + legacyRuleID: "cache.user-essentials", + category: .userEssentials, + risk: .low, + kind: .path(globs: [ + "~/Library/Caches/*" + ]), + reservedRootPaths: [ + "~/Library/Caches" + ] + ), + DiskCleanRuleTarget( + id: "cache.user-essentials.logs", + legacyRuleID: "cache.user-essentials", + category: .logs, + risk: .low, + kind: .path(globs: [ + "~/Library/Logs/*" + ]), + reservedRootPaths: [ + "~/Library/Logs" + ] + ), + DiskCleanRuleTarget( + id: "cache.macos-app-state", + legacyRuleID: "cache.macos-app-state", + category: .systemCaches, + risk: .medium, + kind: .path(globs: [ + "~/Library/Saved Application State/*", + "~/Library/Caches/com.apple.photoanalysisd", + "~/Library/Caches/com.apple.akd", + "~/Library/Caches/com.apple.WebKit.Networking/*", + "~/Library/DiagnosticReports/*", + "~/Library/Caches/com.apple.QuickLook.thumbnailcache", + "~/Library/Caches/Quick Look/*", + "~/Library/Caches/com.apple.iconservices*", + "~/Library/Autosave Information/*", + "~/Library/IdentityCaches/*" + ]), + reservedRootPaths: [ + "~/Library/Saved Application State", + "~/Library/Caches/com.apple.photoanalysisd", + "~/Library/Caches/com.apple.akd", + "~/Library/Caches/com.apple.WebKit.Networking", + "~/Library/DiagnosticReports", + "~/Library/Caches/com.apple.QuickLook.thumbnailcache", + "~/Library/Caches/Quick Look", + "~/Library/Caches", + "~/Library/Autosave Information", + "~/Library/IdentityCaches" + ] + ), + DiskCleanRuleTarget( + id: "cache.macos-app-state.protected", + legacyRuleID: "cache.macos-app-state", + category: .systemCaches, + risk: .medium, + kind: .path(globs: [ + "~/Library/Suggestions/*", + "~/Library/Calendars/Calendar Cache", + "~/Library/Application Support/AddressBook/Sources/*/Photos.cache" + ]), + reservedRootPaths: [ + "~/Library/Suggestions", + "~/Library/Calendars/Calendar Cache", + "~/Library/Application Support/AddressBook/Sources" + ], + requiresFullDiskAccess: true + ), + DiskCleanRuleTarget( + id: "cache.apple-sandboxed-apps", + legacyRuleID: "cache.apple-sandboxed-apps", + category: .appCaches, + risk: .low, + kind: .path(globs: [ + "~/Library/Application Support/com.apple.wallpaper/aerials/thumbnails/*", + "~/Library/Caches/com.apple.helpd/*", + "~/Library/Caches/GeoServices/*" + ]), + reservedRootPaths: [ + "~/Library/Application Support/com.apple.wallpaper/aerials/thumbnails", + "~/Library/Caches/com.apple.helpd", + "~/Library/Caches/GeoServices" + ] + ), + DiskCleanRuleTarget( + id: "cache.apple-sandboxed-apps.containers", + legacyRuleID: "cache.apple-sandboxed-apps", + category: .appCaches, + risk: .low, + kind: .path(globs: [ + "~/Library/Containers/com.apple.wallpaper.agent/Data/Library/Caches/*", + "~/Library/Containers/com.apple.mediaanalysisd/Data/Library/Caches/*", + "~/Library/Containers/com.apple.mediaanalysisd/Data/tmp/*", + "~/Library/Containers/com.apple.AppStore/Data/Library/Caches/*", + "~/Library/Containers/com.apple.configurator.xpc.InternetService/Data/tmp/*", + "~/Library/Containers/com.apple.wallpaper.extension.aerials/Data/tmp/*", + "~/Library/Containers/com.apple.geod/Data/tmp/*", + "~/Library/Containers/com.apple.stocks/Data/Library/Caches/*", + "~/Library/Containers/com.apple.AvatarUI.AvatarPickerMemojiPicker/Data/Library/Caches/*", + "~/Library/Containers/com.apple.AMPArtworkAgent/Data/Library/Caches/*", + "~/Library/Containers/com.apple.CoreDevice.CoreDeviceService/Data/Library/Caches/*", + "~/Library/Containers/com.apple.NeptuneOneExtension/Data/Library/Caches/*", + "~/Library/Containers/com.apple.AppleMediaServicesUI.UtilityExtension/Data/tmp/*", + "~/Library/Group Containers/group.com.apple.contentdelivery/Logs/*", + "~/Library/Group Containers/group.com.apple.contentdelivery/Library/Logs/*" + ]), + reservedRootPaths: [ + "~/Library/Containers/com.apple.wallpaper.agent/Data/Library/Caches", + "~/Library/Containers/com.apple.mediaanalysisd/Data/Library/Caches", + "~/Library/Containers/com.apple.mediaanalysisd/Data/tmp", + "~/Library/Containers/com.apple.AppStore/Data/Library/Caches", + "~/Library/Containers/com.apple.configurator.xpc.InternetService/Data/tmp", + "~/Library/Containers/com.apple.wallpaper.extension.aerials/Data/tmp", + "~/Library/Containers/com.apple.geod/Data/tmp", + "~/Library/Containers/com.apple.stocks/Data/Library/Caches", + "~/Library/Containers/com.apple.AvatarUI.AvatarPickerMemojiPicker/Data/Library/Caches", + "~/Library/Containers/com.apple.AMPArtworkAgent/Data/Library/Caches", + "~/Library/Containers/com.apple.CoreDevice.CoreDeviceService/Data/Library/Caches", + "~/Library/Containers/com.apple.NeptuneOneExtension/Data/Library/Caches", + "~/Library/Containers/com.apple.AppleMediaServicesUI.UtilityExtension/Data/tmp", + "~/Library/Group Containers/group.com.apple.contentdelivery/Logs", + "~/Library/Group Containers/group.com.apple.contentdelivery/Library/Logs" + ], + requiresFullDiskAccess: true + ), + DiskCleanRuleTarget( + id: "cache.cloud-storage", + legacyRuleID: "cache.cloud-storage", + category: .cloudOffice, + risk: .low, + kind: .path(globs: [ + "~/Library/Caches/com.dropbox.*", + "~/Library/Caches/com.getdropbox.dropbox", + "~/Library/Caches/com.google.GoogleDrive", + "~/Library/Caches/com.baidu.netdisk", + "~/Library/Caches/com.alibaba.teambitiondisk", + "~/Library/Caches/com.box.desktop", + "~/Library/Caches/com.microsoft.OneDrive" + ]), + reservedRootPaths: [ + "~/Library/Caches", + "~/Library/Caches/com.getdropbox.dropbox", + "~/Library/Caches/com.google.GoogleDrive", + "~/Library/Caches/com.baidu.netdisk", + "~/Library/Caches/com.alibaba.teambitiondisk", + "~/Library/Caches/com.box.desktop", + "~/Library/Caches/com.microsoft.OneDrive" + ] + ), + DiskCleanRuleTarget( + id: "cache.office-apps", + legacyRuleID: "cache.office-apps", + category: .cloudOffice, + risk: .low, + kind: .path(globs: [ + "~/Library/Caches/com.microsoft.Word", + "~/Library/Caches/com.microsoft.Excel", + "~/Library/Caches/com.microsoft.Powerpoint", + "~/Library/Caches/com.microsoft.Outlook/*", + "~/Library/Caches/com.apple.iWork.*", + "~/Library/Caches/com.kingsoft.wpsoffice.mac", + "~/Library/Caches/org.mozilla.thunderbird/*", + "~/Library/Caches/com.apple.mail/*" + ]), + reservedRootPaths: [ + "~/Library/Caches/com.microsoft.Word", + "~/Library/Caches/com.microsoft.Excel", + "~/Library/Caches/com.microsoft.Powerpoint", + "~/Library/Caches/com.microsoft.Outlook", + "~/Library/Caches", + "~/Library/Caches/com.kingsoft.wpsoffice.mac", + "~/Library/Caches/org.mozilla.thunderbird", + "~/Library/Caches/com.apple.mail" + ] + ), + DiskCleanRuleTarget( + id: "cache.office-apps.containers", + legacyRuleID: "cache.office-apps", + category: .cloudOffice, + risk: .low, + kind: .path(globs: [ + "~/Library/Containers/com.microsoft.Word/Data/Library/Caches/*", + "~/Library/Containers/com.microsoft.Word/Data/tmp/*", + "~/Library/Containers/com.microsoft.Word/Data/Library/Logs/*", + "~/Library/Containers/com.microsoft.Excel/Data/Library/Caches/*", + "~/Library/Containers/com.microsoft.Excel/Data/tmp/*", + "~/Library/Containers/com.microsoft.Excel/Data/Library/Logs/*" + ]), + reservedRootPaths: [ + "~/Library/Containers/com.microsoft.Word/Data/Library/Caches", + "~/Library/Containers/com.microsoft.Word/Data/tmp", + "~/Library/Containers/com.microsoft.Word/Data/Library/Logs", + "~/Library/Containers/com.microsoft.Excel/Data/Library/Caches", + "~/Library/Containers/com.microsoft.Excel/Data/tmp", + "~/Library/Containers/com.microsoft.Excel/Data/Library/Logs" + ], + requiresFullDiskAccess: true + ), + DiskCleanRuleTarget( + id: "cache.virtualization", + legacyRuleID: "cache.virtualization", + category: .virtualization, + risk: .medium, + kind: .path(globs: [ + "~/Library/Caches/com.vmware.fusion", + "~/Library/Caches/com.parallels.*", + "~/VirtualBox VMs/.cache", + "~/.vagrant.d/tmp/*" + ]), + reservedRootPaths: [ + "~/Library/Caches/com.vmware.fusion", + "~/Library/Caches", + "~/VirtualBox VMs/.cache", + "~/.vagrant.d/tmp" + ] + ), + DiskCleanRuleTarget( + id: "cache.communication", + legacyRuleID: "cache.communication", + category: .communication, + risk: .low, + kind: .path(globs: [ + "~/Library/Application Support/discord/Cache/*", + "~/Library/Application Support/legcord/Cache/*", + "~/Library/Application Support/Slack/Cache/*", + "~/Library/Caches/us.zoom.xos/*", + "~/Library/Caches/com.tencent.xinWeChat/*", + "~/Library/Caches/ru.keepcoder.Telegram/*", + "~/Library/Caches/com.microsoft.teams2/*", + "~/Library/Caches/net.whatsapp.WhatsApp/*", + "~/Library/Caches/com.skype.skype/*", + "~/Library/Caches/com.tencent.meeting/*", + "~/Library/Caches/com.tencent.WeWorkMac/*", + "~/Library/Caches/com.feishu.*/*", + "~/Library/Application Support/Microsoft/Teams/Cache/*", + "~/Library/Application Support/Microsoft/Teams/Application Cache/*", + "~/Library/Application Support/Microsoft/Teams/Code Cache/*", + "~/Library/Application Support/Microsoft/Teams/GPUCache/*", + "~/Library/Application Support/Microsoft/Teams/logs/*", + "~/Library/Application Support/Microsoft/Teams/tmp/*" + ]), + reservedRootPaths: [ + "~/Library/Application Support/discord/Cache", + "~/Library/Application Support/legcord/Cache", + "~/Library/Application Support/Slack/Cache", + "~/Library/Caches/us.zoom.xos", + "~/Library/Caches/com.tencent.xinWeChat", + "~/Library/Caches/ru.keepcoder.Telegram", + "~/Library/Caches/com.microsoft.teams2", + "~/Library/Caches/net.whatsapp.WhatsApp", + "~/Library/Caches/com.skype.skype", + "~/Library/Caches/com.tencent.meeting", + "~/Library/Caches/com.tencent.WeWorkMac", + "~/Library/Caches", + "~/Library/Application Support/Microsoft/Teams/Cache", + "~/Library/Application Support/Microsoft/Teams/Application Cache", + "~/Library/Application Support/Microsoft/Teams/Code Cache", + "~/Library/Application Support/Microsoft/Teams/GPUCache", + "~/Library/Application Support/Microsoft/Teams/logs", + "~/Library/Application Support/Microsoft/Teams/tmp" + ] + ), + DiskCleanRuleTarget( + id: "cache.ai-assistants", + legacyRuleID: "cache.ai-assistants", + category: .aiTools, + risk: .low, + kind: .path(globs: [ + "~/Library/Caches/com.openai.chat/*", + "~/Library/Caches/com.anthropic.claudefordesktop/*", + "~/Library/Logs/Claude/*", + "~/Library/Logs/com.openai.codex/*", + "~/Library/Application Support/Codex/Cache/*", + "~/Library/Application Support/Codex/Code Cache/*", + "~/Library/Application Support/Codex/GPUCache/*", + "~/Library/Application Support/Codex/DawnGraphiteCache/*", + "~/Library/Application Support/Codex/DawnWebGPUCache/*" + ]), + reservedRootPaths: [ + "~/Library/Caches/com.openai.chat", + "~/Library/Caches/com.anthropic.claudefordesktop", + "~/Library/Logs/Claude", + "~/Library/Logs/com.openai.codex", + "~/Library/Application Support/Codex/Cache", + "~/Library/Application Support/Codex/Code Cache", + "~/Library/Application Support/Codex/GPUCache", + "~/Library/Application Support/Codex/DawnGraphiteCache", + "~/Library/Application Support/Codex/DawnWebGPUCache" + ] + ), + DiskCleanRuleTarget( + id: "cache.creative-tools", + legacyRuleID: "cache.creative-tools", + category: .appCaches, + risk: .low, + kind: .path(globs: [ + "~/Library/Caches/com.bohemiancoding.sketch3/*", + "~/Library/Application Support/com.bohemiancoding.sketch3/cache/*", + "~/Library/Caches/Adobe/*", + "~/Library/Caches/com.adobe.*/*", + "~/Library/Caches/com.figma.Desktop/*", + "~/Library/Application Support/Adobe/Common/Media Cache Files/*", + "~/Library/Caches/net.telestream.screenflow10/*", + "~/Library/Caches/com.apple.FinalCut/*", + "~/Library/Caches/com.blackmagic-design.DaVinciResolve/*", + "~/Movies/CacheClip/*", + "~/Library/Caches/com.adobe.PremierePro.*/*", + "~/Library/Caches/org.blenderfoundation.blender/*", + "~/Library/Caches/com.maxon.cinema4d/*", + "~/Library/Caches/com.autodesk.*/*", + "~/Library/Caches/com.sketchup.*/*" + ]), + reservedRootPaths: [ + "~/Library/Caches/com.bohemiancoding.sketch3", + "~/Library/Application Support/com.bohemiancoding.sketch3/cache", + "~/Library/Caches/Adobe", + "~/Library/Caches", + "~/Library/Caches/com.figma.Desktop", + "~/Library/Application Support/Adobe/Common/Media Cache Files", + "~/Library/Caches/net.telestream.screenflow10", + "~/Library/Caches/com.apple.FinalCut", + "~/Library/Caches/com.blackmagic-design.DaVinciResolve", + "~/Movies/CacheClip", + "~/Library/Caches/org.blenderfoundation.blender", + "~/Library/Caches/com.maxon.cinema4d" + ] + ), + DiskCleanRuleTarget( + id: "cache.productivity-media", + legacyRuleID: "cache.productivity-media", + category: .appCaches, + risk: .low, + kind: .path(globs: [ + "~/Library/Caches/com.tw93.MiaoYan/*", + "~/Library/Caches/com.klee.desktop/*", + "~/Library/Caches/klee_desktop/*", + "~/Library/Caches/com.orabrowser.app/*", + "~/Library/Caches/com.filo.client/*", + "~/Library/Caches/com.flomoapp.mac/*", + "~/Library/Application Support/Quark/Cache/videoCache/*", + "~/.cache/kaku/*", + "~/Library/Caches/com.spotify.client/*", + "~/Library/Caches/com.apple.Music", + "~/Library/Caches/com.apple.podcasts", + "~/Library/Caches/com.apple.TV/*", + "~/Library/Caches/tv.plex.player.desktop", + "~/Library/Caches/com.netease.163music", + "~/Library/Caches/com.tencent.QQMusic/*", + "~/Library/Caches/com.kugou.mac/*", + "~/Library/Caches/com.kuwo.mac/*", + "~/Library/Caches/com.colliderli.iina", + "~/Library/Caches/org.videolan.vlc", + "~/Library/Caches/io.mpv", + "~/Library/Caches/com.iqiyi.player", + "~/Library/Caches/com.tencent.tenvideo", + "~/Library/Caches/tv.danmaku.bili/*", + "~/Library/Caches/com.douyu.*/*", + "~/Library/Caches/com.huya.*/*", + "~/Library/Caches/smart.stremio*/*", + "~/Library/Application Support/stremio/stremio-server/stremio-cache/*" + ]), + reservedRootPaths: [ + "~/Library/Caches/com.tw93.MiaoYan", + "~/Library/Caches/com.klee.desktop", + "~/Library/Caches/klee_desktop", + "~/Library/Caches/com.orabrowser.app", + "~/Library/Caches/com.filo.client", + "~/Library/Caches/com.flomoapp.mac", + "~/Library/Application Support/Quark/Cache/videoCache", + "~/.cache/kaku", + "~/Library/Caches/com.spotify.client", + "~/Library/Caches/com.apple.Music", + "~/Library/Caches/com.apple.podcasts", + "~/Library/Caches/com.apple.TV", + "~/Library/Caches/tv.plex.player.desktop", + "~/Library/Caches/com.netease.163music", + "~/Library/Caches/com.tencent.QQMusic", + "~/Library/Caches/com.kugou.mac", + "~/Library/Caches/com.kuwo.mac", + "~/Library/Caches/com.colliderli.iina", + "~/Library/Caches/org.videolan.vlc", + "~/Library/Caches/io.mpv", + "~/Library/Caches/com.iqiyi.player", + "~/Library/Caches/com.tencent.tenvideo", + "~/Library/Caches/tv.danmaku.bili", + "~/Library/Caches", + "~/Library/Application Support/stremio/stremio-server/stremio-cache" + ] + ), + DiskCleanRuleTarget( + id: "cache.productivity-media.containers", + legacyRuleID: "cache.productivity-media", + category: .appCaches, + risk: .low, + kind: .path(globs: [ + "~/Library/Containers/com.ranchero.NetNewsWire-Evergreen/Data/Library/Caches/*", + "~/Library/Containers/com.ideasoncanvas.mindnode/Data/Library/Caches/*", + "~/Library/Containers/com.apple.podcasts/Data/tmp/StreamedMedia", + "~/Library/Containers/com.apple.podcasts/Data/tmp/*.heic", + "~/Library/Containers/com.apple.podcasts/Data/tmp/*.img", + "~/Library/Containers/com.apple.podcasts/Data/tmp/*CFNetworkDownload*.tmp" + ]), + reservedRootPaths: [ + "~/Library/Containers/com.ranchero.NetNewsWire-Evergreen/Data/Library/Caches", + "~/Library/Containers/com.ideasoncanvas.mindnode/Data/Library/Caches", + "~/Library/Containers/com.apple.podcasts/Data/tmp/StreamedMedia", + "~/Library/Containers/com.apple.podcasts/Data/tmp" + ], + requiresFullDiskAccess: true + ), + DiskCleanRuleTarget( + id: "cache.utilities", + legacyRuleID: "cache.utilities", + category: .appCaches, + risk: .low, + kind: .path(globs: [ + "~/Library/Caches/net.xmac.aria2gui", + "~/Library/Caches/org.m0k.transmission", + "~/Library/Caches/com.qbittorrent.qBittorrent", + "~/Library/Caches/com.downie.Downie-*", + "~/Library/Caches/com.folx.*/*", + "~/Library/Caches/com.charlessoft.pacifist/*", + "~/Library/Caches/com.youdao.YoudaoDict", + "~/Library/Caches/com.eudic.*", + "~/Library/Caches/com.bob-build.Bob", + "~/Library/Caches/com.cleanshot.*", + "~/Library/Caches/com.reincubate.camo", + "~/Library/Caches/com.xnipapp.xnip", + "~/Library/Caches/com.readdle.smartemail-Mac", + "~/Library/Caches/com.airmail.*", + "~/Library/Caches/com.todoist.mac.Todoist", + "~/Library/Caches/com.any.do.*", + "~/.zcompdump*", + "~/.lesshst", + "~/.viminfo.tmp", + "~/.wget-hsts", + "~/.cacher/logs/*", + "~/.kite/logs/*", + "~/Library/Caches/dev.warp.Warp-Stable/*", + "~/Library/Logs/warp.log", + "~/Library/Caches/SentryCrash/Warp/*", + "~/Library/Caches/com.mitchellh.ghostty/*" + ]), + reservedRootPaths: [ + "~/Library/Caches/net.xmac.aria2gui", + "~/Library/Caches/org.m0k.transmission", + "~/Library/Caches/com.qbittorrent.qBittorrent", + "~/Library/Caches", + "~/Library/Caches/com.charlessoft.pacifist", + "~/Library/Caches/com.youdao.YoudaoDict", + "~/Library/Caches/com.bob-build.Bob", + "~/Library/Caches/com.reincubate.camo", + "~/Library/Caches/com.xnipapp.xnip", + "~/Library/Caches/com.readdle.smartemail-Mac", + "~/Library/Caches/com.todoist.mac.Todoist", + "~", + "~/.lesshst", + "~/.viminfo.tmp", + "~/.wget-hsts", + "~/.cacher/logs", + "~/.kite/logs", + "~/Library/Caches/dev.warp.Warp-Stable", + "~/Library/Logs/warp.log", + "~/Library/Caches/SentryCrash/Warp", + "~/Library/Caches/com.mitchellh.ghostty" + ] + ), + DiskCleanRuleTarget( + id: "cache.games-notes-remote", + legacyRuleID: "cache.games-notes-remote", + category: .appCaches, + risk: .low, + kind: .path(globs: [ + "~/Library/Caches/com.valvesoftware.steam/*", + "~/Library/Application Support/Steam/htmlcache/*", + "~/Library/Application Support/Steam/appcache/*", + "~/Library/Application Support/Steam/depotcache/*", + "~/Library/Application Support/Steam/steamapps/shadercache/*", + "~/Library/Application Support/Steam/logs/*", + "~/Library/Caches/com.epicgames.EpicGamesLauncher/*", + "~/Library/Caches/com.blizzard.Battle.net/*", + "~/Library/Application Support/Battle.net/Cache/*", + "~/Library/Caches/com.ea.*/*", + "~/Library/Caches/com.gog.galaxy/*", + "~/Library/Caches/com.riotgames.*/*", + "~/Library/Application Support/minecraft/logs/*", + "~/Library/Application Support/minecraft/crash-reports/*", + "~/Library/Application Support/minecraft/webcache/*", + "~/Library/Application Support/minecraft/webcache2/*", + "~/.lunarclient/game-cache/*", + "~/.lunarclient/launcher-cache/*", + "~/.lunarclient/logs/*", + "~/.lunarclient/offline/*/logs/*", + "~/.lunarclient/offline/files/*/logs/*", + "~/Library/Caches/net.pcsx2.PCSX2/*", + "~/Library/Application Support/PCSX2/cache/*", + "~/Library/Logs/PCSX2/*", + "~/Library/Caches/net.rpcs3.rpcs3/*", + "~/Library/Application Support/rpcs3/logs/*", + "~/Library/Caches/notion.id/*", + "~/Library/Caches/md.obsidian/*", + "~/Library/Caches/com.logseq.*/*", + "~/Library/Caches/com.bear-writer.*/*", + "~/Library/Caches/com.evernote.*/*", + "~/Library/Caches/com.yinxiang.*/*", + "~/Library/Caches/com.teamviewer.*/*", + "~/Library/Caches/com.anydesk.*/*", + "~/Library/Caches/com.todesk.*/*", + "~/Library/Caches/com.sunlogin.*/*" + ]), + reservedRootPaths: [ + "~/Library/Caches/com.valvesoftware.steam", + "~/Library/Application Support/Steam/htmlcache", + "~/Library/Application Support/Steam/appcache", + "~/Library/Application Support/Steam/depotcache", + "~/Library/Application Support/Steam/steamapps/shadercache", + "~/Library/Application Support/Steam/logs", + "~/Library/Caches/com.epicgames.EpicGamesLauncher", + "~/Library/Caches/com.blizzard.Battle.net", + "~/Library/Application Support/Battle.net/Cache", + "~/Library/Caches", + "~/Library/Caches/com.gog.galaxy", + "~/Library/Application Support/minecraft/logs", + "~/Library/Application Support/minecraft/crash-reports", + "~/Library/Application Support/minecraft/webcache", + "~/Library/Application Support/minecraft/webcache2", + "~/.lunarclient/game-cache", + "~/.lunarclient/launcher-cache", + "~/.lunarclient/logs", + "~/.lunarclient/offline", + "~/.lunarclient/offline/files", + "~/Library/Caches/net.pcsx2.PCSX2", + "~/Library/Application Support/PCSX2/cache", + "~/Library/Logs/PCSX2", + "~/Library/Caches/net.rpcs3.rpcs3", + "~/Library/Application Support/rpcs3/logs", + "~/Library/Caches/notion.id", + "~/Library/Caches/md.obsidian" + ] + ), + DiskCleanRuleTarget( + id: "cache.launchers-system-utils", + legacyRuleID: "cache.launchers-system-utils", + category: .appCaches, + risk: .low, + kind: .path(globs: [ + "~/Library/Caches/com.runningwithcrayons.Alfred/*", + "~/Library/Caches/cx.c3.theunarchiver/*", + "~/Library/Caches/com.raycast.macos/urlcache/*", + "~/Library/Caches/com.raycast.macos/fsCachedData/*", + "~/Library/Caches/com.runjuu.Input-Source-Pro/*", + "~/Library/Caches/macos-wakatime.WakaTime/*", + "~/Library/Application Support/WeType/com.onevcat.Kingfisher.ImageCache.WeType/*", + "~/Library/Application Support/WeType/DictUpdate/*", + "~/Library/Application Support/mihomo-party/Cache/*", + "~/Library/Application Support/mihomo-party/Code Cache/*", + "~/Library/Application Support/mihomo-party/GPUCache/*", + "~/Library/Application Support/mihomo-party/DawnGraphiteCache/*", + "~/Library/Application Support/mihomo-party/DawnWebGPUCache/*", + "~/Library/Application Support/mihomo-party/logs/*", + "~/Library/Caches/ws.stash.app.mac/*" + ]), + reservedRootPaths: [ + "~/Library/Caches/com.runningwithcrayons.Alfred", + "~/Library/Caches/cx.c3.theunarchiver", + "~/Library/Caches/com.raycast.macos/urlcache", + "~/Library/Caches/com.raycast.macos/fsCachedData", + "~/Library/Caches/com.runjuu.Input-Source-Pro", + "~/Library/Caches/macos-wakatime.WakaTime", + "~/Library/Application Support/WeType/com.onevcat.Kingfisher.ImageCache.WeType", + "~/Library/Application Support/WeType/DictUpdate", + "~/Library/Application Support/mihomo-party/Cache", + "~/Library/Application Support/mihomo-party/Code Cache", + "~/Library/Application Support/mihomo-party/GPUCache", + "~/Library/Application Support/mihomo-party/DawnGraphiteCache", + "~/Library/Application Support/mihomo-party/DawnWebGPUCache", + "~/Library/Application Support/mihomo-party/logs", + "~/Library/Caches/ws.stash.app.mac" + ] + ) + ] + + // MARK: - Developer artifacts and tool caches (v1 `developer.*` rules) + + private static let developerTargets: [DiskCleanRuleTarget] = [ + DiskCleanRuleTarget( + id: "developer.xcode-derived-data", + legacyRuleID: "developer.xcode-derived-data", + category: .developer, + risk: .low, + kind: .path(globs: [ + "~/Library/Developer/Xcode/DerivedData/*" + ]), + reservedRootPaths: [ + "~/Library/Developer/Xcode/DerivedData" + ], + lockedByBundleIDs: [ + "com.apple.dt.Xcode" + ], + skipWhenProcessIsRunning: [ + "Xcode" + ] + ), + DiskCleanRuleTarget( + id: "developer.xcode-user-caches", + legacyRuleID: "developer.xcode-user-caches", + category: .developer, + risk: .low, + kind: .path(globs: [ + "~/Library/Caches/com.apple.dt.Xcode/*", + "~/Library/Developer/Xcode/iOS Device Logs/*", + "~/Library/Developer/Xcode/watchOS Device Logs/*", + "~/Library/Developer/Xcode/Products/*", + "~/Library/Developer/Xcode/Archives/*", + "~/Library/Developer/Xcode/DocumentationCache/*", + "~/Library/Developer/Xcode/DocumentationIndex/*", + "~/Library/Developer/Xcode/UserData/IB Support/*", + "~/Library/Developer/Xcode/iOS DeviceSupport/*/Symbols/System/Library/Caches/*", + "~/Library/Developer/Xcode/iOS DeviceSupport/*.log", + "~/Library/Developer/Xcode/watchOS DeviceSupport/*/Symbols/System/Library/Caches/*", + "~/Library/Developer/Xcode/watchOS DeviceSupport/*.log", + "~/Library/Developer/Xcode/tvOS DeviceSupport/*/Symbols/System/Library/Caches/*", + "~/Library/Developer/Xcode/tvOS DeviceSupport/*.log" + ]), + reservedRootPaths: [ + "~/Library/Caches/com.apple.dt.Xcode", + "~/Library/Developer/Xcode/iOS Device Logs", + "~/Library/Developer/Xcode/watchOS Device Logs", + "~/Library/Developer/Xcode/Products", + "~/Library/Developer/Xcode/Archives", + "~/Library/Developer/Xcode/DocumentationCache", + "~/Library/Developer/Xcode/DocumentationIndex", + "~/Library/Developer/Xcode/UserData/IB Support", + "~/Library/Developer/Xcode/iOS DeviceSupport", + "~/Library/Developer/Xcode/watchOS DeviceSupport", + "~/Library/Developer/Xcode/tvOS DeviceSupport" + ], + lockedByBundleIDs: [ + "com.apple.dt.Xcode" + ], + skipWhenProcessIsRunning: [ + "Xcode" + ] + ), + DiskCleanRuleTarget( + id: "developer.simulator-unavailable", + legacyRuleID: "developer.simulator-unavailable", + category: .developer, + risk: .medium, + kind: .dynamic(provider: DiskCleanDynamicRuleProviders.unavailableSimulators), + reservedRootPaths: [ + "~/Library/Developer/CoreSimulator/Devices" + ], + lockedByBundleIDs: [ + "com.apple.iphonesimulator" + ], + skipWhenProcessIsRunning: [ + "Simulator" + ] + ), + DiskCleanRuleTarget( + id: "developer.simulator-caches", + legacyRuleID: "developer.simulator-caches", + category: .developer, + risk: .low, + kind: .path(globs: [ + "~/Library/Developer/CoreSimulator/Caches/*", + "~/Library/Developer/CoreSimulator/Devices/*/data/tmp/*", + "~/Library/Logs/CoreSimulator/*", + "~/Library/Developer/CoreSimulator/Profiles/Runtimes/*/Contents/Resources/RuntimeRoot/System/Library/Caches/*" + ]), + reservedRootPaths: [ + "~/Library/Developer/CoreSimulator/Caches", + "~/Library/Developer/CoreSimulator/Devices", + "~/Library/Logs/CoreSimulator", + "~/Library/Developer/CoreSimulator/Profiles/Runtimes" + ], + lockedByBundleIDs: [ + "com.apple.iphonesimulator" + ], + skipWhenProcessIsRunning: [ + "Simulator" + ] + ), + DiskCleanRuleTarget( + id: "developer.package-manager-dynamic-caches", + legacyRuleID: "developer.package-manager-dynamic-caches", + category: .developer, + risk: .low, + kind: .path(globs: [ + "~/.cache/go-build/*", + "~/Library/Caches/mise/*" + ]), + reservedRootPaths: [ + "~/.cache/go-build", + "~/Library/Caches/mise" + ] + ), + DiskCleanRuleTarget( + id: "developer.javascript-caches", + legacyRuleID: "developer.javascript-caches", + category: .developer, + risk: .low, + kind: .path(globs: [ + "~/.npm/_cacache/*", + "~/.npm/_npx/*", + "~/.npm/_logs/*", + "~/.npm/_prebuilds/*", + "~/Library/pnpm/store/*", + "~/.bun/install/cache/*", + "~/.tnpm/_cacache/*", + "~/.tnpm/_logs/*", + "~/.yarn/cache/*", + "~/Library/Caches/Yarn/*", + "~/.cache/typescript/*", + "~/.cache/electron/*", + "~/.cache/node-gyp/*", + "~/.node-gyp/*", + "~/.turbo/cache/*", + "~/.vite/cache/*", + "~/.cache/vite/*", + "~/.cache/webpack/*", + "~/.parcel-cache/*", + "~/.cache/eslint/*", + "~/.cache/prettier/*" + ]), + reservedRootPaths: [ + "~/.npm/_cacache", + "~/.npm/_npx", + "~/.npm/_logs", + "~/.npm/_prebuilds", + "~/Library/pnpm/store", + "~/.bun/install/cache", + "~/.tnpm/_cacache", + "~/.tnpm/_logs", + "~/.yarn/cache", + "~/Library/Caches/Yarn", + "~/.cache/typescript", + "~/.cache/electron", + "~/.cache/node-gyp", + "~/.node-gyp", + "~/.turbo/cache", + "~/.vite/cache", + "~/.cache/vite", + "~/.cache/webpack", + "~/.parcel-cache", + "~/.cache/eslint", + "~/.cache/prettier" + ] + ), + DiskCleanRuleTarget( + id: "developer.python-caches", + legacyRuleID: "developer.python-caches", + category: .developer, + risk: .low, + kind: .path(globs: [ + "~/Library/Caches/pip/*", + "~/.cache/pip/*", + "~/.pyenv/cache/*", + "~/.cache/poetry/*", + "~/.cache/uv/*", + "~/.cache/ruff/*", + "~/.cache/mypy/*", + "~/.pytest_cache/*", + "~/.jupyter/runtime/*", + "~/.cache/huggingface/*", + "~/.cache/torch/*", + "~/.cache/tensorflow/*", + "~/.conda/pkgs/*", + "~/anaconda3/pkgs/*", + "~/.cache/wandb/*" + ]), + reservedRootPaths: [ + "~/Library/Caches/pip", + "~/.cache/pip", + "~/.pyenv/cache", + "~/.cache/poetry", + "~/.cache/uv", + "~/.cache/ruff", + "~/.cache/mypy", + "~/.pytest_cache", + "~/.jupyter/runtime", + "~/.cache/huggingface", + "~/.cache/torch", + "~/.cache/tensorflow", + "~/.conda/pkgs", + "~/anaconda3/pkgs", + "~/.cache/wandb" + ] + ), + DiskCleanRuleTarget( + id: "developer.rust-go", + legacyRuleID: "developer.rust-go-docker", + category: .developer, + risk: .low, + kind: .path(globs: [ + "~/.cargo/registry/cache/*", + "~/.cargo/git/*", + "~/.rustup/downloads/*", + "~/Library/Caches/go-build/*", + "~/go/pkg/mod/*" + ]), + reservedRootPaths: [ + "~/.cargo/registry/cache", + "~/.cargo/git", + "~/.rustup/downloads", + "~/Library/Caches/go-build", + "~/go/pkg/mod" + ] + ), + DiskCleanRuleTarget( + id: "developer.docker", + legacyRuleID: "developer.rust-go-docker", + category: .developer, + risk: .medium, + kind: .path(globs: [ + "~/.docker/buildx/cache/*" + ]), + reservedRootPaths: [ + "~/.docker/buildx/cache" + ] + ), + DiskCleanRuleTarget( + id: "developer.mobile-caches", + legacyRuleID: "developer.mobile-caches", + category: .developer, + risk: .low, + kind: .path(globs: [ + "~/Library/Caches/Google/AndroidStudio*/*", + "~/.android/build-cache/*", + "~/.android/cache/*", + "~/.cache/swift-package-manager/*", + "~/Library/Caches/org.swift.swiftpm/*", + "~/.expo/expo-go/*", + "~/.expo/android-apk-cache/*", + "~/.expo/ios-simulator-app-cache/*", + "~/.expo/native-modules-cache/*", + "~/.expo/schema-cache/*", + "~/.expo/template-cache/*", + "~/.expo/versions-cache/*" + ]), + reservedRootPaths: [ + "~/Library/Caches/Google", + "~/.android/build-cache", + "~/.android/cache", + "~/.cache/swift-package-manager", + "~/Library/Caches/org.swift.swiftpm", + "~/.expo/expo-go", + "~/.expo/android-apk-cache", + "~/.expo/ios-simulator-app-cache", + "~/.expo/native-modules-cache", + "~/.expo/schema-cache", + "~/.expo/template-cache", + "~/.expo/versions-cache" + ] + ), + DiskCleanRuleTarget( + id: "developer.jvm-caches", + legacyRuleID: "developer.jvm-caches", + category: .developer, + risk: .low, + kind: .path(globs: [ + "~/.m2/repository/*", + "~/.sbt/*", + "~/.ivy2/cache/*", + "~/.gradle/caches/*", + "~/.gradle/daemon/*" + ]), + reservedRootPaths: [ + "~/.m2/repository", + "~/.sbt", + "~/.ivy2/cache", + "~/.gradle/caches", + "~/.gradle/daemon" + ] + ), + DiskCleanRuleTarget( + id: "developer.jetbrains-toolbox-old-versions", + legacyRuleID: "developer.jetbrains-toolbox-old-versions", + category: .developer, + risk: .medium, + kind: .dynamic(provider: DiskCleanDynamicRuleProviders.jetbrainsToolboxOldVersions), + reservedRootPaths: [ + "~/Library/Application Support/JetBrains/Toolbox/apps" + ] + ), + DiskCleanRuleTarget( + id: "developer.ai-agent-old-versions", + legacyRuleID: "developer.ai-agent-old-versions", + category: .aiTools, + risk: .medium, + kind: .dynamic(provider: DiskCleanDynamicRuleProviders.aiAgentOldVersions), + reservedRootPaths: [ + "~/.local/share/claude/versions", + "~/.claude/versions", + "~/.codex/versions", + "~/.local/share/opencode/versions" + ] + ), + DiskCleanRuleTarget( + id: "developer.editor-caches", + legacyRuleID: "developer.editor-caches", + category: .developer, + risk: .low, + kind: .path(globs: [ + "~/Library/Application Support/Code/logs/*", + "~/Library/Application Support/Code/Cache/*", + "~/Library/Application Support/Code/CachedExtensions/*", + "~/Library/Application Support/Code/CachedData/*", + "~/Library/Application Support/Code/DawnGraphiteCache/*", + "~/Library/Application Support/Code/DawnWebGPUCache/*", + "~/Library/Application Support/Code/GPUCache/*", + "~/Library/Application Support/Code/CachedExtensionVSIXs/*", + "~/Library/Application Support/Code/Service Worker/ScriptCache/*", + "~/Library/Caches/com.microsoft.VSCode/Cache/*", + "~/Library/Caches/com.sublimetext.*/*", + "~/Library/Caches/Zed/*", + "~/Library/Logs/Zed/*", + "~/Library/Caches/copilot/*", + "~/.cache/vscode-ripgrep/*", + "~/Library/Caches/Cursor/*", + "~/Library/Application Support/Cursor/CachedData/*", + "~/Library/Application Support/Cursor/CachedExtensionVSIXs/*", + "~/Library/Application Support/Cursor/GPUCache/*", + "~/Library/Application Support/Cursor/DawnGraphiteCache/*", + "~/Library/Application Support/Cursor/DawnWebGPUCache/*", + "~/Library/Application Support/Cursor/Service Worker/ScriptCache/*" + ]), + reservedRootPaths: [ + "~/Library/Application Support/Code/logs", + "~/Library/Application Support/Code/Cache", + "~/Library/Application Support/Code/CachedExtensions", + "~/Library/Application Support/Code/CachedData", + "~/Library/Application Support/Code/DawnGraphiteCache", + "~/Library/Application Support/Code/DawnWebGPUCache", + "~/Library/Application Support/Code/GPUCache", + "~/Library/Application Support/Code/CachedExtensionVSIXs", + "~/Library/Application Support/Code/Service Worker/ScriptCache", + "~/Library/Caches/com.microsoft.VSCode/Cache", + "~/Library/Caches", + "~/Library/Caches/Zed", + "~/Library/Logs/Zed", + "~/Library/Caches/copilot", + "~/.cache/vscode-ripgrep", + "~/Library/Caches/Cursor", + "~/Library/Application Support/Cursor/CachedData", + "~/Library/Application Support/Cursor/CachedExtensionVSIXs", + "~/Library/Application Support/Cursor/GPUCache", + "~/Library/Application Support/Cursor/DawnGraphiteCache", + "~/Library/Application Support/Cursor/DawnWebGPUCache", + "~/Library/Application Support/Cursor/Service Worker/ScriptCache" + ] + ), + DiskCleanRuleTarget( + id: "developer.cloud-devops-caches", + legacyRuleID: "developer.cloud-devops-caches", + category: .developer, + risk: .low, + kind: .path(globs: [ + "~/.kube/cache/*", + "~/.local/share/containers/storage/tmp/*", + "~/.aws/cli/cache/*", + "~/.config/gcloud/logs/*", + "~/.azure/logs/*", + "~/.cache/terraform/*", + "~/.grafana/cache/*", + "~/.prometheus/data/wal/*", + "~/.jenkins/workspace/*/target/*", + "~/.cache/gitlab-runner/*", + "~/.github/cache/*", + "~/.circleci/cache/*", + "~/.sonar/*" + ]), + reservedRootPaths: [ + "~/.kube/cache", + "~/.local/share/containers/storage/tmp", + "~/.aws/cli/cache", + "~/.config/gcloud/logs", + "~/.azure/logs", + "~/.cache/terraform", + "~/.grafana/cache", + "~/.prometheus/data/wal", + "~/.jenkins/workspace", + "~/.cache/gitlab-runner", + "~/.github/cache", + "~/.circleci/cache", + "~/.sonar" + ] + ), + DiskCleanRuleTarget( + id: "developer.language-caches", + legacyRuleID: "developer.language-caches", + category: .developer, + risk: .low, + kind: .path(globs: [ + "~/.bundle/cache/*", + "~/.composer/cache/*", + "~/Library/Caches/composer/*", + "~/.nuget/packages/*", + "~/.cache/bazel/*", + "~/.cache/zig/*", + "~/Library/Caches/deno/*", + "~/.hex/cache/*", + "~/.cabal/packages/*", + "~/.opam/download-cache/*" + ]), + reservedRootPaths: [ + "~/.bundle/cache", + "~/.composer/cache", + "~/Library/Caches/composer", + "~/.nuget/packages", + "~/.cache/bazel", + "~/.cache/zig", + "~/Library/Caches/deno", + "~/.hex/cache", + "~/.cabal/packages", + "~/.opam/download-cache" + ] + ), + DiskCleanRuleTarget( + id: "developer.database-api-caches", + legacyRuleID: "developer.database-api-caches", + category: .developer, + risk: .low, + kind: .path(globs: [ + "~/Library/Caches/com.sequel-ace.sequel-ace/*", + "~/Library/Caches/com.eggerapps.Sequel-Pro/*", + "~/Library/Caches/redis-desktop-manager/*", + "~/Library/Caches/com.navicat.*", + "~/Library/Caches/com.dbeaver.*", + "~/Library/Caches/com.redis.RedisInsight", + "~/Library/Caches/com.postmanlabs.mac/*", + "~/Library/Caches/com.konghq.insomnia/*", + "~/Library/Caches/com.tinyapp.TablePlus/*", + "~/Library/Caches/com.getpaw.Paw/*", + "~/Library/Caches/com.charlesproxy.charles/*", + "~/Library/Caches/com.proxyman.NSProxy/*" + ]), + reservedRootPaths: [ + "~/Library/Caches/com.sequel-ace.sequel-ace", + "~/Library/Caches/com.eggerapps.Sequel-Pro", + "~/Library/Caches/redis-desktop-manager", + "~/Library/Caches", + "~/Library/Caches/com.redis.RedisInsight", + "~/Library/Caches/com.postmanlabs.mac", + "~/Library/Caches/com.konghq.insomnia", + "~/Library/Caches/com.tinyapp.TablePlus", + "~/Library/Caches/com.getpaw.Paw", + "~/Library/Caches/com.charlesproxy.charles", + "~/Library/Caches/com.proxyman.NSProxy" + ] + ), + DiskCleanRuleTarget( + id: "developer.misc-caches", + legacyRuleID: "developer.misc-caches", + category: .developer, + risk: .low, + kind: .path(globs: [ + "~/Library/Caches/com.unity3d.*/*", + "~/Library/Caches/com.mongodb.compass/*", + "~/Library/Caches/com.github.GitHubDesktop/*", + "~/Library/Caches/SentryCrash/*", + "~/Library/Caches/KSCrash/*", + "~/Library/Caches/com.crashlytics.data/*", + "~/Library/Application Support/Antigravity/Cache/*", + "~/Library/Application Support/Antigravity/Code Cache/*", + "~/Library/Application Support/Antigravity/GPUCache/*", + "~/Library/Application Support/Antigravity/DawnGraphiteCache/*", + "~/Library/Application Support/Antigravity/DawnWebGPUCache/*", + "~/Library/Application Support/Filo/production/Cache/*", + "~/Library/Application Support/Filo/production/Code Cache/*", + "~/Library/Application Support/Filo/production/GPUCache/*", + "~/Library/Application Support/Filo/production/DawnGraphiteCache/*", + "~/Library/Application Support/Filo/production/DawnWebGPUCache/*", + "~/Library/Application Support/Claude/Cache/*", + "~/Library/Application Support/Claude/Code Cache/*", + "~/Library/Application Support/Claude/GPUCache/*", + "~/Library/Application Support/Claude/DawnGraphiteCache/*", + "~/Library/Application Support/Claude/DawnWebGPUCache/*", + "~/Library/Application Support/Claude/sentry/*", + "~/Library/Application Support/Claude/pending-uploads/*", + "~/Library/Application Support/Qoder/Cache/*", + "~/Library/Application Support/Qoder/CachedData/*", + "~/Library/Application Support/Qoder/CachedExtensionVSIXs/*", + "~/Library/Application Support/Qoder/Code Cache/*", + "~/Library/Application Support/Qoder/GPUCache/*", + "~/Library/Application Support/Qoder/DawnGraphiteCache/*", + "~/Library/Application Support/Qoder/DawnWebGPUCache/*", + "~/Library/Application Support/Qoder/logs/*", + "~/.cache/prisma/*", + "~/.cache/opencode/*" + ]), + reservedRootPaths: [ + "~/Library/Caches", + "~/Library/Caches/com.mongodb.compass", + "~/Library/Caches/com.github.GitHubDesktop", + "~/Library/Caches/SentryCrash", + "~/Library/Caches/KSCrash", + "~/Library/Caches/com.crashlytics.data", + "~/Library/Application Support/Antigravity/Cache", + "~/Library/Application Support/Antigravity/Code Cache", + "~/Library/Application Support/Antigravity/GPUCache", + "~/Library/Application Support/Antigravity/DawnGraphiteCache", + "~/Library/Application Support/Antigravity/DawnWebGPUCache", + "~/Library/Application Support/Filo/production/Cache", + "~/Library/Application Support/Filo/production/Code Cache", + "~/Library/Application Support/Filo/production/GPUCache", + "~/Library/Application Support/Filo/production/DawnGraphiteCache", + "~/Library/Application Support/Filo/production/DawnWebGPUCache", + "~/Library/Application Support/Claude/Cache", + "~/Library/Application Support/Claude/Code Cache", + "~/Library/Application Support/Claude/GPUCache", + "~/Library/Application Support/Claude/DawnGraphiteCache", + "~/Library/Application Support/Claude/DawnWebGPUCache", + "~/Library/Application Support/Claude/sentry", + "~/Library/Application Support/Claude/pending-uploads", + "~/Library/Application Support/Qoder/Cache", + "~/Library/Application Support/Qoder/CachedData", + "~/Library/Application Support/Qoder/CachedExtensionVSIXs", + "~/Library/Application Support/Qoder/Code Cache", + "~/Library/Application Support/Qoder/GPUCache", + "~/Library/Application Support/Qoder/DawnGraphiteCache", + "~/Library/Application Support/Qoder/DawnWebGPUCache", + "~/Library/Application Support/Qoder/logs", + "~/.cache/prisma", + "~/.cache/opencode" + ] + ), + DiskCleanRuleTarget( + id: "developer.shell-network-caches", + legacyRuleID: "developer.shell-network-caches", + category: .developer, + risk: .low, + kind: .path(globs: [ + "~/.gitconfig.lock", + "~/.gitconfig.bak*", + "~/.oh-my-zsh/cache/*", + "~/.config/fish/fish_history.bak*", + "~/.bash_history.bak*", + "~/.zsh_history.bak*", + "~/.cache/pre-commit/*", + "~/.cache/curl/*", + "~/.cache/wget/*", + "~/Library/Caches/curl/*", + "~/Library/Caches/wget/*" + ]), + reservedRootPaths: [ + "~/.gitconfig.lock", + "~", + "~/.oh-my-zsh/cache", + "~/.config/fish", + "~/.cache/pre-commit", + "~/.cache/curl", + "~/.cache/wget", + "~/Library/Caches/curl", + "~/Library/Caches/wget" + ] + ), + DiskCleanRuleTarget( + id: "developer.homebrew", + legacyRuleID: "developer.homebrew", + category: .developer, + risk: .low, + kind: .path(globs: [ + "~/Library/Caches/Homebrew/*" + ]), + reservedRootPaths: [ + "~/Library/Caches/Homebrew" + ] + ) + ] + + // MARK: - Browser caches (v1 `browser.*` rules) + + private static let browserTargets: [DiskCleanRuleTarget] = [ + DiskCleanRuleTarget( + id: "browser.safari", + legacyRuleID: "browser.safari", + category: .browsers, + risk: .low, + kind: .path(globs: [ + "~/Library/Caches/com.apple.Safari/*" + ]), + reservedRootPaths: [ + "~/Library/Caches/com.apple.Safari" + ], + requiresFullDiskAccess: true + ), + DiskCleanRuleTarget( + id: "browser.chrome", + legacyRuleID: "browser.chrome", + category: .browsers, + risk: .low, + kind: .path(globs: [ + "~/Library/Caches/Google/Chrome/*", + "~/Library/Application Support/Google/Chrome/*/Application Cache/*", + "~/Library/Application Support/Google/Chrome/*/GPUCache/*", + "~/Library/Application Support/Google/Chrome/component_crx_cache/*", + "~/Library/Application Support/Google/Chrome/ShaderCache/*", + "~/Library/Application Support/Google/Chrome/GrShaderCache/*", + "~/Library/Application Support/Google/Chrome/GraphiteDawnCache/*", + "~/Library/Application Support/Google/GoogleUpdater/crx_cache/*", + "~/Library/Application Support/Google/GoogleUpdater/*.old", + "~/Library/Caches/Chromium/*", + "~/.cache/puppeteer/*" + ]), + reservedRootPaths: [ + "~/Library/Caches/Google/Chrome", + "~/Library/Application Support/Google/Chrome", + "~/Library/Application Support/Google/Chrome/component_crx_cache", + "~/Library/Application Support/Google/Chrome/ShaderCache", + "~/Library/Application Support/Google/Chrome/GrShaderCache", + "~/Library/Application Support/Google/Chrome/GraphiteDawnCache", + "~/Library/Application Support/Google/GoogleUpdater/crx_cache", + "~/Library/Application Support/Google/GoogleUpdater", + "~/Library/Caches/Chromium", + "~/.cache/puppeteer" + ], + lockedByBundleIDs: [ + "com.google.Chrome" + ], + skipWhenProcessIsRunning: [ + "Google Chrome" + ] + ), + DiskCleanRuleTarget( + id: "browser.chrome.service-worker", + legacyRuleID: "browser.chrome", + category: .browsers, + risk: .medium, + kind: .path(globs: [ + "~/Library/Application Support/Google/Chrome/*/Service Worker/ScriptCache/*" + ]), + reservedRootPaths: [ + "~/Library/Application Support/Google/Chrome" + ], + lockedByBundleIDs: [ + "com.google.Chrome" + ], + skipWhenProcessIsRunning: [ + "Google Chrome" + ] + ), + DiskCleanRuleTarget( + id: "browser.edge", + legacyRuleID: "browser.edge", + category: .browsers, + risk: .low, + kind: .path(globs: [ + "~/Library/Caches/com.microsoft.edgemac/*" + ]), + reservedRootPaths: [ + "~/Library/Caches/com.microsoft.edgemac" + ] + ), + DiskCleanRuleTarget( + id: "browser.arc-dia", + legacyRuleID: "browser.arc-dia", + category: .browsers, + risk: .low, + kind: .path(globs: [ + "~/Library/Caches/company.thebrowser.Browser/*", + "~/Library/Application Support/Arc/*/GPUCache/*", + "~/Library/Application Support/Arc/ShaderCache/*", + "~/Library/Application Support/Arc/GrShaderCache/*", + "~/Library/Application Support/Arc/GraphiteDawnCache/*", + "~/Library/Caches/company.thebrowser.dia/*" + ]), + reservedRootPaths: [ + "~/Library/Caches/company.thebrowser.Browser", + "~/Library/Application Support/Arc", + "~/Library/Application Support/Arc/ShaderCache", + "~/Library/Application Support/Arc/GrShaderCache", + "~/Library/Application Support/Arc/GraphiteDawnCache", + "~/Library/Caches/company.thebrowser.dia" + ], + lockedByBundleIDs: [ + "company.thebrowser.Browser" + ], + skipWhenProcessIsRunning: [ + "Arc" + ] + ), + DiskCleanRuleTarget( + id: "browser.arc-dia.service-worker", + legacyRuleID: "browser.arc-dia", + category: .browsers, + risk: .medium, + kind: .path(globs: [ + "~/Library/Application Support/Arc/*/Service Worker/ScriptCache/*" + ]), + reservedRootPaths: [ + "~/Library/Application Support/Arc" + ], + lockedByBundleIDs: [ + "company.thebrowser.Browser" + ], + skipWhenProcessIsRunning: [ + "Arc" + ] + ), + DiskCleanRuleTarget( + id: "browser.brave", + legacyRuleID: "browser.brave", + category: .browsers, + risk: .low, + kind: .path(globs: [ + "~/Library/Caches/BraveSoftware/Brave-Browser/*", + "~/Library/Application Support/BraveSoftware/Brave-Browser/*/Application Cache/*", + "~/Library/Application Support/BraveSoftware/Brave-Browser/*/GPUCache/*", + "~/Library/Application Support/BraveSoftware/Brave-Browser/component_crx_cache/*", + "~/Library/Application Support/BraveSoftware/Brave-Browser/ShaderCache/*", + "~/Library/Application Support/BraveSoftware/Brave-Browser/GrShaderCache/*", + "~/Library/Application Support/BraveSoftware/Brave-Browser/GraphiteDawnCache/*" + ]), + reservedRootPaths: [ + "~/Library/Caches/BraveSoftware/Brave-Browser", + "~/Library/Application Support/BraveSoftware/Brave-Browser", + "~/Library/Application Support/BraveSoftware/Brave-Browser/component_crx_cache", + "~/Library/Application Support/BraveSoftware/Brave-Browser/ShaderCache", + "~/Library/Application Support/BraveSoftware/Brave-Browser/GrShaderCache", + "~/Library/Application Support/BraveSoftware/Brave-Browser/GraphiteDawnCache" + ], + lockedByBundleIDs: [ + "com.brave.Browser" + ], + skipWhenProcessIsRunning: [ + "Brave Browser" + ] + ), + DiskCleanRuleTarget( + id: "browser.brave.service-worker", + legacyRuleID: "browser.brave", + category: .browsers, + risk: .medium, + kind: .path(globs: [ + "~/Library/Application Support/BraveSoftware/Brave-Browser/*/Service Worker/ScriptCache/*" + ]), + reservedRootPaths: [ + "~/Library/Application Support/BraveSoftware/Brave-Browser" + ], + lockedByBundleIDs: [ + "com.brave.Browser" + ], + skipWhenProcessIsRunning: [ + "Brave Browser" + ] + ), + DiskCleanRuleTarget( + id: "browser.helium-yandex", + legacyRuleID: "browser.helium-yandex", + category: .browsers, + risk: .low, + kind: .path(globs: [ + "~/Library/Caches/net.imput.helium/*", + "~/Library/Application Support/net.imput.helium/*/GPUCache/*", + "~/Library/Application Support/net.imput.helium/component_crx_cache/*", + "~/Library/Application Support/net.imput.helium/extensions_crx_cache/*", + "~/Library/Application Support/net.imput.helium/GrShaderCache/*", + "~/Library/Application Support/net.imput.helium/GraphiteDawnCache/*", + "~/Library/Application Support/net.imput.helium/ShaderCache/*", + "~/Library/Application Support/net.imput.helium/*/Application Cache/*", + "~/Library/Caches/Yandex/YandexBrowser/*", + "~/Library/Application Support/Yandex/YandexBrowser/ShaderCache/*", + "~/Library/Application Support/Yandex/YandexBrowser/GrShaderCache/*", + "~/Library/Application Support/Yandex/YandexBrowser/GraphiteDawnCache/*", + "~/Library/Application Support/Yandex/YandexBrowser/*/GPUCache/*" + ]), + reservedRootPaths: [ + "~/Library/Caches/net.imput.helium", + "~/Library/Application Support/net.imput.helium", + "~/Library/Application Support/net.imput.helium/component_crx_cache", + "~/Library/Application Support/net.imput.helium/extensions_crx_cache", + "~/Library/Application Support/net.imput.helium/GrShaderCache", + "~/Library/Application Support/net.imput.helium/GraphiteDawnCache", + "~/Library/Application Support/net.imput.helium/ShaderCache", + "~/Library/Caches/Yandex/YandexBrowser", + "~/Library/Application Support/Yandex/YandexBrowser/ShaderCache", + "~/Library/Application Support/Yandex/YandexBrowser/GrShaderCache", + "~/Library/Application Support/Yandex/YandexBrowser/GraphiteDawnCache", + "~/Library/Application Support/Yandex/YandexBrowser" + ] + ), + DiskCleanRuleTarget( + id: "browser.firefox", + legacyRuleID: "browser.firefox", + category: .browsers, + risk: .low, + kind: .path(globs: [ + "~/Library/Caches/Firefox/*", + "~/Library/Application Support/Firefox/Profiles/*/cache2/*" + ]), + reservedRootPaths: [ + "~/Library/Caches/Firefox", + "~/Library/Application Support/Firefox/Profiles" + ], + lockedByBundleIDs: [ + "org.mozilla.firefox" + ], + skipWhenProcessIsRunning: [ + "Firefox" + ] + ), + DiskCleanRuleTarget( + id: "browser.opera-vivaldi", + legacyRuleID: "browser.opera-vivaldi", + category: .browsers, + risk: .low, + kind: .path(globs: [ + "~/Library/Caches/com.operasoftware.Opera/*", + "~/Library/Caches/com.vivaldi.Vivaldi/*", + "~/Library/Application Support/Vivaldi/*/GPUCache/*", + "~/Library/Application Support/Vivaldi/ShaderCache/*", + "~/Library/Application Support/Vivaldi/GrShaderCache/*", + "~/Library/Application Support/Vivaldi/GraphiteDawnCache/*" + ]), + reservedRootPaths: [ + "~/Library/Caches/com.operasoftware.Opera", + "~/Library/Caches/com.vivaldi.Vivaldi", + "~/Library/Application Support/Vivaldi", + "~/Library/Application Support/Vivaldi/ShaderCache", + "~/Library/Application Support/Vivaldi/GrShaderCache", + "~/Library/Application Support/Vivaldi/GraphiteDawnCache" + ], + lockedByBundleIDs: [ + "com.vivaldi.Vivaldi" + ], + skipWhenProcessIsRunning: [ + "Vivaldi" + ] + ), + DiskCleanRuleTarget( + id: "browser.opera-vivaldi.service-worker", + legacyRuleID: "browser.opera-vivaldi", + category: .browsers, + risk: .medium, + kind: .path(globs: [ + "~/Library/Application Support/Vivaldi/*/Service Worker/ScriptCache/*" + ]), + reservedRootPaths: [ + "~/Library/Application Support/Vivaldi" + ], + lockedByBundleIDs: [ + "com.vivaldi.Vivaldi" + ], + skipWhenProcessIsRunning: [ + "Vivaldi" + ] + ), + DiskCleanRuleTarget( + id: "browser.comet-orion-zen", + legacyRuleID: "browser.comet-orion-zen", + category: .browsers, + risk: .low, + kind: .path(globs: [ + "~/Library/Caches/Comet/*", + "~/Library/Caches/com.kagi.kagimacOS/*", + "~/Library/Caches/zen/*" + ]), + reservedRootPaths: [ + "~/Library/Caches/Comet", + "~/Library/Caches/com.kagi.kagimacOS", + "~/Library/Caches/zen" + ] + ), + DiskCleanRuleTarget( + id: "browser.service-worker", + legacyRuleID: "browser.service-worker", + category: .browsers, + risk: .medium, + kind: .path(globs: [ + "~/Library/Application Support/Google/Chrome/*/Service Worker/CacheStorage/*/*", + "~/Library/Application Support/Arc/*/Service Worker/CacheStorage/*/*", + "~/Library/Application Support/BraveSoftware/Brave-Browser/*/Service Worker/CacheStorage/*/*", + "~/Library/Application Support/Vivaldi/*/Service Worker/CacheStorage/*/*" + ]), + reservedRootPaths: [ + "~/Library/Application Support/Google/Chrome", + "~/Library/Application Support/Arc", + "~/Library/Application Support/BraveSoftware/Brave-Browser", + "~/Library/Application Support/Vivaldi" + ] + ), + DiskCleanRuleTarget( + id: "browser.service-worker.editors", + legacyRuleID: "browser.service-worker", + category: .developer, + risk: .medium, + kind: .path(globs: [ + "~/Library/Application Support/Code/Service Worker/CacheStorage/*/*", + "~/Library/Application Support/Cursor/Service Worker/CacheStorage/*/*" + ]), + reservedRootPaths: [ + "~/Library/Application Support/Code/Service Worker/CacheStorage", + "~/Library/Application Support/Cursor/Service Worker/CacheStorage" + ] + ), + DiskCleanRuleTarget( + id: "browser.old-versions", + legacyRuleID: "browser.old-versions", + category: .browsers, + risk: .medium, + kind: .dynamic(provider: DiskCleanDynamicRuleProviders.oldBrowserVersions), + reservedRootPaths: [ + "~/Library/Application Support/Google/GoogleUpdater", + "~/Library/Application Support/Microsoft/EdgeUpdater", + "~/Library/Application Support/BraveSoftware/BraveUpdater" + ] + ) + ] + + // MARK: - P2 developer-artifact purge (design §10.1) + + /// Synthetic targets, one per kind. + /// + /// Split to kind granularity rather than one `purge.artifacts`: `targetID` is written as-is + /// into audit logs (§7.8), so "which artifact class was deleted" must be obvious when reading + /// history. + /// + /// **Risk is always medium**: true risk depends on candidate facts (whether the repo is dirty) + /// and is overridden to low by the expansion source via `DiskCleanCandidateFacts.risk`. The + /// catalog value is the fail-safe fallback when override is missing—missing override only + /// means not default-selected, never default-selecting dirty-repo artifacts. + /// + /// **Empty `reservedRootPaths`** is the only exception in this file: scan roots are user- + /// configured, so the catalog has no fixed prefix to write. Reserved roots are supplied at + /// runtime by the expansion source (all configured roots enter the artifact reserved set); + /// `DiskCleanScanEngineTests` asserts this. + private static let developerArtifactTargets: [DiskCleanRuleTarget] = DiskCleanPurgeKind.allCases.map { kind in + DiskCleanRuleTarget( + id: kind.targetID, + legacyRuleID: kind.targetID, + category: .developerArtifacts, + risk: .medium, + kind: .external, + reservedRootPaths: [] + ) + } + + // MARK: - P2 leftover installers (design §10.2) + + /// Synthetic targets, one per extension. Risk and reserved-root handling match developer + /// artifacts; the difference is a fixed top-level `~/Downloads` scope, so reserved roots can + /// be hard-coded. + private static let installerTargets: [DiskCleanRuleTarget] = DiskCleanInstallerKind.allCases.map { kind in + DiskCleanRuleTarget( + id: kind.targetID, + legacyRuleID: kind.targetID, + category: .installers, + risk: .medium, + kind: .external, + reservedRootPaths: [DiskCleanInstallerScanner.defaultDownloadsPath] + ) + } +} diff --git a/Plugins/DiskClean/Sources/DiskCleanRuleModels.swift b/Plugins/DiskClean/Sources/DiskCleanRuleModels.swift new file mode 100644 index 00000000..766e3191 --- /dev/null +++ b/Plugins/DiskClean/Sources/DiskCleanRuleModels.swift @@ -0,0 +1,283 @@ +import Foundation +import MacToolsPluginKit + +// MARK: - Risk ordering + +/// Comparable risk levels for category display order (low → high) and assertions such as "dynamic rules are at least medium". +extension DiskCleanRisk: Comparable { + static func < (lhs: DiskCleanRisk, rhs: DiskCleanRisk) -> Bool { + lhs.severity < rhs.severity + } + + private var severity: Int { + switch self { + case .low: + return 0 + case .medium: + return 1 + case .high: + return 2 + } + } +} + +// MARK: - Categories + +/// The ten cleanup categories in rules v2 (design §5.1). +/// +/// Each category carries `risk` (display order and copy tone only), an honest `consequence` +/// string, and an SF Symbol. Whether a candidate is selected by default is decided by +/// **target-level** `risk`, not category risk — a category may mix risks (e.g. under +/// `developer`, `developer.docker` is medium while most others are low). +enum DiskCleanCategoryID: String, CaseIterable, Identifiable, Hashable, Sendable { + case userEssentials + case appCaches + case systemCaches + case logs + case developer + case browsers + case cloudOffice + case communication + case aiTools + case virtualization + /// P2 developer-artifact cleanup (design §10.1). Candidates come from user-configured scan roots, not rule-glob expansion. + case developerArtifacts + /// P2 leftover installers (design §10.2). + case installers + + var id: String { rawValue } + + /// Detail-page and panel display order: low risk → high (design §5.1). + /// `DiskCleanRisk` has only three levels while there are a dozen-plus categories, so this + /// table owns the order; `risk` only guarantees the table is non-decreasing. + /// + /// The two P2 categories sit at the end but do not share a frame with rule categories — + /// each renders in its own detail-page section, and order only matters inside a section. + /// They still live in this table because grouping always filters by it; absence would + /// silently drop the group. + static let displayOrder: [DiskCleanCategoryID] = [ + .userEssentials, + .appCaches, + .logs, + .browsers, + .cloudOffice, + .communication, + .aiTools, + .developer, + .systemCaches, + .virtualization, + .installers, + .developerArtifacts + ] + + var risk: DiskCleanRisk { + switch self { + case .userEssentials, .appCaches, .logs, .browsers, .cloudOffice, .communication, .aiTools: + return .low + // Installers are the user's own files, not caches: a mistaken delete has no + // "auto-rebuild" path, only re-download — shown at the same level as developer artifacts. + case .developer, .systemCaches, .virtualization, .developerArtifacts, .installers: + return .medium + } + } + + var symbolName: String { + switch self { + case .userEssentials: + return "person.crop.circle" + case .appCaches: + return "square.grid.2x2" + case .systemCaches: + return "gearshape" + case .logs: + return "doc.text" + case .developer: + return "hammer" + case .browsers: + return "safari" + case .cloudOffice: + return "cloud" + case .communication: + return "bubble.left.and.bubble.right" + case .aiTools: + return "sparkles" + case .virtualization: + return "cube" + case .developerArtifacts: + return "shippingbox" + case .installers: + return "arrow.down.circle" + } + } + + var titleKey: String { "category.\(rawValue).title" } + + var consequenceKey: String { "category.\(rawValue).consequence" } + + func title(localization: PluginLocalization = PluginLocalization(bundle: .main)) -> String { + localization.string(titleKey, defaultValue: defaultTitle) + } + + /// One honest consequence string. Does not promise "no impact" or overstate risk. + func consequence(localization: PluginLocalization = PluginLocalization(bundle: .main)) -> String { + localization.string(consequenceKey, defaultValue: defaultConsequence) + } + + private var defaultTitle: String { + switch self { + case .userEssentials: + return "用户缓存" + case .appCaches: + return "应用缓存" + case .systemCaches: + return "系统状态缓存" + case .logs: + return "日志与诊断报告" + case .developer: + return "开发者缓存" + case .browsers: + return "浏览器缓存" + case .cloudOffice: + return "云盘与办公" + case .communication: + return "通讯应用" + case .aiTools: + return "AI 工具" + case .virtualization: + return "虚拟化" + case .developerArtifacts: + return "开发产物" + case .installers: + return "残留安装包" + } + } + + private var defaultConsequence: String { + switch self { + case .userEssentials: + return "应用首次启动会稍慢,登录状态与文档不受影响。" + case .appCaches: + return "相关应用首次打开会稍慢,缩略图与预览需要重新生成。" + case .systemCaches: + return "下次打开应用可能不恢复上次的窗口,预览缩略图需要重建。" + case .logs: + return "历史日志与诊断报告会丢失,反馈问题时可能缺少记录。" + case .developer: + return "首次构建会变慢,依赖与索引需要重新下载或生成。" + case .browsers: + return "首次访问网站会稍慢,书签与登录状态不受影响。" + case .cloudOffice: + return "云盘与办公应用需要重建本地缓存,同步会短暂变慢。" + case .communication: + return "聊天记录不受影响,图片与文件需要重新下载。" + case .aiTools: + return "对话记录不受影响,模型与界面资源需要重新缓存。" + case .virtualization: + return "虚拟机磁盘不受影响,快照缓存与临时文件需要重建。" + case .developerArtifacts: + return "源码与提交不受影响,依赖需要重新安装、产物需要重新构建。" + case .installers: + return "已安装的应用不受影响,安装包需要时可重新下载。" + } + } +} + +// MARK: - Target-level rules + +/// Cleanup targets in rules v2 (design §5.2). +/// +/// v1 carried risk at "rule" granularity; v2 pushes it down to targets so one v1 rule can +/// split into several targets (e.g. `cache.user-essentials` becomes user-cache and user-log +/// categories), each with its own category and risk. `legacyRuleID` keeps the v1 rule id for +/// audit logs, whitelist migration, and mapping-snapshot tests. +struct DiskCleanRuleTarget: Identifiable, Sendable { + enum Kind: Sendable { + /// Static glob group. Globs in one target share category, risk, and lock conditions. + case path(globs: [String]) + /// Dynamic expansion (simctl, version-directory comparison, etc.; see `DiskCleanDynamicRules`). + case dynamic(provider: any DiskCleanDynamicRuleProviding) + /// Candidates are discovered by a dedicated scanner; the engine does not expand this + /// target (design §10 P2 synthetic targets). + /// + /// The only reason this exists: **deletes must go through a plan minted by the Planner**, + /// and the Planner looks up lock declarations by `targetID` in the catalog. P2 candidates + /// must therefore hang on a real target; otherwise they would need a second minting path — + /// exactly what this design forbids. + case external + } + + /// Stable target ID. Single-target rules reuse the v1 rule id; split targets append a suffix. + let id: String + /// v1 rule id. Multiple targets may share one legacyRuleID. + let legacyRuleID: String + let category: DiskCleanCategoryID + /// Target-level risk. Default-selection policy looks only here (only `.low` is selected by default). + let risk: DiskCleanRisk + let kind: Kind + /// Normalized absolute reserved roots (may use a `~` prefix; expand via `expandedReservedRootPaths` before use). + /// + /// **Required, regardless of whether the target ran successfully** (design §5.2, §6.1): when a + /// target is skipped for FDA or a dynamic provider fails, content under these paths is treated + /// as "present but unscanned", and the Planner refuses to delete their ancestors so an unreviewed + /// subtree is never deleted as a side effect. Path targets derive these as literals from fixed + /// directory prefixes of their globs. + let reservedRootPaths: [String] + /// Whether to skip the whole target without Full Disk Access (design §9). Set true only when + /// every glob is under TCC protection; otherwise prefer degrading individual paths as + /// `permissionDenied` over sacrificing other cleanable paths in the same target. + let requiresFullDiskAccess: Bool + /// App bundle IDs that lock the target while running (`NSWorkspace.runningApplications` snapshot). + let lockedByBundleIDs: [String] + /// Process names that lock the target while running (batched pgrep snapshot; covers non-app processes). + let skipWhenProcessIsRunning: [String] + + init( + id: String, + legacyRuleID: String, + category: DiskCleanCategoryID, + risk: DiskCleanRisk, + kind: Kind, + reservedRootPaths: [String], + requiresFullDiskAccess: Bool = false, + lockedByBundleIDs: [String] = [], + skipWhenProcessIsRunning: [String] = [] + ) { + self.id = id + self.legacyRuleID = legacyRuleID + self.category = category + self.risk = risk + self.kind = kind + self.reservedRootPaths = reservedRootPaths + self.requiresFullDiskAccess = requiresFullDiskAccess + self.lockedByBundleIDs = lockedByBundleIDs + self.skipWhenProcessIsRunning = skipWhenProcessIsRunning + } + + var pathGlobs: [String] { + guard case let .path(globs) = kind else { return [] } + return globs + } + + var dynamicProvider: (any DiskCleanDynamicRuleProviding)? { + guard case let .dynamic(provider) = kind else { return nil } + return provider + } + + var isDynamic: Bool { dynamicProvider != nil } + + /// Whether the engine skips this target during rule expansion. + var isExternallyDiscovered: Bool { + if case .external = kind { return true } + return false + } + + /// Reserved roots with `~` expanded. Ancestor assertions and reserved sets always use the expanded form so `~` is never compared against absolute paths. + func expandedReservedRootPaths(homeDirectory: String = NSHomeDirectory()) -> [String] { + reservedRootPaths.map { Self.expandHome(in: $0, homeDirectory: homeDirectory) } + } + + static func expandHome(in path: String, homeDirectory: String) -> String { + guard path == "~" || path.hasPrefix("~/") else { return path } + return homeDirectory + String(path.dropFirst()) + } +} diff --git a/Plugins/DiskClean/Sources/DiskCleanRunningAppLock.swift b/Plugins/DiskClean/Sources/DiskCleanRunningAppLock.swift new file mode 100644 index 00000000..70d59de3 --- /dev/null +++ b/Plugins/DiskClean/Sources/DiskCleanRunningAppLock.swift @@ -0,0 +1,192 @@ +import AppKit +import Foundation + +/// Snapshot of running apps/processes (design §4.2 item 4). +/// +/// v1 spawned `pgrep` **once per rule** (43 rules = 43 subprocesses). Here we take one +/// snapshot: one `NSWorkspace.runningApplications` plus one batched `pgrep`, then every +/// target's lock check runs in memory. +/// +/// The snapshot is a value type and does not auto-refresh — that is intentional: use one +/// point-in-time decision for the whole scan, then take a fresh snapshot before execution +/// (§7.1 preflight, §7.2 per-item recheck) for a dual-time lock. +struct DiskCleanRunningAppSnapshot: Equatable, Sendable { + /// Bundle IDs of running apps (lowercased; bundle IDs are case-insensitive). + let runningBundleIDs: Set + /// Running process names (`pgrep -x` exact matches; case-sensitive). + let runningProcessNames: Set + let observedAt: Date + + init( + runningBundleIDs: Set = [], + runningProcessNames: Set = [], + observedAt: Date = Date() + ) { + self.runningBundleIDs = Set(runningBundleIDs.map { $0.lowercased() }) + self.runningProcessNames = runningProcessNames + self.observedAt = observedAt + } + + /// Process name that locks this target, or nil when unlocked. + /// + /// On a bundle-ID hit, return the bundle ID itself as the display name — the user sees + /// something like "in use (com.google.Chrome)", which is more honest than a guessed + /// localized app name and needs no extra lookup. + func lockingProcessName(for target: DiskCleanRuleTarget) -> String? { + lockingProcessName( + bundleIDs: target.lockedByBundleIDs, + processNames: target.skipWhenProcessIsRunning + ) + } + + /// Execution-side entry: plan items carry their own lock declarations (copied from the catalog at mint time), so no catalog lookup is needed. + func lockingProcessName(bundleIDs: [String], processNames: [String]) -> String? { + for bundleID in bundleIDs where runningBundleIDs.contains(bundleID.lowercased()) { + return bundleID + } + for name in processNames where runningProcessNames.contains(name) { + return name + } + return nil + } + + /// Replace the bundle-ID set, keep everything else. Used when the executor refreshes lock checks per item. + func replacingBundleIDs(_ bundleIDs: Set, observedAt: Date) -> DiskCleanRunningAppSnapshot { + DiskCleanRunningAppSnapshot( + runningBundleIDs: bundleIDs, + runningProcessNames: runningProcessNames, + observedAt: observedAt + ) + } + + /// Deduplicated process names declared by all targets. That set is the batch pgrep query. + static func processNames(in targets: [DiskCleanRuleTarget]) -> [String] { + var seen: Set = [] + var ordered: [String] = [] + for target in targets { + for name in target.skipWhenProcessIsRunning where seen.insert(name).inserted { + ordered.append(name) + } + } + return ordered + } +} + +/// Snapshot source seam. Scan and execution share one interface so the decision matrix is tested once. +protocol DiskCleanRunningAppSnapshotting: Sendable { + func makeSnapshot(processNames: [String]) async -> DiskCleanRunningAppSnapshot + + /// Refresh only the bundle-ID half; keep process names from the provided snapshot. + /// + /// Used by the executor's per-item lock recheck (§7.2): `NSWorkspace.runningApplications` + /// is a cheap in-memory read, so refreshing per item is fine; `pgrep` spawns a subprocess, + /// and per-item runs would turn one cleanup into dozens of forks, so process names reuse + /// the preflight snapshot. The trade-off is that process-name lock decisions can lag by + /// at most one cleanup duration; the real defenses are the removal primitive's identity + /// checks and freeze (§7.3, §7.4), not this layer. + func refreshingBundleIDs(in snapshot: DiskCleanRunningAppSnapshot) async -> DiskCleanRunningAppSnapshot +} + +extension DiskCleanRunningAppSnapshotting { + func refreshingBundleIDs(in snapshot: DiskCleanRunningAppSnapshot) async -> DiskCleanRunningAppSnapshot { + snapshot + } +} + +/// Real implementation: `NSWorkspace` plus one batched `pgrep`. +/// +/// Both sources are additive only: either source failing only under-reports locks +/// (candidates may still be deleted), so **execution must retake a snapshot and recheck +/// per item** (§7.2). This layer is not the sole defense. +struct DiskCleanRunningAppLock: DiskCleanRunningAppSnapshotting { + static let defaultExecutablePath = "/usr/bin/pgrep" + + let executablePath: String + let timeout: Duration + let subprocessRunner: any DiskCleanSubprocessRunning + let now: @Sendable () -> Date + + init( + executablePath: String = DiskCleanRunningAppLock.defaultExecutablePath, + timeout: Duration = DiskCleanDynamicRuleProviders.subprocessTimeout, + subprocessRunner: any DiskCleanSubprocessRunning = LocalDiskCleanSubprocessRunner(), + now: @escaping @Sendable () -> Date = Date.init + ) { + self.executablePath = executablePath + self.timeout = timeout + self.subprocessRunner = subprocessRunner + self.now = now + } + + func makeSnapshot(processNames: [String]) async -> DiskCleanRunningAppSnapshot { + DiskCleanRunningAppSnapshot( + runningBundleIDs: await Self.runningBundleIDs(), + runningProcessNames: await runningProcessNames(among: processNames), + observedAt: now() + ) + } + + func refreshingBundleIDs(in snapshot: DiskCleanRunningAppSnapshot) async -> DiskCleanRunningAppSnapshot { + snapshot.replacingBundleIDs(await Self.runningBundleIDs(), observedAt: now()) + } + + /// `NSWorkspace` is only guaranteed main-thread safe, so hop to the main actor to read it. + private static func runningBundleIDs() async -> Set { + await MainActor.run { + Set(NSWorkspace.shared.runningApplications.compactMap(\.bundleIdentifier)) + } + } + + private func runningProcessNames(among names: [String]) async -> Set { + guard !names.isEmpty else { return [] } + + let result: DiskCleanSubprocessResult + do { + result = try await subprocessRunner.run( + executablePath: executablePath, + arguments: ["-x", "-l", Self.pattern(for: names)], + timeout: timeout + ) + } catch { + // pgrep missing or timed out → under-report locks; execution-side recheck covers it. + return [] + } + // pgrep exits 1 with empty output when nothing matches — not a failure. + guard result.exitCode == 0 || result.exitCode == 1 else { return [] } + + // Trust only names in this query set: the pattern is a regex, so output could theoretically include surprises. + let requested = Set(names) + return Set(Self.processNames(fromPgrepOutput: result.standardOutput).filter(requested.contains)) + } + + /// Query all names at once: `pgrep -x` pattern is ERE and `-x` requires a full-name match, + /// so `(a|b|c)` means "name exactly equals one of these". + static func pattern(for names: [String]) -> String { + "(" + names.map(escapeForExtendedRegex).joined(separator: "|") + ")" + } + + /// Process names routinely contain regex metacharacters such as `.`, `+`, `(` (`com.docker.backend`); escape every character. + static func escapeForExtendedRegex(_ name: String) -> String { + let metacharacters: Set = ["\\", ".", "^", "$", "*", "+", "?", "(", ")", "[", "]", "{", "}", "|"] + var escaped = "" + escaped.reserveCapacity(name.count) + for character in name { + if metacharacters.contains(character) { + escaped.append("\\") + } + escaped.append(character) + } + return escaped + } + + /// Each `pgrep -l` line is ` `; names may contain spaces, so only strip the pid before the first space. + static func processNames(fromPgrepOutput output: Data) -> [String] { + String(decoding: output, as: UTF8.self) + .split(separator: "\n", omittingEmptySubsequences: true) + .compactMap { line in + guard let separatorIndex = line.firstIndex(of: " ") else { return nil } + let name = String(line[line.index(after: separatorIndex)...]) + return name.isEmpty ? nil : name + } + } +} diff --git a/Plugins/DiskClean/Sources/DiskCleanSafetyPolicy.swift b/Plugins/DiskClean/Sources/DiskCleanSafetyPolicy.swift index 89321152..029585fe 100644 --- a/Plugins/DiskClean/Sources/DiskCleanSafetyPolicy.swift +++ b/Plugins/DiskClean/Sources/DiskCleanSafetyPolicy.swift @@ -53,6 +53,28 @@ struct DiskCleanSafetyPolicy: Sendable { for path: String, isSymlink: Bool = false, resolvedSymlinkTarget: String? = nil + ) -> DiskCleanSafetyStatus { + safetyStatus( + for: path, + alsoChecking: [], + isSymlink: isSymlink, + resolvedSymlinkTarget: resolvedSymlinkTarget + ) + } + + /// Evaluate safety for a physical path, also consulting any logical aliases. + /// + /// Whitelist and sensitive-path rules are **lexical** (home expansion + slash normalize only). + /// Scan expansion rewrites candidates to physical form for `O_NOFOLLOW_ANY`, so a rule like + /// `~/.cache/huggingface/*` would miss `/Volumes/Data/cache/huggingface/*` if we only checked + /// the physical path. Checking every alias means either identity matching a rule protects the item. + /// + /// Path-shape validation still uses the primary path (the deletion target). + func safetyStatus( + for path: String, + alsoChecking alternatePaths: [String], + isSymlink: Bool = false, + resolvedSymlinkTarget: String? = nil ) -> DiskCleanSafetyStatus { let normalizedPath = normalizePath(path) let shapeStatus = validatePathShape( @@ -64,12 +86,23 @@ struct DiskCleanSafetyPolicy: Sendable { return shapeStatus } - if let reason = sensitiveProtectionReason(for: normalizedPath) { - return .protected(reason: reason) + var seen = Set() + var pathsToCheck: [String] = [] + for candidate in [normalizedPath] + alternatePaths.map(normalizePath) { + guard seen.insert(candidate).inserted else { continue } + pathsToCheck.append(candidate) } - if let rule = whitelistStore.matchingRule(for: normalizedPath) { - return .whitelisted(rule: rule.expandedPattern) + for candidate in pathsToCheck { + if let reason = sensitiveProtectionReason(for: candidate) { + return .protected(reason: reason) + } + } + + for candidate in pathsToCheck { + if let rule = whitelistStore.matchingRule(for: candidate) { + return .whitelisted(rule: rule.expandedPattern) + } } return .allowed @@ -99,6 +132,19 @@ struct DiskCleanSafetyPolicy: Sendable { private func sensitiveProtectionReason(for path: String) -> String? { let lower = path.lowercased() + // Staged objects (design §7.6): wide globs and ancestor decomposition can both + // discover orphan staged objects — `directChildren` returns hidden entries too, + // so a leading-dot filter is not enough. The only code allowed to touch these + // objects is startup reconciliation, which addresses them via the journal and + // never through the candidate pipeline. + // + // The design says "last-path-component match"; we broaden that to **any** component + // so paths inside the staging tree are blocked as well. `.mactools-staged-` is a + // plugin-reserved prefix; the cost of over-matching is only "skip cleaning one directory". + if Self.containsStagedComponent(lower) { + return "staging in progress" + } + if Self.hasPathFragment(lower, "/library/keychains") || Self.hasPathFragment(lower, "/.ssh") || Self.hasPathFragment(lower, "/.gnupg") @@ -297,6 +343,12 @@ struct DiskCleanSafetyPolicy: Sendable { || normalized.hasPrefix("/private/var/db/") } + private static func containsStagedComponent(_ lowerPath: String) -> Bool { + lowerPath + .split(separator: "/", omittingEmptySubsequences: true) + .contains { $0.hasPrefix(DiskCleanRemovalPrimitive.stagedNamePrefix) } + } + private static func hasPathFragment(_ lowerPath: String, _ fragment: String) -> Bool { lowerPath == fragment || lowerPath.contains(fragment + "/") || lowerPath.contains(fragment) } diff --git a/Plugins/DiskClean/Sources/DiskCleanScanEngine.swift b/Plugins/DiskClean/Sources/DiskCleanScanEngine.swift new file mode 100644 index 00000000..5cf1145e --- /dev/null +++ b/Plugins/DiskClean/Sources/DiskCleanScanEngine.swift @@ -0,0 +1,1012 @@ +import Foundation +import MacToolsPluginKit + +// MARK: - Engine interface + +protocol DiskCleanScanning: Sendable { + /// Event stream. Consumer cancellation (`break` out of the loop or task cancel) → + /// `onTermination` reaches the engine root task → no new sizing tasks are spawned + /// (design §4.1). + /// + /// Rule-section scope is expressed as `DiskCleanChoice`, not the `Set` + /// written in design §4.1: categories are not isomorphic to v1 panel groups (see + /// `DiskCleanChoice` comments), and choosing by category would silently change scan + /// coverage. Categories remain the unit of display and `categoryFinished`. + /// + /// P2 sections use the same method (design §10): **only the expansion source differs**; + /// sizing, completeness, and artifact minting are all shared, so there is no second path + /// into the executor. + func scan( + scope: DiskCleanScanScope, + forceRefresh: Bool + ) -> AsyncThrowingStream +} + +extension DiskCleanScanning { + func scan( + choices: Set, + forceRefresh: Bool + ) -> AsyncThrowingStream { + scan(scope: .rules(choices: choices), forceRefresh: forceRefresh) + } +} + +/// Execution seam for blocking sizing (wiring point for design §13-7). +/// +/// Folds "run on a resident thread + abandon on timeout + circuit-break state" into one +/// protocol: that is exactly what the engine needs, and tests need to inject hangs and +/// fake circuit-break state without starting real threads to verify limitation derivation. +protocol DiskCleanSizingExecuting: Sendable { + func size( + ofItemAt path: String, + using sizer: any DiskCleanDirectorySizing, + deadline: Date + ) async -> DiskCleanSizeResult + + var isCircuitBroken: Bool { get } + var abandonedThreads: Int { get } +} + +extension DiskCleanWorkerPool: DiskCleanSizingExecuting {} + +struct DiskCleanScanEngineConfiguration: Sendable { + /// Per-item deadline (design §4.2 item 3). Timeout → `partial([.timedOut])`. + var itemTimeout: TimeInterval = 20 + /// Global deadline. After it, remaining candidates are marked timed out immediately and sizing is not submitted. + var globalTimeout: TimeInterval = 300 + /// Concurrency cap for the sizing phase. Matches WorkerPool resident thread count; extra submissions only queue. + var maximumConcurrentSizing: Int = 3 + /// Cap on `volumeSkipped` reports collected during expansion. Under circuit break almost + /// every candidate hits it; reporting all would bloat limitations into hundreds of rows; + /// `walkerCircuitBroken` already expresses the same fact. + var maximumVolumeSkippedReports: Int = 10 + + init() {} +} + +// MARK: - Engine + +/// Scan orchestration (design §4.2). +/// +/// Two phases: **expand** (serial, fast — user sees rows within 1–2s) → **size** (bounded +/// concurrency via WorkerPool, emit events as they complete). The engine itself never does +/// heavy filesystem work; it only owns ordering, concurrency caps, deadlines, and honest aggregation. +struct DiskCleanScanEngine: DiskCleanScanning { + let catalog: DiskCleanRuleCatalogV2 + let fileSystem: any DiskCleanFileSystemProviding + let safetyPolicy: DiskCleanSafetyPolicy + let sizer: any DiskCleanDirectorySizing + let sizingExecutor: any DiskCleanSizingExecuting + let sizeCache: DiskCleanSizeCache + let identityProbe: any DiskCleanRootIdentityProbing + let runningAppLock: any DiskCleanRunningAppSnapshotting + let fullDiskAccess: any DiskCleanFullDiskAccessProbing + /// Expansion sources for P2 sections (design §10). + let developerArtifactExpansion: any DiskCleanExternalExpanding + let installerExpansion: any DiskCleanExternalExpanding + let configuration: DiskCleanScanEngineConfiguration + let localization: PluginLocalization + let now: @Sendable () -> Date + + init( + catalog: DiskCleanRuleCatalogV2 = .current, + fileSystem: any DiskCleanFileSystemProviding = LocalDiskCleanFileSystem(), + safetyPolicy: DiskCleanSafetyPolicy = DiskCleanSafetyPolicy(), + sizer: any DiskCleanDirectorySizing = DiskCleanFastWalker(), + sizingExecutor: any DiskCleanSizingExecuting = DiskCleanWorkerPool.shared, + sizeCache: DiskCleanSizeCache = DiskCleanSizeCache(), + identityProbe: any DiskCleanRootIdentityProbing = DiskCleanRootIdentityProbe(), + runningAppLock: any DiskCleanRunningAppSnapshotting = DiskCleanRunningAppLock(), + fullDiskAccess: any DiskCleanFullDiskAccessProbing = DiskCleanFullDiskAccessProbe.shared, + developerArtifactExpansion: any DiskCleanExternalExpanding = DiskCleanDeveloperArtifactExpansion(), + installerExpansion: any DiskCleanExternalExpanding = DiskCleanInstallerExpansion(), + configuration: DiskCleanScanEngineConfiguration = DiskCleanScanEngineConfiguration(), + localization: PluginLocalization = PluginLocalization(bundle: .main), + now: @escaping @Sendable () -> Date = Date.init + ) { + self.catalog = catalog + self.fileSystem = fileSystem + self.safetyPolicy = safetyPolicy + self.sizer = sizer + self.sizingExecutor = sizingExecutor + self.sizeCache = sizeCache + self.identityProbe = identityProbe + self.runningAppLock = runningAppLock + self.fullDiskAccess = fullDiskAccess + self.developerArtifactExpansion = developerArtifactExpansion + self.installerExpansion = installerExpansion + self.configuration = configuration + self.localization = localization + self.now = now + } + + func scan( + scope: DiskCleanScanScope, + forceRefresh: Bool + ) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let task = Task { + await run(scope: scope, forceRefresh: forceRefresh, continuation: continuation) + } + continuation.onTermination = { _ in + task.cancel() + } + } + } + + // MARK: - Main flow + + private func run( + scope: DiskCleanScanScope, + forceRefresh: Bool, + continuation: AsyncThrowingStream.Continuation + ) async { + let startedAt = now() + let globalDeadline = startedAt.addingTimeInterval(configuration.globalTimeout) + var collector = DiskCleanLimitationCollector( + maximumVolumeSkippedReports: configuration.maximumVolumeSkippedReports + ) + + let scopedTargets = scopedTargets(for: scope) + let lockSnapshot = await runningAppLock.makeSnapshot( + processNames: DiskCleanRunningAppSnapshot.processNames(in: scopedTargets) + ) + + guard !Task.isCancelled else { + continuation.finish(throwing: CancellationError()) + return + } + + // Expansion phase: serial; expand and attribute only, no sizing. + let expansion = await expand( + scope: scope, + targets: scopedTargets, + lockSnapshot: lockSnapshot, + collector: &collector, + continuation: continuation + ) + guard !Task.isCancelled else { + continuation.finish(throwing: CancellationError()) + return + } + + var candidates = expansion.candidates + for candidate in candidates { + continuation.yield(.candidateFound(candidate)) + } + for message in Self.foundLogMessages(for: candidates, localization: localization) { + continuation.yield(.log(message)) + } + + var pendingByCategory = Self.candidateCounts(byCategory: candidates) + for category in expansion.scopedCategories where (pendingByCategory[category] ?? 0) == 0 { + continuation.yield(.categoryFinished(category)) + } + + // Sizing phase. + let categoryByCandidateID = Dictionary( + candidates.map { ($0.id, $0.category) }, + uniquingKeysWith: { first, _ in first } + ) + let sizedResults = await sizeAll( + candidates: candidates, + forceRefresh: forceRefresh, + globalDeadline: globalDeadline + ) { candidateID, result in + continuation.yield(.candidateSized(id: candidateID, result: result)) + guard let category = categoryByCandidateID[candidateID], + let remaining = pendingByCategory[category] else { return } + pendingByCategory[category] = remaining - 1 + if remaining - 1 <= 0 { + continuation.yield(.categoryFinished(category)) + } + } + + guard !Task.isCancelled else { + continuation.finish(throwing: CancellationError()) + return + } + + for index in candidates.indices { + guard let result = sizedResults[candidates[index].id] else { continue } + candidates[index] = candidates[index].applying(result) + collector.observe(sizeResult: result, path: candidates[index].path) + } + collector.observe(pool: sizingExecutor) + + let artifact = DiskCleanScanArtifact( + scope: scope, + candidates: candidates, + reservedRootPaths: expansion.reservedRootPaths, + limitations: collector.limitations, + startedAt: startedAt, + finishedAt: now() + ) + let summary = DiskCleanScanSummary(artifact: artifact) + for message in Self.limitationLogMessages(for: summary.limitations, localization: localization) { + continuation.yield(.log(message)) + } + continuation.yield( + .log( + DiskCleanScanLogMessage( + text: localization.format( + "scanLog.completed", + defaultValue: "扫描完成:%d 项,%d 项可清理", + summary.candidateCount, + summary.cleanableCount + ), + tone: .success + ) + ) + ) + continuation.yield(.finished(summary)) + continuation.finish() + } + + // MARK: - Expansion phase + + private struct ExpansionOutcome { + var candidates: [DiskCleanCandidate] = [] + var reservedRootPaths: [String] = [] + var scopedCategories: [DiskCleanCategoryID] = [] + } + + /// Targets involved in this scan. + /// + /// Rule sections filter by panel group; P2 sections take the matching synthetic targets — + /// they do not participate in rule expansion (`kind == .external`) but must still appear + /// here because lock snapshots and category order are both derived from targets. + private func scopedTargets(for scope: DiskCleanScanScope) -> [DiskCleanRuleTarget] { + switch scope { + case let .rules(choices): + return catalog.targetsInDisplayOrder.filter { + guard !$0.isExternallyDiscovered else { return false } + return DiskCleanChoice(legacyRuleID: $0.legacyRuleID).map(choices.contains) ?? false + } + case .developerArtifacts: + return catalog.targets(in: .developerArtifacts) + case .installers: + return catalog.targets(in: .installers) + } + } + + private func expand( + scope: DiskCleanScanScope, + targets: [DiskCleanRuleTarget], + lockSnapshot: DiskCleanRunningAppSnapshot, + collector: inout DiskCleanLimitationCollector, + continuation: AsyncThrowingStream.Continuation + ) async -> ExpansionOutcome { + var outcome = ExpansionOutcome() + outcome.scopedCategories = targets.reduce(into: []) { categories, target in + guard !categories.contains(target.category) else { return } + categories.append(target.category) + } + + let hits: [DiskCleanTargetHit] + switch scope { + case .rules: + hits = await expandRules( + targets: targets, + outcome: &outcome, + collector: &collector, + continuation: continuation + ) + case .developerArtifacts: + hits = await expandExternally( + using: developerArtifactExpansion, + scope: scope, + outcome: &outcome, + collector: &collector, + continuation: continuation + ) + case .installers: + hits = await expandExternally( + using: installerExpansion, + scope: scope, + outcome: &outcome, + collector: &collector, + continuation: continuation + ) + } + + let assembler = DiskCleanCandidateAssembler(fileSystem: fileSystem) + outcome.candidates = assembler.assemble(hits: hits).map { owned in + makeCandidate(from: owned, lockSnapshot: lockSnapshot) + } + return outcome + } + + /// Expansion via a dedicated scanner (design §10). Product shape matches rule expansion so later steps are shared. + private func expandExternally( + using expander: any DiskCleanExternalExpanding, + scope: DiskCleanScanScope, + outcome: inout ExpansionOutcome, + collector: inout DiskCleanLimitationCollector, + continuation: AsyncThrowingStream.Continuation + ) async -> [DiskCleanTargetHit] { + let expansion = await expander.expand( + scope: scope, + catalog: catalog, + localization: localization + ) + outcome.reservedRootPaths += expansion.reservedRootPaths + for limitation in expansion.limitations { + collector.add(limitation) + } + for message in expansion.logMessages { + continuation.yield(.log(message)) + } + return expansion.hits + } + + private func expandRules( + targets: [DiskCleanRuleTarget], + outcome: inout ExpansionOutcome, + collector: inout DiskCleanLimitationCollector, + continuation: AsyncThrowingStream.Continuation + ) async -> [DiskCleanTargetHit] { + var hits: [DiskCleanTargetHit] = [] + var skippedByFDA: [String] = [] + let hasFullDiskAccess = fullDiskAccess.hasFullDiskAccess + + for target in targets { + guard !Task.isCancelled else { return hits } + + // Without authorization, skip wholesale — never trigger a TCC prompt storm per directory (design §9). + if target.requiresFullDiskAccess && !hasFullDiskAccess { + skippedByFDA.append(target.id) + outcome.reservedRootPaths += target.expandedReservedRootPaths() + continue + } + + do { + let expanded = try await expandHits(for: target) + hits += expanded + continuation.yield( + .log( + DiskCleanScanLogMessage( + text: localization.format( + "scanLog.expandTarget", + defaultValue: "展开 %@:匹配 %d 项", + target.id, + expanded.count + ), + tone: expanded.isEmpty ? .info : .success + ) + ) + ) + } catch { + // Failed targets record a limitation + reserved roots: their subtrees are + // "present but unscanned", so the Planner forbids deleting their ancestors. + // Never block the whole scan. + collector.add(Self.failureLimitation(for: target, error: error)) + outcome.reservedRootPaths += target.expandedReservedRootPaths() + continuation.yield( + .log( + DiskCleanScanLogMessage( + text: localization.format( + "scanLog.targetFailed", + defaultValue: "展开失败:%@(%@)", + target.id, + Self.reason(for: error) + ), + tone: .warning + ) + ) + ) + } + } + + if !skippedByFDA.isEmpty { + collector.add(.fdaRestricted(skippedTargetIDs: skippedByFDA)) + } + return hits + } + + private func expandHits(for target: DiskCleanRuleTarget) async throws -> [DiskCleanTargetHit] { + switch target.kind { + case .external: + // Synthetic-target candidates are discovered by a dedicated scanner; rule expansion + // should not reach here (`scopedTargets(for:)` already splits by scope). Return empty; + // never invent paths. + return [] + + case let .path(globs): + var hits: [DiskCleanTargetHit] = [] + for glob in globs { + let specificity = DiskCleanGlobPrefix.fixedPrefix(of: glob).count + for item in try fileSystem.expandPathPattern(glob) { + hits.append( + DiskCleanTargetHit( + target: target, + item: Self.physical(item), + logicalPath: item.path, + specificity: specificity + ) + ) + } + } + return hits + + case let .dynamic(provider): + let items = try await provider.expand() + let reservedRoots = target.expandedReservedRootPaths() + return items.map { item in + let physical = Self.physical(item) + // Dynamic targets have no glob to measure specificity; use the hit reserved-root + // length instead: reserved roots are the author-declared fixed prefix, same semantics + // as a glob fixed prefix. + let specificity = reservedRoots + .filter { physical.path == $0 || physical.path.hasPrefix($0 + "/") } + .map(\.count) + .max() ?? 0 + return DiskCleanTargetHit( + target: target, + item: physical, + logicalPath: item.path, + specificity: specificity + ) + } + } + } + + /// Always convert paths to physical form (design §13-6): keep the leaf as-is, resolve ancestors. + private static func physical(_ item: DiskCleanFileItem) -> DiskCleanFileItem { + let physicalPath = DiskCleanPhysicalPath.resolve(item.path) + guard physicalPath != item.path else { return item } + return DiskCleanFileItem( + path: physicalPath, + isDirectory: item.isDirectory, + isSymlink: item.isSymlink, + resolvedSymlinkTarget: item.resolvedSymlinkTarget + ) + } + + private func makeCandidate( + from owned: DiskCleanOwnedPath, + lockSnapshot: DiskCleanRunningAppSnapshot + ) -> DiskCleanCandidate { + let target = owned.target + let safety: DiskCleanSafetyStatus + if let processName = lockSnapshot.lockingProcessName(for: target) { + safety = .inUse(processName: processName) + } else { + safety = safetyPolicy.safetyStatus( + for: owned.item.path, + alsoChecking: owned.logicalPaths.filter { $0 != owned.item.path }, + isSymlink: owned.item.isSymlink, + resolvedSymlinkTarget: owned.item.resolvedSymlinkTarget + ) + } + + return DiskCleanCandidate( + id: DiskCleanCandidate.makeID(targetID: target.id, path: owned.item.path), + targetID: target.id, + legacyRuleID: target.legacyRuleID, + category: target.category, + path: owned.item.path, + logicalPaths: owned.logicalPaths, + // When the expansion source supplies no override, use target risk (rule candidates always take this path). + risk: owned.facts.risk ?? target.risk, + safety: safety, + notes: owned.facts.notes, + sizeResult: nil + ) + } + + // MARK: - Sizing phase + + private func sizeAll( + candidates: [DiskCleanCandidate], + forceRefresh: Bool, + globalDeadline: Date, + onResult: (DiskCleanCandidate.ID, DiskCleanSizeResult) -> Void + ) async -> [DiskCleanCandidate.ID: DiskCleanSizeResult] { + guard !candidates.isEmpty else { return [:] } + + let cachingSizer = DiskCleanCachingSizer( + base: sizer, + cache: sizeCache, + identityProbe: identityProbe, + forceRefresh: forceRefresh + ) + var results: [DiskCleanCandidate.ID: DiskCleanSizeResult] = [:] + results.reserveCapacity(candidates.count) + + await withTaskGroup(of: (DiskCleanCandidate.ID, DiskCleanSizeResult).self) { group in + var iterator = candidates.makeIterator() + var inFlight = 0 + + // After cancel, **spawn no more** tasks: in-flight work is wound down by WorkerPool's cancel flag. + func submitNext() -> Bool { + guard !Task.isCancelled, let candidate = iterator.next() else { return false } + group.addTask { + (candidate.id, await size(candidate, using: cachingSizer, globalDeadline: globalDeadline)) + } + inFlight += 1 + return true + } + + while inFlight < max(configuration.maximumConcurrentSizing, 1), submitNext() {} + + while let (candidateID, result) = await group.next() { + inFlight -= 1 + results[candidateID] = result + onResult(candidateID, result) + _ = submitNext() + } + } + + return results + } + + private func size( + _ candidate: DiskCleanCandidate, + using sizer: any DiskCleanDirectorySizing, + globalDeadline: Date + ) async -> DiskCleanSizeResult { + let startedAt = now() + // Global deadline already passed: mark timed out honestly. Do not submit work, but + // still emit events or the UI stays stuck on "calculating". + guard startedAt < globalDeadline else { + return .unavailable(reasons: [.timedOut], observedAt: startedAt) + } + + let itemDeadline = min( + startedAt.addingTimeInterval(configuration.itemTimeout), + globalDeadline + ) + return await sizingExecutor.size( + ofItemAt: candidate.path, + using: sizer, + deadline: itemDeadline + ) + } + + // MARK: - Log and limitation copy + + private static func candidateCounts( + byCategory candidates: [DiskCleanCandidate] + ) -> [DiskCleanCategoryID: Int] { + candidates.reduce(into: [:]) { counts, candidate in + counts[candidate.category, default: 0] += 1 + } + } + + private static func failureLimitation( + for target: DiskCleanRuleTarget, + error: Error + ) -> DiskCleanScanLimitation { + target.isDynamic + ? .dynamicRuleFailed(targetID: target.id, reason: reason(for: error)) + : .targetExpansionFailed(targetID: target.id, reason: reason(for: error)) + } + + private static func reason(for error: Error) -> String { + if let localized = error as? LocalizedError, let description = localized.errorDescription { + return description + } + return String(describing: error) + } + + /// Log only **uncleanable** candidates: cleanable ones are obvious in the list; + /// what needs explaining is "why this one cannot be deleted". + private static func foundLogMessages( + for candidates: [DiskCleanCandidate], + localization: PluginLocalization + ) -> [DiskCleanScanLogMessage] { + candidates.compactMap { candidate in + switch candidate.safety { + case .allowed: + return nil + case let .whitelisted(rule): + return DiskCleanScanLogMessage( + text: localization.format( + "scanLog.candidate.whitelisted", + defaultValue: "白名单保护:%@(%@)", + candidate.path, + rule + ), + tone: .warning + ) + case let .protected(reason): + return DiskCleanScanLogMessage( + text: localization.format( + "scanLog.candidate.protected", + defaultValue: "敏感数据保护:%@(%@)", + candidate.path, + reason + ), + tone: .warning + ) + case let .invalid(reason): + return DiskCleanScanLogMessage( + text: localization.format( + "scanLog.candidate.invalid", + defaultValue: "路径安全保护:%@(%@)", + candidate.path, + reason + ), + tone: .warning + ) + case let .requiresAdmin(reason): + return DiskCleanScanLogMessage( + text: localization.format( + "scanLog.candidate.requiresAdmin", + defaultValue: "需要管理员权限:%@(%@)", + candidate.path, + reason + ), + tone: .warning + ) + case let .inUse(processName): + return DiskCleanScanLogMessage( + text: localization.format( + "scanLog.candidate.inUse", + defaultValue: "正在使用(%@):%@", + processName, + candidate.path + ), + tone: .warning + ) + } + } + } + + private static func limitationLogMessages( + for limitations: [DiskCleanScanLimitation], + localization: PluginLocalization + ) -> [DiskCleanScanLogMessage] { + limitations.map { limitation in + switch limitation { + case let .fdaRestricted(skippedTargetIDs): + return DiskCleanScanLogMessage( + text: localization.format( + "scanLog.limitation.fda", + defaultValue: "未开启完全磁盘访问,已跳过 %d 项规则", + skippedTargetIDs.count + ), + tone: .warning + ) + case let .dynamicRuleFailed(targetID, reason): + return DiskCleanScanLogMessage( + text: localization.format( + "scanLog.limitation.dynamicFailed", + defaultValue: "动态规则失败:%@(%@)", + targetID, + reason + ), + tone: .warning + ) + case let .targetExpansionFailed(targetID, reason): + return DiskCleanScanLogMessage( + text: localization.format( + "scanLog.limitation.expansionFailed", + defaultValue: "规则展开失败:%@(%@)", + targetID, + reason + ), + tone: .warning + ) + case let .volumeSkipped(path): + return DiskCleanScanLogMessage( + text: localization.format( + "scanLog.limitation.volumeSkipped", + defaultValue: "已跳过不支持的卷:%@", + path + ), + tone: .warning + ) + case let .scanRootUnreadable(path, reason): + return DiskCleanScanLogMessage( + text: localization.format( + "scanLog.limitation.scanRootUnreadable", + defaultValue: "无法读取 %@(%@)", + path, + DiskCleanFormat.partialReasons([reason], localization: localization) + ), + tone: .warning + ) + case .walkerCircuitBroken: + return DiskCleanScanLogMessage( + text: localization.string( + "scanLog.limitation.circuitBroken", + defaultValue: "扫描引擎已降级,重启应用恢复" + ), + tone: .error + ) + case let .threadsAbandoned(count): + return DiskCleanScanLogMessage( + text: localization.format( + "scanLog.limitation.threadsAbandoned", + defaultValue: "%d 个扫描线程无响应已被放弃", + count + ), + tone: .warning + ) + } + } + } +} + +// MARK: - Limitation collection + +/// Limitation collector with stable order, dedup, and bounded report volume. +struct DiskCleanLimitationCollector { + private(set) var limitations: [DiskCleanScanLimitation] = [] + private var volumeSkippedPaths: Set = [] + private let maximumVolumeSkippedReports: Int + + init(maximumVolumeSkippedReports: Int) { + self.maximumVolumeSkippedReports = max(maximumVolumeSkippedReports, 0) + } + + mutating func add(_ limitation: DiskCleanScanLimitation) { + guard !limitations.contains(limitation) else { return } + limitations.append(limitation) + } + + /// Derive volume-level limitations from sizing results. + mutating func observe(sizeResult: DiskCleanSizeResult, path: String) { + guard sizeResult.completeness.partialReasons.contains(.unsupportedVolume) else { return } + guard volumeSkippedPaths.count < maximumVolumeSkippedReports else { return } + guard volumeSkippedPaths.insert(path).inserted else { return } + add(.volumeSkipped(path: path)) + } + + /// Circuit break and thread abandons are derived from WorkerPool's **process-wide** state + /// (design §4.5, §13-7). Do not depend on "this scan happened to produce certain candidates" — + /// budget burned by a prior scan must still be visible. + mutating func observe(pool: any DiskCleanSizingExecuting) { + if pool.isCircuitBroken { + add(.walkerCircuitBroken) + } + let abandoned = pool.abandonedThreads + if abandoned > 0 { + add(.threadsAbandoned(count: abandoned)) + } + } +} + +// MARK: - Ownership attribution and ancestor decomposition + +/// One raw hit from the expansion phase. +enum DiskCleanPathAlias { + static func unique(_ paths: [String]) -> [String] { + var seen = Set() + var result: [String] = [] + for path in paths where seen.insert(path).inserted { + result.append(path) + } + return result + } +} + +struct DiskCleanTargetHit: Sendable { + let target: DiskCleanRuleTarget + let item: DiskCleanFileItem + /// Paths before physical conversion. Multiple aliases can resolve to the same physical item. + let logicalPaths: [String] + /// Fixed-prefix length of the hitting glob (reserved root for dynamic targets); longer is more specific. + /// P2 synthetic targets have no measurable glob and are always 0 — the same path is never hit by two synthetic targets. + let specificity: Int + /// Candidate attributes known only at expansion time (P2 git state, installer age, etc.). Rule hits are always `.inherited`. + let facts: DiskCleanCandidateFacts + + /// Primary logical path for display / legacy call sites. + var logicalPath: String { logicalPaths.first ?? item.path } + + init( + target: DiskCleanRuleTarget, + item: DiskCleanFileItem, + logicalPath: String? = nil, + logicalPaths: [String]? = nil, + specificity: Int, + facts: DiskCleanCandidateFacts = .inherited + ) { + self.target = target + self.item = item + if let logicalPaths { + self.logicalPaths = DiskCleanPathAlias.unique(logicalPaths.isEmpty ? [item.path] : logicalPaths) + } else if let logicalPath { + self.logicalPaths = DiskCleanPathAlias.unique([logicalPath]) + } else { + self.logicalPaths = [item.path] + } + self.specificity = specificity + self.facts = facts + } + + func mergingLogicalPaths(from other: DiskCleanTargetHit) -> DiskCleanTargetHit { + DiskCleanTargetHit( + target: target, + item: item, + logicalPaths: logicalPaths + other.logicalPaths, + specificity: specificity, + facts: facts + ) + } +} + +struct DiskCleanOwnedPath: Sendable { + let target: DiskCleanRuleTarget + let item: DiskCleanFileItem + /// All pre-physical aliases that resolve to this physical path. + let logicalPaths: [String] + /// Children produced by ancestor decomposition inherit the source hit's facts — they are fragments of the same hit, so risk and notes should match. + let facts: DiskCleanCandidateFacts + + var logicalPath: String { logicalPaths.first ?? item.path } + + init( + target: DiskCleanRuleTarget, + item: DiskCleanFileItem, + logicalPath: String? = nil, + logicalPaths: [String]? = nil, + facts: DiskCleanCandidateFacts = .inherited + ) { + self.target = target + self.item = item + if let logicalPaths { + self.logicalPaths = DiskCleanPathAlias.unique(logicalPaths.isEmpty ? [item.path] : logicalPaths) + } else if let logicalPath { + self.logicalPaths = DiskCleanPathAlias.unique([logicalPath]) + } else { + self.logicalPaths = [item.path] + } + self.facts = facts + } +} + +/// Ownership attribution and ancestor decomposition (design §5.3). Purely functional: hit set in, final attribution table out. +struct DiskCleanCandidateAssembler: Sendable { + /// Decomposition recursion depth cap. Recursion only descends branches that still have + /// descendant candidates, so depth is naturally bounded; the cap is only a backstop for + /// pathological input (extremely deep paths). + static let maximumDecompositionDepth = 64 + + let fileSystem: any DiskCleanFileSystemProviding + + func assemble(hits: [DiskCleanTargetHit]) -> [DiskCleanOwnedPath] { + let owners = Self.resolveOwnership(hits: hits) + let paths = owners.keys.sorted() + var results: [DiskCleanOwnedPath] = [] + let pathSet = Set(paths) + + for path in paths { + guard let hit = owners[path] else { continue } + // No descendant candidates → keep an independent identity and accept as-is. + guard Self.hasStrictDescendant(of: path, in: paths) else { + results.append( + DiskCleanOwnedPath( + target: hit.target, + item: hit.item, + logicalPaths: hit.logicalPaths, + facts: hit.facts + ) + ) + continue + } + results += decompose( + path: path, + target: hit.target, + logicalRoots: hit.logicalPaths, + physicalRoot: hit.item.path, + facts: hit.facts, + allPaths: pathSet, + sortedPaths: paths, + depth: 0 + ) + } + + return results.sorted { $0.item.path < $1.item.path } + } + + /// Same physical path hit by multiple targets/aliases → keep the most specific ownership, + /// but **merge every logical alias**. Otherwise a more-specific `~/Library/Caches/pip/*` + /// hit would discard `~/.cache/pip/*` and its whitelist rule would never be checked. + static func resolveOwnership(hits: [DiskCleanTargetHit]) -> [String: DiskCleanTargetHit] { + var owners: [String: DiskCleanTargetHit] = [:] + for hit in hits { + guard let incumbent = owners[hit.item.path] else { + owners[hit.item.path] = hit + continue + } + if isMoreSpecific(hit, than: incumbent) { + owners[hit.item.path] = hit.mergingLogicalPaths(from: incumbent) + } else { + owners[hit.item.path] = incumbent.mergingLogicalPaths(from: hit) + } + } + return owners + } + + private static func isMoreSpecific( + _ candidate: DiskCleanTargetHit, + than incumbent: DiskCleanTargetHit + ) -> Bool { + if candidate.specificity != incumbent.specificity { + return candidate.specificity > incumbent.specificity + } + if candidate.target.risk != incumbent.target.risk { + return candidate.target.risk > incumbent.target.risk + } + return candidate.target.id < incumbent.target.id + } + + /// Ancestor decomposition: if A is an ancestor of B → replace A with its direct children + /// (inheriting A's target and risk), recurse until no other candidates remain under it; + /// B keeps an independent identity (its own risk, lock, default selection). + /// + /// If the directory cannot be listed, **drop A wholesale**: keeping A would delete B's + /// subtree as a side effect, erasing B's independent risk and selection. Prefer cleaning + /// less over overreaching. + private func decompose( + path: String, + target: DiskCleanRuleTarget, + logicalRoots: [String], + physicalRoot: String, + facts: DiskCleanCandidateFacts, + allPaths: Set, + sortedPaths: [String], + depth: Int + ) -> [DiskCleanOwnedPath] { + guard depth < Self.maximumDecompositionDepth else { return [] } + guard let children = try? fileSystem.directChildren(of: path) else { return [] } + + var results: [DiskCleanOwnedPath] = [] + for child in children { + // The child is itself a candidate → it has its own identity; do not absorb it. + if allPaths.contains(child.path) { + continue + } + if Self.hasStrictDescendant(of: child.path, in: sortedPaths) { + results += decompose( + path: child.path, + target: target, + logicalRoots: logicalRoots, + physicalRoot: physicalRoot, + facts: facts, + allPaths: allPaths, + sortedPaths: sortedPaths, + depth: depth + 1 + ) + continue + } + results.append( + DiskCleanOwnedPath( + target: target, + item: child, + logicalPaths: Self.logicalChildPaths( + physicalChild: child.path, + physicalRoot: physicalRoot, + logicalRoots: logicalRoots + ), + facts: facts + ) + ) + } + return results + } + + /// Map a decomposed physical child under every logical root when possible. + private static func logicalChildPaths( + physicalChild: String, + physicalRoot: String, + logicalRoots: [String] + ) -> [String] { + guard physicalChild == physicalRoot || physicalChild.hasPrefix(physicalRoot + "/") else { + return [physicalChild] + } + let suffix = String(physicalChild.dropFirst(physicalRoot.count)) + return DiskCleanPathAlias.unique(logicalRoots.map { $0 + suffix }) + } + + /// `sortedPaths` is sorted, so strict descendants necessarily appear contiguously afterward. + static func hasStrictDescendant(of path: String, in sortedPaths: [String]) -> Bool { + let prefix = path.hasSuffix("/") ? path : path + "/" + guard let index = sortedPaths.firstIndex(where: { $0 > path }) else { return false } + return sortedPaths[index].hasPrefix(prefix) + } +} diff --git a/Plugins/DiskClean/Sources/DiskCleanScanner.swift b/Plugins/DiskClean/Sources/DiskCleanScanner.swift deleted file mode 100644 index 1144799d..00000000 --- a/Plugins/DiskClean/Sources/DiskCleanScanner.swift +++ /dev/null @@ -1,301 +0,0 @@ -import Foundation -import MacToolsPluginKit - -typealias DiskCleanScanProgressHandler = @Sendable (DiskCleanScanLogMessage) async -> Void - -protocol DiskCleanScanning: Sendable { - func scan( - choices: Set, - progress: DiskCleanScanProgressHandler - ) async throws -> DiskCleanScanResult -} - -extension DiskCleanScanning { - func scan(choices: Set) async throws -> DiskCleanScanResult { - try await scan(choices: choices, progress: { _ in }) - } -} - -protocol DiskCleanProcessInspecting: Sendable { - func runningProcessName(from names: [String]) -> String? -} - -struct LocalDiskCleanProcessInspector: DiskCleanProcessInspecting { - func runningProcessName(from names: [String]) -> String? { - for name in names where isProcessRunning(name) { - return name - } - return nil - } - - private func isProcessRunning(_ name: String) -> Bool { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/pgrep") - process.arguments = ["-x", name] - - do { - try process.run() - process.waitUntilExit() - return process.terminationStatus == 0 - } catch { - return false - } - } -} - -struct DiskCleanScanner: DiskCleanScanning { - private struct ExpandedItem { - let rule: DiskCleanRule - let item: DiskCleanFileItem - } - - let ruleCatalog: DiskCleanRuleCatalog - let fileSystem: DiskCleanFileSystemProviding - let safetyPolicy: DiskCleanSafetyPolicy - let processInspector: DiskCleanProcessInspecting - let now: @Sendable () -> Date - let localization: PluginLocalization - - init( - ruleCatalog: DiskCleanRuleCatalog = .moleFirstVersion, - fileSystem: DiskCleanFileSystemProviding = LocalDiskCleanFileSystem(), - safetyPolicy: DiskCleanSafetyPolicy = DiskCleanSafetyPolicy(), - processInspector: DiskCleanProcessInspecting = LocalDiskCleanProcessInspector(), - now: @escaping @Sendable () -> Date = Date.init, - localization: PluginLocalization = PluginLocalization(bundle: .main) - ) { - self.ruleCatalog = ruleCatalog - self.fileSystem = fileSystem - self.safetyPolicy = safetyPolicy - self.processInspector = processInspector - self.now = now - self.localization = localization - } - - func scan( - choices: Set, - progress: DiskCleanScanProgressHandler - ) async throws -> DiskCleanScanResult { - var expandedItems: [ExpandedItem] = [] - var currentChoice: DiskCleanChoice? - - for rule in ruleCatalog.rules where choices.contains(rule.choice) { - try Task.checkCancellation() - - if currentChoice != rule.choice { - currentChoice = rule.choice - await progress( - DiskCleanScanLogMessage( - text: localization.format( - "scanLog.scope", - defaultValue: "扫描范围:%@", - rule.choice.title(localization: localization) - ), - tone: .info - ) - ) - } - - await progress( - DiskCleanScanLogMessage( - text: localization.format("scanLog.expandRule", defaultValue: "展开规则:%@", rule.title), - tone: .info - ) - ) - - for target in rule.targets { - try Task.checkCancellation() - let items = try expand(target) - await progress( - DiskCleanScanLogMessage( - text: localization.format( - "scanLog.matched", - defaultValue: "匹配 %d 项:%@", - items.count, - rule.title - ), - tone: items.isEmpty ? .info : .success - ) - ) - expandedItems += items.map { ExpandedItem(rule: rule, item: $0) } - } - } - - let firstItemByPath = Dictionary( - expandedItems.map { ($0.item.path, $0) }, - uniquingKeysWith: { first, _ in first } - ) - let deduplicatedPaths = fileSystem.deduplicatedParentChildPaths(expandedItems.map(\.item.path)) - var candidates: [DiskCleanCandidate] = [] - for path in deduplicatedPaths { - try Task.checkCancellation() - guard let expandedItem = firstItemByPath[path] else { - continue - } - let candidate = makeCandidate(from: expandedItem) - candidates.append(candidate) - await progress(logMessage(for: candidate)) - } - - await progress( - DiskCleanScanLogMessage( - text: localization.format( - "scanLog.completed", - defaultValue: "扫描完成:%d 项,%d 项可清理", - candidates.count, - candidates.filter { $0.safety.isCleanable }.count - ), - tone: .success - ) - ) - - return DiskCleanScanResult( - choices: choices, - candidates: candidates, - scannedAt: now() - ) - } - - private func expand(_ target: DiskCleanRule.Target) throws -> [DiskCleanFileItem] { - switch target { - case let .path(pattern): - return try fileSystem.expandPathPattern(pattern) - case let .dynamic(dynamicRule): - return try expand(dynamicRule) - } - } - - private func expand(_ dynamicRule: DiskCleanDynamicRule) throws -> [DiskCleanFileItem] { - var items: [DiskCleanFileItem] = [] - for pattern in staticFallbackPatterns(for: dynamicRule) { - items += try fileSystem.expandPathPattern(pattern) - } - return items - } - - private func staticFallbackPatterns(for dynamicRule: DiskCleanDynamicRule) -> [String] { - switch dynamicRule { - case .xcodeDerivedData: - return ["~/Library/Developer/Xcode/DerivedData/*"] - case .unavailableSimulators: - return [] - case .npmCache: - return [ - "~/.npm/_cacache/*", - "~/.npm/_npx/*", - "~/.npm/_logs/*", - "~/.npm/_prebuilds/*" - ] - case .pnpmStore: - return ["~/Library/pnpm/store/*"] - case .bunCache: - return ["~/.bun/install/cache/*"] - case .pipCache: - return [ - "~/Library/Caches/pip/*", - "~/.cache/pip/*" - ] - case .goBuildCache: - return [ - "~/Library/Caches/go-build/*", - "~/.cache/go-build/*" - ] - case .goModuleCache: - return ["~/go/pkg/mod/*"] - case .miseCache: - return ["~/Library/Caches/mise/*"] - case .jetbrainsToolboxOldVersions: - return [] - case .aiAgentOldVersions: - return [] - case .serviceWorkerCache: - return [ - "~/Library/Application Support/Google/Chrome/*/Service Worker/CacheStorage/*/*", - "~/Library/Application Support/Arc/*/Service Worker/CacheStorage/*/*", - "~/Library/Application Support/BraveSoftware/Brave-Browser/*/Service Worker/CacheStorage/*/*", - "~/Library/Application Support/Vivaldi/*/Service Worker/CacheStorage/*/*", - "~/Library/Application Support/Code/Service Worker/CacheStorage/*/*", - "~/Library/Application Support/Cursor/Service Worker/CacheStorage/*/*" - ] - case .oldBrowserVersions: - return [] - } - } - - private func makeCandidate(from expandedItem: ExpandedItem) -> DiskCleanCandidate { - let rule = expandedItem.rule - let item = expandedItem.item - let safety = safetyStatus(for: rule, item: item) - let size = (try? fileSystem.sizeOfItem(at: item.path)) ?? 0 - - return DiskCleanCandidate( - id: "\(rule.id)::\(item.path)", - ruleID: rule.id, - choice: rule.choice, - title: rule.title, - path: item.path, - sizeBytes: size, - safety: safety, - risk: rule.risk - ) - } - - private func logMessage(for candidate: DiskCleanCandidate) -> DiskCleanScanLogMessage { - switch candidate.safety { - case .allowed: - return DiskCleanScanLogMessage( - text: localization.format("scanLog.candidate.allowed", defaultValue: "可清理:%@", candidate.path), - tone: .success - ) - case .whitelisted: - return DiskCleanScanLogMessage( - text: localization.format("scanLog.candidate.whitelisted", defaultValue: "白名单保护:%@", candidate.path), - tone: .warning - ) - case .protected: - return DiskCleanScanLogMessage( - text: localization.format("scanLog.candidate.protected", defaultValue: "敏感数据保护:%@", candidate.path), - tone: .warning - ) - case .invalid: - return DiskCleanScanLogMessage( - text: localization.format("scanLog.candidate.invalid", defaultValue: "路径安全保护:%@", candidate.path), - tone: .warning - ) - case .requiresAdmin: - return DiskCleanScanLogMessage( - text: localization.format("scanLog.candidate.requiresAdmin", defaultValue: "需要管理员权限:%@", candidate.path), - tone: .warning - ) - case let .inUse(processName): - return DiskCleanScanLogMessage( - text: localization.format( - "scanLog.candidate.inUse", - defaultValue: "正在使用(%@):%@", - processName, - candidate.path - ), - tone: .warning - ) - } - } - - private func safetyStatus( - for rule: DiskCleanRule, - item: DiskCleanFileItem - ) -> DiskCleanSafetyStatus { - if let processName = processInspector.runningProcessName(from: rule.skipWhenProcessIsRunning) { - return .inUse(processName: processName) - } - - if rule.requiresAdmin { - return .requiresAdmin(reason: "admin privileges required") - } - - return safetyPolicy.safetyStatus( - for: item.path, - isSymlink: item.isSymlink, - resolvedSymlinkTarget: item.resolvedSymlinkTarget - ) - } -} diff --git a/Plugins/DiskClean/Sources/DiskCleanSectionView.swift b/Plugins/DiskClean/Sources/DiskCleanSectionView.swift new file mode 100644 index 00000000..6d63f2da --- /dev/null +++ b/Plugins/DiskClean/Sources/DiskCleanSectionView.swift @@ -0,0 +1,397 @@ +import SwiftUI +import MacToolsPluginKit + +// MARK: - Section guidance states + +/// What a section should say when there are "no candidates to show" (design §10). +/// +/// Purely derived, view-free, so it can be asserted directly against snapshots. It exists +/// because several empty states **must stay distinct**: no scan roots configured, never +/// scanned, scanned but access denied, roots unreadable, truly empty — each demands a +/// different next step from the user. Collapsing them into one "nothing here" makes the user guess. +enum DiskCleanSectionGuidance: Equatable, Sendable { + /// Show the candidate list normally. + case candidates + /// Developer-artifacts section has no scan roots configured yet. + case needsRoots + /// Not scanned yet. + case notScanned + /// Scan root access denied (TCC). **Must never look like "nothing cleanable"** — + /// `~/Downloads` may hold tens of GB of installers. + case accessDenied(path: String) + /// Scan root cannot be opened: deleted, replaced by a file, or on a non-local volume. + case rootsUnreadable(paths: [String]) + /// Scanned; truly empty. + case empty + + static func resolve(_ snapshot: DiskCleanControllerSnapshot) -> DiskCleanSectionGuidance { + if case .developerArtifacts = snapshot.scope, snapshot.scope.isEmpty { + return .needsRoots + } + guard let result = snapshot.scanResult else { return .notScanned } + // With candidates, show them normally; root problems are reported honestly by the limitation banner and need not own the whole list. + guard result.candidates.isEmpty else { return .candidates } + + let unreadable = result.limitations.compactMap { limitation -> (String, DiskCleanScanCompleteness.PartialReason)? in + guard case let .scanRootUnreadable(path, reason) = limitation else { return nil } + return (path, reason) + } + if let denied = unreadable.first(where: { $0.1 == .permissionDenied }) { + return .accessDenied(path: denied.0) + } + if !unreadable.isEmpty { + return .rootsUnreadable(paths: unreadable.map(\.0)) + } + return .empty + } +} + +// MARK: - Reusable pieces + +/// Banner (shared by limitation, error, and FDA guidance). +struct DiskCleanBanner: View { + let symbolName: String + let tint: Color + let title: String + let lines: [String] + @ViewBuilder let trailing: () -> Trailing + + var body: some View { + HStack(alignment: .top, spacing: PluginSettingsTheme.Spacing.rowContentControl) { + Image(systemName: symbolName) + .foregroundStyle(tint) + .frame(width: PluginSettingsTheme.Size.rowIcon) + + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.rowTitleDescription) { + Text(title) + .font(PluginSettingsTheme.Typography.rowTitle) + ForEach(Array(lines.enumerated()), id: \.offset) { _, line in + Text(line) + .font(PluginSettingsTheme.Typography.rowDescription) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + Spacer(minLength: PluginSettingsTheme.Spacing.rowContentControl) + + trailing() + } + .pluginSettingsListRowPadding() + .pluginSettingsCardBackground(.plugin) + } +} + +extension DiskCleanBanner where Trailing == EmptyView { + init(symbolName: String, tint: Color, title: String, lines: [String]) { + self.init(symbolName: symbolName, tint: tint, title: title, lines: lines) { EmptyView() } + } +} + +/// Empty state. Optional action button — if empty state only says "nothing here" while the +/// user still needs a configuration step, that empty state is a dead end. +struct DiskCleanEmptyState: View { + let symbolName: String + let text: String + @ViewBuilder let action: () -> Action + + var body: some View { + HStack { + Spacer() + VStack(spacing: PluginSettingsTheme.Spacing.controlCluster) { + Image(systemName: symbolName) + .font(.system(size: PluginSettingsTheme.Size.emptyStateIcon)) + .foregroundStyle(.secondary) + Text(text) + .font(PluginSettingsTheme.Typography.pageDescription) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + action() + } + .padding(.vertical, PluginSettingsTheme.Spacing.pagePadding) + Spacer() + } + .pluginSettingsCardBackground(.plugin) + } +} + +extension DiskCleanEmptyState where Action == EmptyView { + init(symbolName: String, text: String) { + self.init(symbolName: symbolName, text: text) { EmptyView() } + } +} + +struct DiskCleanSectionHeader: View { + let title: String + let symbolName: String + + var body: some View { + Label(title, systemImage: symbolName) + .font(PluginSettingsTheme.Typography.sectionTitle) + .foregroundStyle(.secondary) + } +} + +/// Scan / clean / stop + selection summary. Shared by all three sections so behavior and copy cannot diverge. +struct DiskCleanActionBar: View { + let snapshot: DiskCleanControllerSnapshot + let localization: PluginLocalization + let onScan: () -> Void + let onClean: () -> Void + let onCancel: () -> Void + + var body: some View { + HStack(spacing: PluginSettingsTheme.Spacing.controlCluster) { + Button(action: onScan) { + Label( + localization.string("detail.action.scan", defaultValue: "扫描"), + systemImage: "magnifyingglass" + ) + .font(PluginSettingsTheme.Typography.controlLabel) + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(!snapshot.canScan) + + Button(action: onClean) { + Label( + DiskCleanFormat.cleanActionTitle(snapshot, localization: localization), + systemImage: "trash" + ) + .font(PluginSettingsTheme.Typography.controlLabel) + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + .disabled(!snapshot.canClean) + + if snapshot.isBusy { + Button(action: onCancel) { + Label( + localization.string("detail.action.stop", defaultValue: "停止"), + systemImage: "xmark.circle" + ) + .font(PluginSettingsTheme.Typography.controlLabel) + } + .buttonStyle(.bordered) + .controlSize(.small) + } + + if snapshot.phase == .scanning { + ProgressView() + .controlSize(.small) + .scaleEffect(0.8) + } + + Spacer(minLength: 0) + + Text(DiskCleanFormat.selectionSummary(snapshot, localization: localization)) + .font(PluginSettingsTheme.Typography.rowDescription) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } +} + +// MARK: - P2 sections + +/// Developer-artifacts / leftover-installers sections (design §8.3 item 4, §10). +/// +/// Shares the same `DiskCleanController` as the rules section: candidates, selection, plan +/// minting, and execution all use one pipeline; this view only lays out "scan entry + guidance +/// + candidate list". An **independent scan entry** is a design requirement — developer +/// artifacts walk user project trees and installers may trigger a `~/Downloads` TCC prompt; +/// neither should ride along with ordinary cache scans. +struct DiskCleanCleanupSectionView: View { + @ObservedObject var controller: DiskCleanController + let title: String + let symbolName: String + let localization: PluginLocalization + /// Section-specific configuration (scan-root management for the developer-artifacts section). + @ViewBuilder let configuration: () -> Configuration + + @State private var expandedCategories: Set = [] + + var body: some View { + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.sectionHeaderContent) { + DiskCleanSectionHeader(title: title, symbolName: symbolName) + + configuration() + + VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.rowContentControl) { + DiskCleanActionBar( + snapshot: snapshot, + localization: localization, + onScan: { controller.scan() }, + onClean: { controller.clean() }, + onCancel: { controller.cancelCurrentOperation() } + ) + } + .pluginSettingsListRowPadding(interactive: true) + .pluginSettingsCardBackground(.plugin) + + if let errorMessage = snapshot.errorMessage { + DiskCleanBanner( + symbolName: "xmark.octagon.fill", + tint: .red, + title: localization.string("detail.error.title", defaultValue: "操作未完成"), + lines: [errorMessage] + ) + } + + content + } + .confirmationDialog( + DiskCleanFormat.confirmationTitle(snapshot, localization: localization), + isPresented: confirmationBinding, + titleVisibility: .visible + ) { + Button(role: .destructive) { + controller.confirmPendingClean() + } label: { + Text(localization.string("detail.confirm.confirm", defaultValue: "永久删除")) + } + Button(role: .cancel) { + controller.cancelPendingClean() + } label: { + Text(localization.string("detail.action.cancelClean", defaultValue: "取消")) + } + } message: { + Text( + localization.string( + "detail.confirm.message", + defaultValue: "永久删除不进废纸篓,无法恢复。" + ) + ) + } + } + + private var snapshot: DiskCleanControllerSnapshot { + controller.snapshot + } + + @ViewBuilder + private var content: some View { + switch DiskCleanSectionGuidance.resolve(snapshot) { + case .candidates: + DiskCleanCategoryListView( + groups: DiskCleanCategoryGroup.groups( + candidates: snapshot.scanResult?.candidates ?? [], + selection: snapshot.selection + ), + selection: snapshot.selection, + outcomesByCandidateID: outcomesByCandidateID, + localization: localization, + isInteractionEnabled: !snapshot.isBusy, + onToggleCandidate: { controller.setCandidateSelected($0, isSelected: $1) }, + onToggleCategory: { controller.setCategorySelection($0, isSelected: $1) }, + expandedCategories: $expandedCategories + ) + + case .needsRoots: + // Empty state itself has no "add folder" button: the entry lives in the scan-root + // manager above; putting one in both places would look like two different actions. + DiskCleanEmptyState( + symbolName: "folder.badge.plus", + text: localization.string( + "detail.developerArtifacts.needsRoots", + defaultValue: "先添加要扫描的工程文件夹,只会扫描你指定的目录" + ) + ) + + case .notScanned: + DiskCleanEmptyState( + symbolName: "magnifyingglass", + text: localization.string( + "detail.section.notScanned", + defaultValue: "点击「扫描」查看可清理内容" + ) + ) + + case let .accessDenied(path): + DiskCleanBanner( + symbolName: "lock.fill", + tint: .orange, + title: localization.string( + "detail.section.accessDenied.title", + defaultValue: "没有访问权限,无法确认里面有什么" + ), + lines: [ + localization.format( + "detail.section.accessDenied.message", + defaultValue: "系统拒绝了对 %@ 的访问。在系统设置里允许后重新扫描。", + path + ) + ] + ) { + Button { + DiskCleanFullDiskAccessGuide.openSettings() + } label: { + Text(localization.string("detail.fda.openSettings", defaultValue: "前往授权")) + .font(PluginSettingsTheme.Typography.controlLabel) + } + .buttonStyle(.bordered) + .controlSize(.small) + } + + case let .rootsUnreadable(paths): + DiskCleanBanner( + symbolName: "questionmark.folder", + tint: .orange, + title: localization.string( + "detail.section.rootsUnreadable.title", + defaultValue: "有文件夹已无法读取" + ), + lines: paths.map { + localization.format( + "detail.section.rootsUnreadable.line", + defaultValue: "%@ 已被移除、替换或位于其他卷上。", + $0 + ) + } + ) + + case .empty: + DiskCleanEmptyState( + symbolName: "checkmark.circle", + text: localization.string("detail.candidates.empty", defaultValue: "没有发现可清理项目") + ) + } + } + + private var outcomesByCandidateID: [DiskCleanCandidate.ID: DiskCleanExecutionItemResult.Outcome] { + guard let executionResult = snapshot.executionResult else { return [:] } + return Dictionary( + executionResult.itemResults.map { ($0.candidateID, $0.outcome) }, + uniquingKeysWith: { first, _ in first } + ) + } + + private var confirmationBinding: Binding { + Binding( + get: { snapshot.phase == .confirming }, + set: { isPresented in + guard !isPresented else { return } + controller.cancelPendingClean() + } + ) + } +} + +extension DiskCleanCleanupSectionView where Configuration == EmptyView { + init( + controller: DiskCleanController, + title: String, + symbolName: String, + localization: PluginLocalization + ) { + self.init( + controller: controller, + title: title, + symbolName: symbolName, + localization: localization + ) { + EmptyView() + } + } +} diff --git a/Plugins/DiskClean/Sources/DiskCleanSelectionModel.swift b/Plugins/DiskClean/Sources/DiskCleanSelectionModel.swift new file mode 100644 index 00000000..0633fe90 --- /dev/null +++ b/Plugins/DiskClean/Sources/DiskCleanSelectionModel.swift @@ -0,0 +1,217 @@ +import Foundation + +// MARK: - Category tri-state + +/// Tri-state for category checkboxes (design §8.1). +/// +/// `unavailable` and `noneSelected` must stay distinct: the former means "this category has +/// nothing selectable" (checkbox disabled; clicking does nothing), the latter means "there +/// are selectable items but none are selected" (one click selects all low-risk items). +/// Merging them makes users hammer a checkbox that never moves. +/// +/// There is no `none` case name: this type appears as dictionary values, and +/// `dict[key] = .none` would be read as `Optional.none` (delete the key) — a trap not worth leaving. +enum DiskCleanCategorySelectionState: Equatable, Sendable { + case unavailable + case noneSelected + case partiallySelected + case allSelected + + var isSelectable: Bool { + self != .unavailable + } + + /// Whether the checkbox currently looks checked (partial uses a dash rather than a tick; the view distinguishes that). + var isChecked: Bool { + self == .allSelected || self == .partiallySelected + } +} + +// MARK: - Selection projection + +/// Read-only projection of selection state. Snapshots carry it; detail and menu-bar both read only this copy. +/// +/// Menu bar and detail share one authoritative selection (design review round1-#5): both entries +/// read the same projection and issue the same commands. There is no dual semantics of +/// "panel cleans everything / detail cleans checked items". +struct DiskCleanSelectionProjection: Equatable, Sendable { + let selectedIDs: Set + let selectableIDs: Set + let selectedEstimatedBytes: Int64 + let categoryStates: [DiskCleanCategoryID: DiskCleanCategorySelectionState] + + static let empty = DiskCleanSelectionProjection( + selectedIDs: [], + selectableIDs: [], + selectedEstimatedBytes: 0, + categoryStates: [:] + ) + + var selectedCount: Int { selectedIDs.count } + + var isEmpty: Bool { selectedIDs.isEmpty } + + func isSelected(_ candidateID: DiskCleanCandidate.ID) -> Bool { + selectedIDs.contains(candidateID) + } + + func isSelectable(_ candidateID: DiskCleanCandidate.ID) -> Bool { + selectableIDs.contains(candidateID) + } + + /// Categories absent from this scan are treated as `unavailable` — no candidates means nothing selectable. + func state(of category: DiskCleanCategoryID) -> DiskCleanCategorySelectionState { + categoryStates[category] ?? .unavailable + } +} + +// MARK: - Selection model + +/// Authoritative tri-state selection model (design §8.1). Owned exclusively by the Controller. +/// +/// **This model does not store candidates — only "what the user did"**: per-candidate explicit +/// overrides, and per-category explicit select-all / deselect-all. Selectedness is always +/// derived from `(candidate facts, user overrides, category operations)`. Streaming new +/// candidates therefore need no "backfill registration" step to land on the right side — +/// during scan, candidates appear without sizes and are later completed by `candidateSized`; +/// an incrementally maintained selected set is easy to desync from candidate facts between +/// those moments, while recomputation cannot desync. +/// +/// Three semantics (design §8.1): +/// - **Unselectable means toggle is rejected**, not merely UI-disabled: locked / whitelisted / +/// protected / non-complete / unsized / mount-containing candidates make `setCandidate` +/// return false with no record kept. +/// - **Default selected = low risk and selectable**. medium/high and dynamic-rule products +/// (target risk always >= medium) are never selected by default. +/// - **"Select all" = select every low-risk item in the category**; medium/high are never +/// pulled in by select-all (UI copy matches this). +struct DiskCleanSelectionModel: Equatable, Sendable { + /// Category-level explicit operation. + /// + /// `selectAllLowRisk` affects **newly arrived** candidates exactly like "never operated" + /// (both fall through to default policy). We still record it to override a prior + /// `deselectAll` and to give "user explicitly acted on this category" a name in the model. + enum CategoryOperation: Equatable, Sendable { + case selectAllLowRisk + case deselectAll + } + + private struct CandidateOverride: Equatable, Sendable { + let category: DiskCleanCategoryID + let isSelected: Bool + } + + private var candidateOverrides: [DiskCleanCandidate.ID: CandidateOverride] = [:] + private var categoryOperations: [DiskCleanCategoryID: CategoryOperation] = [:] + + init() {} + + // MARK: - Selectability + + /// Whether the candidate is selectable. + /// + /// Same decision as `DiskCleanCandidate.isCleanable` — do not duplicate the conditions: + /// the six unselectable cases in design §8.1 (locked / whitelisted / protected / + /// non-complete / unsized / mount-containing) all live in `isCleanable`; two copies would drift. + static func isSelectable(_ candidate: DiskCleanCandidate) -> Bool { + candidate.isCleanable + } + + /// Default policy: low risk and selectable. Dynamic-rule products always have target risk >= medium, so they land on the unselected side automatically. + static func isSelectedByDefault(_ candidate: DiskCleanCandidate) -> Bool { + isSelectable(candidate) && candidate.risk == .low + } + + func isSelected(_ candidate: DiskCleanCandidate) -> Bool { + guard Self.isSelectable(candidate) else { return false } + if let override = candidateOverrides[candidate.id] { + return override.isSelected + } + if categoryOperations[candidate.category] == .deselectAll { + return false + } + return candidate.risk == .low + } + + // MARK: - Commands + + /// Select/deselect one candidate. Returns whether it was accepted — unselectable candidates are always rejected with no override record. + @discardableResult + mutating func setCandidate(_ candidate: DiskCleanCandidate, isSelected: Bool) -> Bool { + guard Self.isSelectable(candidate) else { return false } + candidateOverrides[candidate.id] = CandidateOverride( + category: candidate.category, + isSelected: isSelected + ) + return true + } + + /// Category-level select all (low risk only) / deselect all. + /// + /// Clears prior per-item overrides for the category: a category operation is coarser, and pressing it means reset this category's state. + mutating func setCategory(_ category: DiskCleanCategoryID, isSelected: Bool) { + categoryOperations[category] = isSelected ? .selectAllLowRisk : .deselectAll + candidateOverrides = candidateOverrides.filter { $0.value.category != category } + } + + /// Return to "user has done nothing". Must be called for each new scan: candidate IDs are + /// `targetID::path` and stable across scans, so without a clear prior selections would silently carry over. + mutating func reset() { + candidateOverrides = [:] + categoryOperations = [:] + } + + // MARK: - Derived state + + func explicitOperation(for category: DiskCleanCategoryID) -> CategoryOperation? { + categoryOperations[category] + } + + func selectedIDs(in candidates: [DiskCleanCandidate]) -> Set { + Set(candidates.filter(isSelected).map(\.id)) + } + + /// One pass computing every selection fact the snapshot needs. + func projection(for candidates: [DiskCleanCandidate]) -> DiskCleanSelectionProjection { + var selectedIDs: Set = [] + var selectableIDs: Set = [] + var selectedBytes: Int64 = 0 + var selectableCountByCategory: [DiskCleanCategoryID: Int] = [:] + var selectedCountByCategory: [DiskCleanCategoryID: Int] = [:] + var categoryStates: [DiskCleanCategoryID: DiskCleanCategorySelectionState] = [:] + + for candidate in candidates { + // Record category presence first: a category of only unselectable items still needs + // a state (`unavailable`), or the view cannot tell "nothing scanned" from "everything protected". + categoryStates[candidate.category] = .unavailable + + guard Self.isSelectable(candidate) else { continue } + selectableIDs.insert(candidate.id) + selectableCountByCategory[candidate.category, default: 0] += 1 + + guard isSelected(candidate) else { continue } + selectedIDs.insert(candidate.id) + selectedBytes += max(candidate.estimatedBytes, 0) + selectedCountByCategory[candidate.category, default: 0] += 1 + } + + for (category, selectableCount) in selectableCountByCategory where selectableCount > 0 { + let selectedCount = selectedCountByCategory[category] ?? 0 + switch selectedCount { + case 0: + categoryStates[category] = .noneSelected + case selectableCount: + categoryStates[category] = .allSelected + default: + categoryStates[category] = .partiallySelected + } + } + + return DiskCleanSelectionProjection( + selectedIDs: selectedIDs, + selectableIDs: selectableIDs, + selectedEstimatedBytes: selectedBytes, + categoryStates: categoryStates + ) + } +} diff --git a/Plugins/DiskClean/Sources/DiskCleanSizeCache.swift b/Plugins/DiskClean/Sources/DiskCleanSizeCache.swift new file mode 100644 index 00000000..8d727577 --- /dev/null +++ b/Plugins/DiskClean/Sources/DiskCleanSizeCache.swift @@ -0,0 +1,197 @@ +import Darwin +import Foundation +import os + +/// Root identity probe: no traversal, one identity sample for cache hit checks. +/// +/// Constraints match `DiskCleanRootOpener` exactly (design §3.2, §13-3): open the parent +/// with `O_NOFOLLOW_ANY` first (any intermediate symlink fails here), then +/// `fstatat(AT_SYMLINK_NOFOLLOW)` for the leaf identity. Plain `lstat` is wrong — it +/// follows intermediate symlinks and would miss a "middle directory replaced by symlink" attack. +protocol DiskCleanRootIdentityProbing: Sendable { + func identity(ofItemAt path: String) -> DiskCleanRootIdentity? +} + +struct DiskCleanRootIdentityProbe: DiskCleanRootIdentityProbing { + init() {} + + func identity(ofItemAt path: String) -> DiskCleanRootIdentity? { + guard let location = ParentAnchoredPath(path: path) else { return nil } + + let parentDescriptor = Darwin.open( + location.parentPath, + O_RDONLY | O_DIRECTORY | O_NOFOLLOW_ANY | O_NONBLOCK + ) + guard parentDescriptor >= 0 else { return nil } + defer { close(parentDescriptor) } + + var status = stat() + guard fstatat(parentDescriptor, location.name, &status, AT_SYMLINK_NOFOLLOW) == 0 else { + return nil + } + return DiskCleanRootIdentity(stat: status) + } +} + +/// Size cache (design §4.3). +/// +/// - Key = path; **hit requires full root-identity triple equality `(devid, fileID, mtime)`**. +/// mtime alone is not enough: a directory replaced wholesale (delete/recreate or swap) +/// can keep mtime while fileID always changes. +/// - **Cache only complete results**: reusing partial would permanently bake in a degradation. +/// - TTL is 240s, strictly below the 300s stale gate — otherwise "stale → rescan → hit old +/// cache → still stale" loops. `forceRefresh` bypassing the cache is the other half of that +/// insurance (§4.3). +/// - Always propagate the cache entry's original `observedAt`; never refresh it to read time, +/// or the stale gate is bypassed. +/// +/// Use a lock, not an actor: hit checks run on `DiskCleanWorkerPool` resident threads (same +/// call as the blocking sizer) where await is not allowed. Only dictionary work runs under +/// the lock; identity probing happens outside it. +final class DiskCleanSizeCache: Sendable { + static let timeToLive: TimeInterval = 240 + static let defaultCapacity = 500 + + private struct Entry { + let identity: DiskCleanRootIdentity + let result: DiskCleanSizeResult + /// Write time. TTL is computed from write time; usually matches `result.observedAt` but is a separate concern. + let storedAt: Date + } + + private struct State { + var entries: [String: Entry] = [:] + /// Write order for capacity eviction. Rewrites move the path to the tail. + var insertionOrder: [String] = [] + } + + private let capacity: Int + private let timeToLive: TimeInterval + private let state = OSAllocatedUnfairLock(initialState: State()) + + init( + capacity: Int = DiskCleanSizeCache.defaultCapacity, + timeToLive: TimeInterval = DiskCleanSizeCache.timeToLive + ) { + self.capacity = max(capacity, 1) + self.timeToLive = max(timeToLive, 0) + } + + /// On hit return the cached result (preserving original observedAt); on miss return nil and drop any stale entry. + func result( + forPath path: String, + identity: DiskCleanRootIdentity, + now: Date + ) -> DiskCleanSizeResult? { + state.withLock { state -> DiskCleanSizeResult? in + guard let entry = state.entries[path] else { return nil } + guard now.timeIntervalSince(entry.storedAt) < timeToLive else { + Self.remove(path: path, from: &state) + return nil + } + guard Self.matches(entry.identity, identity) else { + Self.remove(path: path, from: &state) + return nil + } + return entry.result + } + } + + func store(path: String, result: DiskCleanSizeResult, now: Date) { + guard result.completeness.isComplete, let identity = result.rootIdentity else { return } + + state.withLock { state in + if state.entries[path] == nil { + state.insertionOrder.append(path) + } else { + state.insertionOrder.removeAll { $0 == path } + state.insertionOrder.append(path) + } + state.entries[path] = Entry(identity: identity, result: result, storedAt: now) + + while state.insertionOrder.count > capacity { + let oldest = state.insertionOrder.removeFirst() + state.entries[oldest] = nil + } + } + } + + var count: Int { + state.withLock { $0.entries.count } + } + + func removeAll() { + state.withLock { state in + state.entries.removeAll() + state.insertionOrder.removeAll() + } + } + + /// Full identity-triple equality. fileType must match too — a directory cannot become + /// another type at the same inode, but comparing it is a free extra check. + private static func matches(_ cached: DiskCleanRootIdentity, _ current: DiskCleanRootIdentity) -> Bool { + cached.devid == current.devid + && cached.fileID == current.fileID + && cached.mtime == current.mtime + && cached.fileType == current.fileType + } + + private static func remove(path: String, from state: inout State) { + state.entries[path] = nil + state.insertionOrder.removeAll { $0 == path } + } +} + +/// Cache decorator around any sizer. +/// +/// Order matters: **probe identity before consulting the cache**. The reverse +/// (load a cache entry then validate identity) can return a stale result after path +/// replacement. When identity probing fails (path gone, intermediate symlink), fall +/// through to the real sizer so it can report the precise degradation reason. +struct DiskCleanCachingSizer: DiskCleanDirectorySizing { + let base: any DiskCleanDirectorySizing + /// Used when `base` (normally FastWalker) returns a walk-level failure that SlowWalker can still complete. + let fallback: (any DiskCleanDirectorySizing)? + let cache: DiskCleanSizeCache + let identityProbe: any DiskCleanRootIdentityProbing + /// true = bypass cache reads (still write). Rescans after the stale gate must take this path. + let forceRefresh: Bool + + init( + base: any DiskCleanDirectorySizing, + fallback: (any DiskCleanDirectorySizing)? = DiskCleanSlowWalker(), + cache: DiskCleanSizeCache, + identityProbe: any DiskCleanRootIdentityProbing = DiskCleanRootIdentityProbe(), + forceRefresh: Bool = false + ) { + self.base = base + self.fallback = fallback + self.cache = cache + self.identityProbe = identityProbe + self.forceRefresh = forceRefresh + } + + func size(ofItemAt path: String, context: DiskCleanSizingContext) -> DiskCleanSizeResult { + if !forceRefresh, + let identity = identityProbe.identity(ofItemAt: path), + let cached = cache.result(forPath: path, identity: identity, now: context.now()) { + return cached + } + + let result = base.size(ofItemAt: path, context: context) + if shouldFallback(from: result), let fallback { + let fallbackResult = fallback.size(ofItemAt: path, context: context) + cache.store(path: path, result: fallbackResult, now: context.now()) + return fallbackResult + } + cache.store(path: path, result: result, now: context.now()) + return result + } + + /// FastWalker failures that mean "bulk attributes unusable", not "path really unreadable". + /// Permission / timeout / mount-crossing already carry honest completeness and must not be retried. + private func shouldFallback(from result: DiskCleanSizeResult) -> Bool { + // Only pure walkError (getattrlistbulk/layout) is worth a SlowWalker retry. + result.completeness.partialReasons == [.walkError] + } +} diff --git a/Plugins/DiskClean/Sources/DiskCleanSizingModels.swift b/Plugins/DiskClean/Sources/DiskCleanSizingModels.swift new file mode 100644 index 00000000..8cd1c14c --- /dev/null +++ b/Plugins/DiskClean/Sources/DiskCleanSizingModels.swift @@ -0,0 +1,187 @@ +import Darwin +import Foundation + +/// Completeness of a sizing result. +/// +/// Core invariant (design §3.1): candidates with `completeness != .complete`, or with +/// `crossedMountPoint` in the reason set, are **not cleanable**. The selection model, +/// Planner, and executor each recheck; this type only records degradation reasons honestly. +enum DiskCleanScanCompleteness: Equatable, Sendable { + case complete + case partial(reasons: Set) + + enum PartialReason: Equatable, Sendable, Hashable { + /// Cooperative deadline expired, or the caller cancelled. + case timedOut + /// EPERM/EACCES subtree was skipped. + case permissionDenied + /// Non-local volume, blacklisted volume, or sizing not run after circuit break. + case unsupportedVolume + /// Mount point found in the subtree; did not descend. + case crossedMountPoint + /// Attribute anomaly and per-entry fstatat fallback failed. + case walkError + } + + var isComplete: Bool { + self == .complete + } + + var partialReasons: Set { + guard case let .partial(reasons) = self else { return [] } + return reasons + } +} + +/// Root object identity. Used for size-cache hit checks and pre-execution fd-anchored revalidation. +struct DiskCleanRootIdentity: Equatable, Sendable { + /// File type. Design §3.1 lists directory/regularFile/symlink; `other` covers sockets, + /// FIFOs, and device nodes that really appear in cache directories — they must be + /// classified honestly rather than forced into regular-file shape. + enum FileType: Equatable, Sendable { + case directory + case regularFile + case symlink + case other + } + + /// `st_dev`. Mount guards and the device blacklist both use this value. + let devid: UInt64 + /// `st_ino`。 + let fileID: UInt64 + let mtime: Date + let fileType: FileType + + /// Build identity from `stat`. + init(stat value: stat) { + self.devid = UInt64(UInt32(bitPattern: value.st_dev)) + self.fileID = value.st_ino + self.mtime = Self.date(from: value.st_mtimespec) + self.fileType = FileType(mode: value.st_mode) + } + + init(devid: UInt64, fileID: UInt64, mtime: Date, fileType: FileType) { + self.devid = devid + self.fileID = fileID + self.mtime = mtime + self.fileType = fileType + } + + /// Time conversion lives in one place: cache hits require exact mtime equality, so both sides must use the same conversion. + static func date(from time: timespec) -> Date { + Date(timeIntervalSince1970: Double(time.tv_sec) + Double(time.tv_nsec) / 1_000_000_000) + } +} + +extension DiskCleanRootIdentity.FileType { + init(mode: mode_t) { + switch mode & S_IFMT { + case S_IFDIR: self = .directory + case S_IFREG: self = .regularFile + case S_IFLNK: self = .symlink + default: self = .other + } + } +} + +struct DiskCleanSizeResult: Equatable, Sendable { + /// Estimated logical size (`st_size` sum; hard links deduped by `(devid, fileID)`). + /// + /// Not the same as reclaimable space: APFS clones, sparse files, and hard links outside + /// the tree make them differ. UI always says "about X GB"; completion copy must not say "freed". + let estimatedBytes: Int64 + let fileCount: Int + let completeness: DiskCleanScanCompleteness + /// `fstat` of the root fd. nil only when the root cannot be opened at all (or is blocked by + /// circuit break / blacklist); then `completeness` is never `.complete` and the candidate is not cleanable. + let rootIdentity: DiskCleanRootIdentity? + /// True observation time. On cache hits, propagate the entry's observedAt; never refresh it to read time. + let observedAt: Date + + init( + estimatedBytes: Int64, + fileCount: Int, + completeness: DiskCleanScanCompleteness, + rootIdentity: DiskCleanRootIdentity?, + observedAt: Date + ) { + self.estimatedBytes = estimatedBytes + self.fileCount = fileCount + self.completeness = completeness + self.rootIdentity = rootIdentity + self.observedAt = observedAt + } + + /// Result when no size information could be obtained. + static func unavailable( + reasons: Set, + rootIdentity: DiskCleanRootIdentity? = nil, + observedAt: Date + ) -> DiskCleanSizeResult { + DiskCleanSizeResult( + estimatedBytes: 0, + fileCount: 0, + completeness: .partial(reasons: reasons), + rootIdentity: rootIdentity, + observedAt: observedAt + ) + } +} + +/// Collect degradation reasons along the way so every branch does not rebuild the Set. +struct DiskCleanCompletenessAccumulator { + private var reasons: Set = [] + + mutating func add(_ reason: DiskCleanScanCompleteness.PartialReason) { + reasons.insert(reason) + } + + /// Map errno to a degradation reason: permission cases are separate; everything else is walkError. + mutating func add(errno code: Int32) { + add(code == EPERM || code == EACCES ? .permissionDenied : .walkError) + } + + var completeness: DiskCleanScanCompleteness { + reasons.isEmpty ? .complete : .partial(reasons: reasons) + } +} + +/// Runtime context for a blocking sizer. +/// +/// Cancel and timeout are one exit for the walker: both make traversal return ASAP and +/// mark the result `.timedOut` (callers discard the result on cancel). +struct DiskCleanSizingContext: Sendable { + let deadline: Date + let isCancelled: @Sendable () -> Bool + /// Called **immediately** after the sizer opens the root fd to report the root's device id. + /// + /// false means the device is on the circuit-break blacklist; the sizer must abandon at once + /// and return `partial([.unsupportedVolume])`. The report itself lets WorkerPool know which + /// device to blacklist when it abandons a timed-out thread. + let admitDevice: @Sendable (UInt64) -> Bool + /// Injectable clock for testing deadline checks. + let now: @Sendable () -> Date + + init( + deadline: Date, + isCancelled: @escaping @Sendable () -> Bool = { false }, + admitDevice: @escaping @Sendable (UInt64) -> Bool = { _ in true }, + now: @escaping @Sendable () -> Date = { Date() } + ) { + self.deadline = deadline + self.isCancelled = isCancelled + self.admitDevice = admitDevice + self.now = now + } + + /// Whether traversal should stop (cancelled or deadline passed). + var shouldStop: Bool { + isCancelled() || now() >= deadline + } +} + +/// Sizing capability. **Blocking**: implementations may stall in syscalls and must be +/// invoked on resident `DiskCleanWorkerPool` threads, never directly on the Swift concurrency pool. +protocol DiskCleanDirectorySizing: Sendable { + func size(ofItemAt path: String, context: DiskCleanSizingContext) -> DiskCleanSizeResult +} diff --git a/Plugins/DiskClean/Sources/DiskCleanSlowWalker.swift b/Plugins/DiskClean/Sources/DiskCleanSlowWalker.swift new file mode 100644 index 00000000..22dac2a5 --- /dev/null +++ b/Plugins/DiskClean/Sources/DiskCleanSlowWalker.swift @@ -0,0 +1,124 @@ +import Darwin +import Foundation + +/// Fallback sizer: `fdopendir` / `readdir` + per-entry `fstatat(AT_SYMLINK_NOFOLLOW)` (design §3.5). +/// +/// Used when `getattrlistbulk` fails outright on some volumes (certain FUSE mounts). +/// **Does not use `FileManager`**: that would drop no-follow and device constraints and +/// could still report complete across mount points. Shares FastWalker's traversal skeleton, +/// so mount guards, hard-link dedup, EPERM skips, deadline/cancel, and root-identity +/// semantics stay identical; also runs inside WorkerPool under the same abandon budget. +struct DiskCleanSlowWalker: DiskCleanDirectorySizing { + private let core: DiskCleanDirectoryTreeWalker + + init( + opener: DiskCleanRootOpener = DiskCleanRootOpener(), + batchSize: Int = 128 + ) { + self.core = DiskCleanDirectoryTreeWalker( + opener: opener, + sourceFactory: DiskCleanDirectoryStreamEntrySourceFactory(batchSize: batchSize) + ) + } + + func size(ofItemAt path: String, context: DiskCleanSizingContext) -> DiskCleanSizeResult { + core.size(ofItemAt: path, context: context) + } +} + +struct DiskCleanDirectoryStreamEntrySourceFactory: DiskCleanDirectoryEntrySourceFactory { + /// Batching only so the caller can check cancel and deadline per batch; readdir itself is per-entry. + let batchSize: Int + + init(batchSize: Int = 128) { + self.batchSize = max(batchSize, 1) + } + + func makeSource(fileDescriptor: Int32) throws -> any DiskCleanDirectoryEntrySource { + // On fdopendir failure we do not consume the fd; the caller closes it per the protocol. + guard let stream = fdopendir(fileDescriptor) else { + throw DiskCleanPOSIXError(code: errno) + } + return DiskCleanDirectoryStreamEntrySource(stream: stream, batchSize: batchSize) + } +} + +final class DiskCleanDirectoryStreamEntrySource: DiskCleanDirectoryEntrySource { + private let stream: UnsafeMutablePointer + private let batchSize: Int + private var isFinished = false + private var isClosed = false + + init(stream: UnsafeMutablePointer, batchSize: Int) { + self.stream = stream + self.batchSize = batchSize + } + + deinit { + if !isClosed { + closedir(stream) + } + } + + /// `closedir` also closes the underlying fd, so `close()` must not close it again. + var directoryFileDescriptor: Int32 { + dirfd(stream) + } + + func nextBatch() throws -> [DiskCleanWalkEntry]? { + guard !isFinished, !isClosed else { return nil } + + var entries: [DiskCleanWalkEntry] = [] + entries.reserveCapacity(batchSize) + + while entries.count < batchSize { + errno = 0 + guard let directoryEntry = readdir(stream) else { + let code = errno + if code != 0 { + throw DiskCleanPOSIXError(code: code) + } + isFinished = true + break + } + + guard let nameBytes = Self.nameBytes(of: directoryEntry), !Self.isDotEntry(nameBytes) else { + continue + } + entries.append( + DiskCleanEntryStatFallback.resolve( + nameBytes: nameBytes, + directoryFileDescriptor: directoryFileDescriptor + ) + ) + } + + return entries.isEmpty ? nil : entries + } + + func close() { + guard !isClosed else { return } + isClosed = true + closedir(stream) + } + + private static func nameBytes(of entry: UnsafeMutablePointer) -> [CChar]? { + let length = Int(entry.pointee.d_namlen) + guard length > 0 else { return nil } + return withUnsafePointer(to: entry.pointee.d_name) { tuplePointer in + let characters = UnsafeRawPointer(tuplePointer).assumingMemoryBound(to: CChar.self) + var bytes = Array(UnsafeBufferPointer(start: characters, count: length)) + bytes.append(0) + return bytes + } + } + + private static func isDotEntry(_ nameBytes: [CChar]) -> Bool { + let dot = CChar(UInt8(ascii: ".")) + switch nameBytes.count { + case 2: return nameBytes[0] == dot + case 3: return nameBytes[0] == dot && nameBytes[1] == dot + default: return false + } + } +} diff --git a/Plugins/DiskClean/Sources/DiskCleanStagingJournal.swift b/Plugins/DiskClean/Sources/DiskCleanStagingJournal.swift new file mode 100644 index 00000000..9ac818fd --- /dev/null +++ b/Plugins/DiskClean/Sources/DiskCleanStagingJournal.swift @@ -0,0 +1,195 @@ +import Darwin +import Foundation + +/// Staging transaction journal (design §7.6). +/// +/// **Write-order invariant: rename is allowed only after `begin` is durable and fsynced.** +/// If the process crashes after rename but before the completion record, startup +/// reconciliation uses the journal to recover orphan staged objects. Reversing the +/// order yields "unrecorded `.mactools-staged-*`" — SafetyPolicy would still protect +/// it from the scan pipeline, but nobody knows the original name, so user data is +/// effectively lost. +final class DiskCleanStagingJournal: @unchecked Sendable { + struct Entry: Codable, Equatable, Sendable { + let id: String + let timestamp: Date + /// Physical path of the parent directory. + let parentPath: String + let originalName: String + let stagedName: String + let mode: DiskCleanRemovalMode.RawValue + } + + /// Journal line. Begin and completion share one file and are distinguished by `kind`. + private struct Line: Codable { + enum Kind: String, Codable { + case begin + case end + } + + let kind: Kind + let entry: Entry? + let entryID: String? + let status: String? + let timestamp: Date + } + + static let fileName = "staging-journal.jsonl" + + let directory: URL + private let fileURL: URL + private let lock = NSLock() + private let encoder: JSONEncoder + private let decoder: JSONDecoder + /// Entry IDs currently owned by a live cleanup transaction in this process. + /// Startup reconciliation must not compact these away between begin and complete. + private var activeEntryIDs: Set = [] + + /// **Do not touch the filesystem in init**: a default-constructed plugin (including + /// in tests) would create `~/Library/Application Support/...`, a real user directory. + /// Create the directory on first write instead. + init(directory: URL) { + self.directory = directory + self.fileURL = directory.appendingPathComponent(Self.fileName) + self.encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + self.decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + } + + /// Record staging begin. **Must be called and succeed before rename**; fsync after write. + /// Marks the entry active so concurrent reconciliation will not compact it away. + func begin(_ entry: Entry) throws { + lock.lock() + activeEntryIDs.insert(entry.id) + lock.unlock() + do { + try append( + Line(kind: .begin, entry: entry, entryID: nil, status: nil, timestamp: entry.timestamp), + synchronize: true + ) + } catch { + lock.lock() + activeEntryIDs.remove(entry.id) + lock.unlock() + throw error + } + } + + /// Record staging disposition complete (deleted / rolled back / partiallyDeleted, etc.). + func complete(entryID: String, status: String, at timestamp: Date = Date()) { + try? append( + Line(kind: .end, entry: nil, entryID: entryID, status: status, timestamp: timestamp), + synchronize: true + ) + releaseActive(entryID: entryID) + } + + /// Drop the in-process active mark without writing a completion record. + /// + /// Used when the transaction ends but the journal entry must stay incomplete + /// (`rollbackBlocked`): the current process no longer owns the rename, so + /// reconciliation (same process or next launch) must be allowed to see it. + func releaseActive(entryID: String) { + lock.lock() + activeEntryIDs.remove(entryID) + lock.unlock() + } + + /// Unfinished entries: have begin, no end. Includes live in-process transactions. + func incompleteEntries() -> [Entry] { + lock.lock() + defer { lock.unlock() } + return incompleteEntriesLocked() + } + + /// Unfinished entries that are safe for startup reconciliation to touch. + /// Excludes IDs owned by a live cleanup transaction so compact cannot erase an in-flight begin. + func incompleteEntriesForReconciliation() -> [Entry] { + lock.lock() + defer { lock.unlock() } + return incompleteEntriesLocked().filter { !activeEntryIDs.contains($0.id) } + } + + /// Precondition: `lock` is already held. + private func incompleteEntriesLocked() -> [Entry] { + guard let data = try? Data(contentsOf: fileURL) else { return [] } + var beginsByID: [String: Entry] = [:] + var completedIDs: Set = [] + + for lineData in data.split(separator: UInt8(ascii: "\n")) { + guard let line = try? decoder.decode(Line.self, from: Data(lineData)) else { + // Partial line (crash mid-write) or corrupt line: skip. A corrupt begin means + // rename has not happened yet (write-order invariant), so we do not miss orphans. + continue + } + switch line.kind { + case .begin: + if let entry = line.entry { + beginsByID[entry.id] = entry + } + case .end: + if let entryID = line.entryID { + completedIDs.insert(entryID) + } + } + } + + return beginsByID + .filter { !completedIDs.contains($0.key) } + .values + .sorted { $0.timestamp < $1.timestamp } + } + + /// Compact: keep unfinished entries (including live in-process transactions). + /// Called at reconciliation wrap-up to stop unbounded journal growth. + func compact() { + lock.lock() + defer { lock.unlock() } + let remaining = incompleteEntriesLocked() + + var data = Data() + for entry in remaining { + let line = Line(kind: .begin, entry: entry, entryID: nil, status: nil, timestamp: entry.timestamp) + guard let encoded = try? encoder.encode(line) else { continue } + data.append(encoded) + data.append(UInt8(ascii: "\n")) + } + try? data.write(to: fileURL, options: .atomic) + } + + private func append(_ line: Line, synchronize: Bool) throws { + lock.lock() + defer { lock.unlock() } + + let data = try encoder.encode(line) + Data([UInt8(ascii: "\n")]) + var didCreateFile = false + if !FileManager.default.fileExists(atPath: fileURL.path) { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try Data().write(to: fileURL) + didCreateFile = true + } + let handle = try FileHandle(forWritingTo: fileURL) + defer { try? handle.close() } + try handle.seekToEnd() + try handle.write(contentsOf: data) + if synchronize { + // fsync semantics: begin must be durable before rename (§7.6 invariant). + try handle.synchronize() + if didCreateFile { + // File contents on disk do not imply the **directory entry** is durable. + // When the first begin also creates the journal, skipping this step can leave + // "staged object present, journal file entirely missing" after a crash. + synchronizeDirectory() + } + } + } + + private func synchronizeDirectory() { + let descriptor = open(directory.path, O_RDONLY | O_DIRECTORY) + guard descriptor >= 0 else { return } + defer { close(descriptor) } + fsync(descriptor) + } +} diff --git a/Plugins/DiskClean/Sources/DiskCleanStagingReconciler.swift b/Plugins/DiskClean/Sources/DiskCleanStagingReconciler.swift new file mode 100644 index 00000000..efc0ae0a --- /dev/null +++ b/Plugins/DiskClean/Sources/DiskCleanStagingReconciler.swift @@ -0,0 +1,178 @@ +import Darwin +import Foundation + +/// Plugin on-disk state directory (journal + audit log). +/// +/// Provided by the host via `PluginRuntimeContext.supportDirectory`; may be nil under +/// static loading and similar cases, in which we fall back to the same host-agreed location. +/// That path hits the `DiskCleanSafetyPolicy` "cleanup tool state" protection branch, so +/// the cleaner never deletes its own state as cache. +enum DiskCleanStorageLocation { + static func resolve(supportDirectory: URL?) -> URL { + supportDirectory ?? fallbackDirectory + } + + static var fallbackDirectory: URL { + let applicationSupport = FileManager.default + .urls(for: .applicationSupportDirectory, in: .userDomainMask) + .first + ?? URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent("Library/Application Support") + return applicationSupport.appendingPathComponent("MacTools/DiskClean", isDirectory: true) + } +} + +/// Outcome of reconciling one unfinished journal entry. +enum DiskCleanReconcileOutcome: Equatable, Sendable { + /// Staged object was renamed back to the original path. + case rolledBack(originalPath: String) + /// Staged object is already gone (previous disposal actually succeeded; only the completion record was not fsynced). Entry is closed out. + case absent(stagedName: String) + /// Original path was recreated; never overwrite. Keep the staged object and leave the entry **unfinished** so the next launch can still surface it. + case blocked(stagedName: String, originalPath: String) + /// Cannot decide (parent directory unreadable, etc.). Leave unfinished for the next launch retry. + case failed(stagedName: String, reason: String) +} + +protocol DiskCleanStagingReconciling: Sendable { + func reconcile(storageDirectory: URL) async +} + +/// Startup reconciliation (design §7.6). +/// +/// The crash matrix has only two outcomes: journal written but rename never happened +/// (no staged object — close the entry), or rename happened but the completion record +/// never landed (orphan staged object — rename back). The write-order invariant +/// (begin + fsync → rename) rules out a third case of "staged object with no record". +struct DiskCleanStagingReconciler: DiskCleanStagingReconciling { + private let now: @Sendable () -> Date + + init(now: @escaping @Sendable () -> Date = Date.init) { + self.now = now + } + + func reconcile(storageDirectory: URL) async { + // Prefer callers that already hold the shared journal; this path is for tests / recovery + // tools that only have a directory. + _ = reconcile( + journal: DiskCleanStagingJournal(directory: storageDirectory), + auditLog: DiskCleanAuditLog(directory: storageDirectory) + ) + } + + @discardableResult + func reconcile( + journal: DiskCleanStagingJournal, + auditLog: DiskCleanAuditLog + ) -> [DiskCleanReconcileOutcome] { + // Skip entries owned by a live cleanup in this process so compact cannot erase + // a begin that has not yet renamed. + let entries = journal.incompleteEntriesForReconciliation() + guard !entries.isEmpty else { return [] } + + var outcomes: [DiskCleanReconcileOutcome] = [] + for entry in entries { + let outcome = reconcile(entry, journal: journal) + outcomes.append(outcome) + auditLog.append(record(for: outcome, entry: entry)) + } + journal.compact() + return outcomes + } + + private func reconcile( + _ entry: DiskCleanStagingJournal.Entry, + journal: DiskCleanStagingJournal + ) -> DiskCleanReconcileOutcome { + let originalPath = DiskCleanRemovalPrimitive.join(entry.parentPath, entry.originalName) + + let parentDescriptor = Darwin.open( + entry.parentPath, + O_RDONLY | O_DIRECTORY | O_NOFOLLOW_ANY | O_NONBLOCK + ) + guard parentDescriptor >= 0 else { + let code = errno + guard code == ENOENT else { + return .failed(stagedName: entry.stagedName, reason: message(code)) + } + // Parent directory is gone entirely → staged object is gone too; nothing to recover. + journal.complete(entryID: entry.id, status: "reconciledParentMissing", at: now()) + return .absent(stagedName: entry.stagedName) + } + defer { close(parentDescriptor) } + + var status = stat() + guard fstatat(parentDescriptor, entry.stagedName, &status, AT_SYMLINK_NOFOLLOW) == 0 else { + let code = errno + guard code == ENOENT else { + return .failed(stagedName: entry.stagedName, reason: message(code)) + } + journal.complete(entryID: entry.id, status: "reconciledAbsent", at: now()) + return .absent(stagedName: entry.stagedName) + } + + // Same invariant as execution-time rollback: RENAME_EXCL; never overwrite a recreated original path. + guard renameatx_np( + parentDescriptor, + entry.stagedName, + parentDescriptor, + entry.originalName, + UInt32(RENAME_EXCL) + ) == 0 else { + let code = errno + guard code == EEXIST else { + return .failed(stagedName: entry.stagedName, reason: message(code)) + } + return .blocked(stagedName: entry.stagedName, originalPath: originalPath) + } + + journal.complete(entryID: entry.id, status: "reconciledRolledBack", at: now()) + return .rolledBack(originalPath: originalPath) + } + + private func record( + for outcome: DiskCleanReconcileOutcome, + entry: DiskCleanStagingJournal.Entry + ) -> DiskCleanAuditLog.Record { + let originalPath = DiskCleanRemovalPrimitive.join(entry.parentPath, entry.originalName) + switch outcome { + case .rolledBack: + return DiskCleanAuditLog.Record( + timestamp: now(), + action: .scanEvent, + path: originalPath, + stagedName: entry.stagedName, + status: "reconciledRolledBack" + ) + case .absent: + return DiskCleanAuditLog.Record( + timestamp: now(), + action: .scanEvent, + path: originalPath, + stagedName: entry.stagedName, + status: "reconciledAbsent" + ) + case .blocked: + return DiskCleanAuditLog.Record( + timestamp: now(), + action: .scanEvent, + path: originalPath, + stagedName: entry.stagedName, + status: "rollbackBlocked", + error: "原路径已被重建,暂存对象保留" + ) + case let .failed(_, reason): + return DiskCleanAuditLog.Record( + timestamp: now(), + action: .scanEvent, + path: originalPath, + stagedName: entry.stagedName, + status: "reconcileFailed", + error: reason + ) + } + } + + private func message(_ code: Int32) -> String { + String(cString: strerror(code)) + } +} diff --git a/Plugins/DiskClean/Sources/DiskCleanWorkerPool.swift b/Plugins/DiskClean/Sources/DiskCleanWorkerPool.swift new file mode 100644 index 00000000..6ce6a272 --- /dev/null +++ b/Plugins/DiskClean/Sources/DiskCleanWorkerPool.swift @@ -0,0 +1,394 @@ +import Darwin +import Foundation +import os + +/// Exactly-once resume gate (design §3.4). +/// +/// Timeout and worker completion **race both ways**: whoever arrives first resumes the +/// continuation; the loser only learns it lost — no resume, no touch of the other side's +/// memory (the worker keeps its own result and drops it on loss). This is the only safe way +/// to join the hard constraints "blocking syscalls cannot be cancelled" and +/// "a continuation may be resumed only once". +final class DiskCleanResumeGate: Sendable { + private let isResolvedLock = OSAllocatedUnfairLock(initialState: false) + private let resumeHandler: @Sendable (Value) -> Void + + init(resume: @escaping @Sendable (Value) -> Void) { + self.resumeHandler = resume + } + + /// Claim resume rights. true = this call won the race and **must** call `deliver` exactly once afterward. + /// + /// Separated from `resolve` so the winner can finish its own bookkeeping **before** resume + /// (the timeout side must record abandon budget and blacklist the device first); otherwise + /// the waiter could observe the result before the state update. + func claim() -> Bool { + isResolvedLock.withLock { isResolved -> Bool in + if isResolved { return false } + isResolved = true + return true + } + } + + /// Deliver the value and resume. May be called only once by the side whose `claim()` returned true. + func deliver(_ value: Value) { + resumeHandler(value) + } + + /// true = this call won and already resumed; false = the other side won first and the caller must drop `value`. + @discardableResult + func resolve(with value: Value) -> Bool { + guard claim() else { return false } + deliver(value) + return true + } + + var isResolved: Bool { + isResolvedLock.withLock { $0 } + } +} + +/// One blocking sizing job. +typealias DiskCleanSizingJob = @Sendable (DiskCleanSizingContext) -> DiskCleanSizeResult + +/// Resident thread pool (design §3.4). +/// +/// Why not GCD / Swift concurrency: task cancellation cannot interrupt blocking syscalls, +/// and GCD concurrent queues have no "abandon this thread and replace it" API. On a pathological +/// filesystem `getattrlistbulk` may never return, so we must abandon that thread together with +/// the stuck call — hence a self-owned `Thread` pool. +/// +/// Abandon budget and circuit break are **process-wide**: the `shared` singleton accumulates +/// abandons across scans and fail-closes when exhausted — this process runs no more sizing +/// (Fast/Slow both stop), and later candidates become `partial([.unsupportedVolume])` and thus +/// uncleanable. No auto-recovery: retrying a pathological FS only burns more budget; nor do we +/// switch to the fallback sizer — it can block too and would break the leak ceiling. +final class DiskCleanWorkerPool: @unchecked Sendable { + static let shared = DiskCleanWorkerPool() + + private let maxThreadCount: Int + private let abandonBudget: Int + private let timeoutQueue: DispatchQueue + + /// Protects all mutable state below and is used by worker threads waiting for work. + private let condition = NSCondition() + private var pendingItems: [WorkItem] = [] + private var liveThreadCount = 0 + private var idleThreadCount = 0 + private var abandonedThreadCount = 0 + private var circuitBroken = false + private var blockedDevices: Set = [] + private var isShutDown = false + + init( + maxThreadCount: Int = 3, + abandonBudget: Int = 3, + timeoutQueue: DispatchQueue = DispatchQueue(label: "com.mactools.diskclean.sizing-timeout") + ) { + self.maxThreadCount = max(maxThreadCount, 1) + self.abandonBudget = max(abandonBudget, 1) + self.timeoutQueue = timeoutQueue + } + + /// Circuit-break state. The scan engine reports a `walkerCircuitBroken` limitation from this. + var isCircuitBroken: Bool { + condition.lock() + defer { condition.unlock() } + return circuitBroken + } + + /// Cumulative abandoned-thread count. The scan engine reports a `threadsAbandoned` limitation from this. + var abandonedThreads: Int { + condition.lock() + defer { condition.unlock() } + return abandonedThreadCount + } + + func size( + ofItemAt path: String, + using sizer: any DiskCleanDirectorySizing, + deadline: Date + ) async -> DiskCleanSizeResult { + await perform(deadline: deadline) { context in + sizer.size(ofItemAt: path, context: context) + } + } + + func perform(deadline: Date, job: @escaping DiskCleanSizingJob) async -> DiskCleanSizeResult { + if isCircuitBroken { + return Self.circuitBrokenResult() + } + + let cancellation = DiskCleanCancellationFlag() + return await withTaskCancellationHandler { + await withCheckedContinuation { (continuation: CheckedContinuation) in + let gate = DiskCleanResumeGate { result in + continuation.resume(returning: result) + } + submit( + WorkItem(gate: gate, job: job, deadline: deadline, cancellation: cancellation) + ) + } + } onCancel: { + cancellation.set() + } + } + + /// Tests only: let resident threads exit so idle threads do not pile up in the test process. + func shutDown() { + condition.lock() + isShutDown = true + let drained = pendingItems + pendingItems.removeAll() + condition.broadcast() + condition.unlock() + for item in drained { + item.cancelTimeout() + item.gate.resolve(with: Self.circuitBrokenResult()) + } + } + + // MARK: - Dispatch + + private func submit(_ item: WorkItem) { + condition.lock() + guard !circuitBroken, !isShutDown else { + condition.unlock() + item.gate.resolve(with: Self.circuitBrokenResult()) + return + } + pendingItems.append(item) + let shouldStartThread = idleThreadCount == 0 && liveThreadCount < maxThreadCount + if shouldStartThread { + liveThreadCount += 1 + } + condition.signal() + condition.unlock() + + if shouldStartThread { + startThread() + } + scheduleTimeout(for: item) + } + + private func scheduleTimeout(for item: WorkItem) { + let work = DispatchWorkItem { [weak self] in + self?.handleTimeout(item) + } + item.attachTimeout(work) + timeoutQueue.asyncAfter( + deadline: .now() + max(0, item.deadline.timeIntervalSinceNow), + execute: work + ) + } + + private func startThread() { + let worker = WorkerThread() + let thread = Thread { [weak self] in + self?.runWorkerLoop(worker) + } + thread.name = "com.mactools.diskclean.sizing" + thread.stackSize = 1 << 20 + thread.start() + } + + // MARK: - Worker threads + + private func runWorkerLoop(_ worker: WorkerThread) { + while true { + condition.lock() + idleThreadCount += 1 + while pendingItems.isEmpty && !isShutDown && !worker.isAbandoned { + condition.wait() + } + idleThreadCount -= 1 + + if isShutDown || worker.isAbandoned { + liveThreadCount -= 1 + condition.unlock() + return + } + guard !pendingItems.isEmpty else { + condition.unlock() + continue + } + let item = pendingItems.removeFirst() + // Circuit break may land between enqueue and pickup: fail closed. + if circuitBroken { + condition.unlock() + item.cancelTimeout() + item.gate.resolve(with: Self.circuitBrokenResult()) + continue + } + item.runningThread = worker + condition.unlock() + + let result = item.job(makeContext(for: item)) + + // Result is self-owned: if the timeout already claimed the gate, resolve returns false and the result is dropped. + item.gate.resolve(with: result) + item.cancelTimeout() + + condition.lock() + item.runningThread = nil + let wasAbandoned = worker.isAbandoned + condition.unlock() + + // An abandoned thread exits after its stuck call finishes and does not take more work (liveThreadCount was already decremented on abandon). + if wasAbandoned { + return + } + } + } + + private func makeContext(for item: WorkItem) -> DiskCleanSizingContext { + DiskCleanSizingContext( + deadline: item.deadline, + isCancelled: { item.cancellation.isSet }, + admitDevice: { [weak self] device in + self?.admit(device: device, for: item) ?? false + }, + now: { Date() } + ) + } + + private func admit(device: UInt64, for item: WorkItem) -> Bool { + condition.lock() + defer { condition.unlock() } + // Report the device id so a timeout abandon can blacklist a pathological device. + item.reportedDevice = device + return !circuitBroken && !blockedDevices.contains(device) + } + + // MARK: - Timeout, abandon, and circuit break + + private func handleTimeout(_ item: WorkItem) { + // Claim resume rights first: only a timeout that truly wins does abandon bookkeeping. + // Bookkeeping must finish before deliver so the waiter never sees the result before circuit-break state. + guard item.gate.claim() else { return } + + condition.lock() + + // Job still queued and not yet running → no stuck thread; dequeue only, no budget cost. + if let index = pendingItems.firstIndex(where: { $0 === item }) { + pendingItems.remove(at: index) + condition.unlock() + item.gate.deliver(Self.timedOutResult()) + return + } + + guard let thread = item.runningThread else { + condition.unlock() + item.gate.deliver(Self.timedOutResult()) + return + } + + // Thread is stuck in an uninterruptible syscall: abandon it and spawn a replacement. + thread.markAbandoned() + item.runningThread = nil + liveThreadCount -= 1 + abandonedThreadCount += 1 + if let device = item.reportedDevice { + blockedDevices.insert(device) + } + + var drained: [WorkItem] = [] + if abandonedThreadCount >= abandonBudget { + circuitBroken = true + // Fail closed: even queued jobs stop running. + drained = pendingItems + pendingItems.removeAll() + } + let shouldStartThread = !circuitBroken + && !pendingItems.isEmpty + && idleThreadCount == 0 + && liveThreadCount < maxThreadCount + if shouldStartThread { + liveThreadCount += 1 + } + condition.unlock() + + // State is settled; only now deliver the result. + item.gate.deliver(Self.timedOutResult()) + + for pending in drained { + pending.cancelTimeout() + pending.gate.resolve(with: Self.circuitBrokenResult()) + } + if shouldStartThread { + startThread() + } + } + + private static func timedOutResult() -> DiskCleanSizeResult { + .unavailable(reasons: [.timedOut], observedAt: Date()) + } + + private static func circuitBrokenResult() -> DiskCleanSizeResult { + .unavailable(reasons: [.unsupportedVolume], observedAt: Date()) + } + + // MARK: - Internal types + + /// Mutable fields are protected by `condition`. + private final class WorkItem: @unchecked Sendable { + let gate: DiskCleanResumeGate + let job: DiskCleanSizingJob + let deadline: Date + let cancellation: DiskCleanCancellationFlag + + var runningThread: WorkerThread? + var reportedDevice: UInt64? + /// `DispatchWorkItem` is not Sendable, so protect it with a separate NSLock instead of the pool condition. + private let timeoutLock = NSLock() + private var timeoutWork: DispatchWorkItem? + + init( + gate: DiskCleanResumeGate, + job: @escaping DiskCleanSizingJob, + deadline: Date, + cancellation: DiskCleanCancellationFlag + ) { + self.gate = gate + self.job = job + self.deadline = deadline + self.cancellation = cancellation + } + + func attachTimeout(_ work: DispatchWorkItem) { + timeoutLock.lock() + timeoutWork = work + timeoutLock.unlock() + } + + func cancelTimeout() { + timeoutLock.lock() + let work = timeoutWork + timeoutWork = nil + timeoutLock.unlock() + work?.cancel() + } + } + + private final class WorkerThread: @unchecked Sendable { + /// Protected by `condition`. + private(set) var isAbandoned = false + + func markAbandoned() { + isAbandoned = true + } + } +} + +/// Cancellation flag. `withTaskCancellationHandler` sets it on any thread; workers read it per batch. +final class DiskCleanCancellationFlag: Sendable { + private let flag = OSAllocatedUnfairLock(initialState: false) + + func set() { + flag.withLock { $0 = true } + } + + var isSet: Bool { + flag.withLock { $0 } + } +} diff --git a/Plugins/DiskClean/Tests/DiskCleanAuditLogTests.swift b/Plugins/DiskClean/Tests/DiskCleanAuditLogTests.swift new file mode 100644 index 00000000..3ea3742d --- /dev/null +++ b/Plugins/DiskClean/Tests/DiskCleanAuditLogTests.swift @@ -0,0 +1,134 @@ +import Foundation +import XCTest +@testable import MacTools +@testable import DiskCleanPlugin + +/// Audit log field completeness and rotation (design §7.8). +final class DiskCleanAuditLogTests: XCTestCase { + private var storage: DiskCleanTempDirectory! + + override func setUpWithError() throws { + try super.setUpWithError() + storage = try DiskCleanTempDirectory(name: "diskclean-audit") + } + + override func tearDown() { + storage?.remove() + storage = nil + super.tearDown() + } + + func testInitDoesNotTouchFileSystem() { + let untouched = storage.resolve("never-created") + + _ = DiskCleanAuditLog(directory: untouched) + + XCTAssertFalse(FileManager.default.fileExists(atPath: untouched.path)) + } + + func testRecordRoundTripsEveryField() throws { + let log = DiskCleanAuditLog(directory: storage.url) + let timestamp = Date(timeIntervalSince1970: 1_700_000_000) + + log.append( + DiskCleanAuditLog.Record( + timestamp: timestamp, + action: .delete, + targetID: "cache.example", + legacyRuleID: "cache.legacy", + category: DiskCleanCategoryID.appCaches.rawValue, + path: "/cache/app", + stagedName: ".mactools-staged-abc", + estimatedBytes: 4_096, + status: "partiallyDeleted", + skipReason: nil, + error: "Failed to delete file (Permission denied)" + ) + ) + + let record = try XCTUnwrap(log.recentRecords(limit: 1).first) + XCTAssertEqual(record.timestamp.timeIntervalSince1970, timestamp.timeIntervalSince1970, accuracy: 1) + XCTAssertEqual(record.action, .delete) + XCTAssertEqual(record.targetID, "cache.example") + XCTAssertEqual(record.legacyRuleID, "cache.legacy") + XCTAssertEqual(record.category, "appCaches") + XCTAssertEqual(record.path, "/cache/app") + XCTAssertEqual(record.stagedName, ".mactools-staged-abc") + XCTAssertEqual(record.estimatedBytes, 4_096) + XCTAssertEqual(record.status, "partiallyDeleted") + XCTAssertNil(record.skipReason) + XCTAssertEqual(record.error, "Failed to delete file (Permission denied)") + } + + func testRecentRecordsAreNewestFirstAndRespectLimit() { + let log = DiskCleanAuditLog(directory: storage.url) + for index in 0..<5 { + log.append(record(status: "ok-\(index)", secondsSinceEpoch: Double(1_000 + index * 10))) + } + + let records = log.recentRecords(limit: 3) + + XCTAssertEqual(records.map(\.status), ["ok-4", "ok-3", "ok-2"]) + } + + /// Rotate to `.1` when the threshold is hit (tests inject a small value); keep 2 generations. + func testRotatesAtThresholdAndKeepsTwoGenerations() throws { + let log = DiskCleanAuditLog(directory: storage.url, maximumFileSizeBytes: 400) + + for index in 0..<12 { + log.append(record(status: "gen-\(index)", secondsSinceEpoch: Double(1_000 + index))) + } + + let currentExists = FileManager.default.fileExists( + atPath: storage.resolve(DiskCleanAuditLog.fileName).path + ) + let rotatedExists = FileManager.default.fileExists( + atPath: storage.resolve(DiskCleanAuditLog.rotatedFileName).path + ) + XCTAssertTrue(currentExists) + XCTAssertTrue(rotatedExists, "must rotate to .1 once the threshold is exceeded") + XCTAssertEqual( + try FileManager.default.contentsOfDirectory(atPath: storage.path).sorted(), + [DiskCleanAuditLog.fileName, DiskCleanAuditLog.rotatedFileName].sorted(), + "keep only 2 generations; must not pile up .2 / .3" + ) + } + + /// After rotation, "recent records" must still read across both generations, + /// otherwise cleanup history would go blank at the rotation boundary. + /// + /// Keeping 2 generations means older records are **supposed** to be discarded, + /// so the assertion is "read content spans beyond the current file", + /// not "the earliest record is still present". + func testRecentRecordsSpanRotatedGeneration() throws { + let log = DiskCleanAuditLog(directory: storage.url, maximumFileSizeBytes: 400) + for index in 0..<12 { + log.append(record(status: "gen-\(index)", secondsSinceEpoch: Double(1_000 + index))) + } + + let statuses = log.recentRecords(limit: 50).map(\.status) + let currentGeneration = try String( + contentsOf: storage.resolve(DiskCleanAuditLog.fileName), + encoding: .utf8 + ) + + XCTAssertEqual(statuses.first, "gen-11", "newest record comes first") + XCTAssertTrue( + statuses.contains { !currentGeneration.contains("\"status\":\"\($0)\"") }, + "the generation rotated to .1 must still be readable, or history would go blank at rotation" + ) + let indices = statuses.compactMap { Int($0.dropFirst("gen-".count)) } + XCTAssertEqual(indices, indices.sorted(by: >), "merged generations must remain reverse-chronological") + } + + private func record(status: String, secondsSinceEpoch: Double) -> DiskCleanAuditLog.Record { + DiskCleanAuditLog.Record( + timestamp: Date(timeIntervalSince1970: secondsSinceEpoch), + action: .trash, + targetID: "cache.example", + path: "/cache/app", + estimatedBytes: 1, + status: status + ) + } +} diff --git a/Plugins/DiskClean/Tests/DiskCleanBadgeTests.swift b/Plugins/DiskClean/Tests/DiskCleanBadgeTests.swift new file mode 100644 index 00000000..523a8099 --- /dev/null +++ b/Plugins/DiskClean/Tests/DiskCleanBadgeTests.swift @@ -0,0 +1,110 @@ +import XCTest +import MacToolsPluginKit +@testable import MacTools +@testable import DiskCleanPlugin + +/// Per-item badges (design §8.3, §10). +final class DiskCleanBadgeTests: XCTestCase { + private let localization = PluginLocalization(bundle: .main) + + // MARK: - P2 note badges + + /// Uncommitted changes and unpushed commits are different; the user acts differently in the repo. + func testRepositoryBadgeDistinguishesDirtyReasons() { + XCTAssertEqual( + badgeTexts(notes: [.repositoryHasChanges(repositoryPath: "/code", reason: .uncommittedChanges)]), + ["仓库有未提交改动"] + ) + XCTAssertEqual( + badgeTexts(notes: [.repositoryHasChanges(repositoryPath: "/code", reason: .unpushedCommits)]), + ["仓库有未推送提交"] + ) + } + + /// When git inspection fails, treat as "has changes", but **must not** say + /// "has uncommitted changes" — that invents a fact the user cannot find in the repo. + func testInspectionFailureSaysItCouldNotCheckRatherThanClaimingChanges() { + let texts = badgeTexts( + notes: [.repositoryHasChanges(repositoryPath: "/code", reason: .inspectionFailed("git 检查超时"))] + ) + + XCTAssertEqual(texts, ["无法确认仓库状态:git 检查超时"]) + } + + func testInstallerNotesRenderTheirOwnBadges() { + XCTAssertEqual(badgeTexts(notes: [.mayNotBeInstaller]), ["未必是安装包"]) + XCTAssertEqual( + badgeTexts(notes: [.recentlyDownloaded(modifiedAt: Date(timeIntervalSince1970: 1_000))]).count, + 1 + ) + } + + /// Project affiliation is location info, not a warning; badging it would bury the real "repo has changes" signal. + func testProjectNoteDoesNotProduceABadge() { + XCTAssertTrue(badgeTexts(notes: [.developerProject(path: "/code/app", marker: "package.json")]).isEmpty) + } + + func testNoteBadgesComeBeforeSafetyBadges() { + let badges = DiskCleanBadge.badges( + for: candidate( + notes: [.repositoryHasChanges(repositoryPath: "/code", reason: .uncommittedChanges)], + safety: .inUse(processName: "node") + ), + outcome: nil, + localization: localization + ) + + XCTAssertEqual(badges.map(\.id), ["repositoryHasChanges", "inUse"]) + } + + // MARK: - Coexistence with existing badges + + func testCandidateWithoutNotesIsUnchanged() { + let badges = DiskCleanBadge.badges( + for: candidate(notes: [], safety: .allowed), + outcome: nil, + localization: localization + ) + + XCTAssertTrue(badges.isEmpty) + } + + func testSizingBadgeStillWinsWhenSizeIsUnknown() { + let badges = DiskCleanBadge.badges( + for: candidate(notes: [.mayNotBeInstaller], safety: .allowed, sizeResult: nil), + outcome: nil, + localization: localization + ) + + XCTAssertEqual(badges.map(\.id), ["mayNotBeInstaller", "sizing"]) + } + + // MARK: - Helpers + + private func badgeTexts(notes: [DiskCleanCandidateNote]) -> [String] { + DiskCleanBadge.badges( + for: candidate(notes: notes, safety: .allowed), + outcome: nil, + localization: localization + ) + .map(\.text) + } + + private func candidate( + notes: [DiskCleanCandidateNote], + safety: DiskCleanSafetyStatus, + sizeResult: DiskCleanSizeResult? = .testComplete() + ) -> DiskCleanCandidate { + DiskCleanCandidate( + id: "purge.node_modules::/code/app/node_modules", + targetID: DiskCleanPurgeKind.nodeModules.targetID, + legacyRuleID: DiskCleanPurgeKind.nodeModules.targetID, + category: .developerArtifacts, + path: "/code/app/node_modules", + risk: .low, + safety: safety, + notes: notes, + sizeResult: sizeResult + ) + } +} diff --git a/Plugins/DiskClean/Tests/DiskCleanBulkAttributeParserTests.swift b/Plugins/DiskClean/Tests/DiskCleanBulkAttributeParserTests.swift new file mode 100644 index 00000000..3513a858 --- /dev/null +++ b/Plugins/DiskClean/Tests/DiskCleanBulkAttributeParserTests.swift @@ -0,0 +1,385 @@ +import Darwin +import XCTest +@testable import MacTools +@testable import DiskCleanPlugin + +final class DiskCleanBulkAttributeParserTests: XCTestCase { + private var temporaryDirectory: DiskCleanTempDirectory! + + override func setUpWithError() throws { + try super.setUpWithError() + temporaryDirectory = try DiskCleanTempDirectory(name: "DiskCleanBulkAttributeParserTests") + } + + override func tearDownWithError() throws { + temporaryDirectory?.remove() + temporaryDirectory = nil + try super.tearDownWithError() + } + + // MARK: - RETURNED_ATTRS missing-attribute matrix + + func testParsesAllRequestedAttributes() throws { + let buffer = EntryEncoder.encode([ + EntryEncoder.Entry( + name: "cache.bin", + devid: 0x0100_0011, + objectType: UInt32(VREG.rawValue), + fileID: 4242, + linkCount: 1, + dataLength: 1024 + ) + ]) + + let entry = try XCTUnwrap(parse(buffer, entryCount: 1).entries.first) + + XCTAssertEqual(entry.displayName, "cache.bin") + XCTAssertEqual(entry.devid, 0x0100_0011) + XCTAssertEqual(entry.fileType, .regularFile) + XCTAssertEqual(entry.fileID, 4242) + XCTAssertEqual(entry.linkCount, 1) + XCTAssertEqual(entry.dataLength, 1024) + XCTAssertFalse(entry.hasLayoutMismatch) + XCTAssertTrue(entry.isFullyResolved) + } + + /// Directory entries omit ATTR_FILE_*; that is normal kernel behavior, not an error, and must count as fully resolved. + func testDirectoryEntryWithoutFileAttributesIsFullyResolved() throws { + let buffer = EntryEncoder.encode([ + EntryEncoder.Entry( + name: "Nested", + devid: 7, + objectType: UInt32(VDIR.rawValue), + fileID: 99, + linkCount: nil, + dataLength: nil + ) + ]) + + let entry = try XCTUnwrap(parse(buffer, entryCount: 1).entries.first) + + XCTAssertEqual(entry.displayName, "Nested") + XCTAssertEqual(entry.fileType, .directory) + XCTAssertNil(entry.linkCount) + XCTAssertNil(entry.dataLength) + XCTAssertTrue(entry.isFullyResolved) + } + + /// Drop one critical attribute at a time: remaining fields must still parse correctly + /// (proving bitmap-skip offset math), and isFullyResolved must become false to trigger fstatat fallback. + func testMissingAttributeCombinationsShiftRemainingFieldsCorrectly() throws { + let cases: [(String, EntryEncoder.Entry, (DiskCleanBulkAttributeEntry) -> Void)] = [ + ("missing DEVID", EntryEncoder.Entry( + name: "a", devid: nil, objectType: UInt32(VREG.rawValue), + fileID: 11, linkCount: 2, dataLength: 64 + ), { entry in + XCTAssertNil(entry.devid) + XCTAssertEqual(entry.fileType, .regularFile) + XCTAssertEqual(entry.fileID, 11) + XCTAssertEqual(entry.linkCount, 2) + XCTAssertEqual(entry.dataLength, 64) + }), + ("missing OBJTYPE", EntryEncoder.Entry( + name: "b", devid: 5, objectType: nil, + fileID: 12, linkCount: 1, dataLength: 128 + ), { entry in + XCTAssertEqual(entry.devid, 5) + XCTAssertNil(entry.fileType) + XCTAssertEqual(entry.fileID, 12) + XCTAssertEqual(entry.dataLength, 128) + }), + ("missing FILEID", EntryEncoder.Entry( + name: "c", devid: 5, objectType: UInt32(VLNK.rawValue), + fileID: nil, linkCount: 1, dataLength: 9 + ), { entry in + XCTAssertEqual(entry.fileType, .symlink) + XCTAssertNil(entry.fileID) + XCTAssertEqual(entry.dataLength, 9) + }), + ("missing LINKCOUNT", EntryEncoder.Entry( + name: "d", devid: 5, objectType: UInt32(VREG.rawValue), + fileID: 13, linkCount: nil, dataLength: 256 + ), { entry in + XCTAssertNil(entry.linkCount) + XCTAssertEqual(entry.dataLength, 256) + }), + ("missing DATALENGTH", EntryEncoder.Entry( + name: "e", devid: 5, objectType: UInt32(VREG.rawValue), + fileID: 14, linkCount: 1, dataLength: nil + ), { entry in + XCTAssertEqual(entry.linkCount, 1) + XCTAssertNil(entry.dataLength) + }) + ] + + for (label, encoded, assertions) in cases { + let entry = try XCTUnwrap(parse(EntryEncoder.encode([encoded]), entryCount: 1).entries.first, label) + XCTAssertEqual(entry.displayName, encoded.name, label) + XCTAssertFalse(entry.hasLayoutMismatch, label) + XCTAssertFalse(entry.isFullyResolved, label) + assertions(entry) + } + } + + func testOnlyNameReturned() throws { + let buffer = EntryEncoder.encode([ + EntryEncoder.Entry( + name: "lonely", devid: nil, objectType: nil, + fileID: nil, linkCount: nil, dataLength: nil + ) + ]) + + let entry = try XCTUnwrap(parse(buffer, entryCount: 1).entries.first) + + XCTAssertEqual(entry.displayName, "lonely") + XCTAssertNil(entry.devid) + XCTAssertNil(entry.fileType) + XCTAssertFalse(entry.isFullyResolved) + } + + func testMapsObjectTypesIncludingUnusualOnes() throws { + let expectations: [(UInt32, DiskCleanRootIdentity.FileType)] = [ + (UInt32(VDIR.rawValue), .directory), + (UInt32(VREG.rawValue), .regularFile), + (UInt32(VLNK.rawValue), .symlink), + (UInt32(VSOCK.rawValue), .other), + (UInt32(VFIFO.rawValue), .other) + ] + + for (objectType, expected) in expectations { + let buffer = EntryEncoder.encode([ + EntryEncoder.Entry( + name: "x", devid: 1, objectType: objectType, + fileID: 1, linkCount: 1, dataLength: 0 + ) + ]) + let entry = try XCTUnwrap(parse(buffer, entryCount: 1).entries.first) + XCTAssertEqual(entry.fileType, expected, "objectType=\(objectType)") + } + } + + // MARK: - Layout mismatch detection + + /// Reproduces observed kernel behavior for `ATTR_CMN_ERROR`: requested but returned bit is 0, yet the field still occupies 4 bytes. + /// A naive "skip by bitmap" parser misaligns from here, reading linkCount as the high half of dataLength, etc. + /// The parser must use NAME's attr_dataoffset to detect the boundary mismatch, set hasLayoutMismatch, and discard attribute values. + func testDetectsReservedButUnreturnedAttributeAsLayoutMismatch() throws { + let buffer = EntryEncoder.encode([ + EntryEncoder.Entry( + name: "shifted", devid: 5, objectType: UInt32(VREG.rawValue), + fileID: 20, linkCount: 1, dataLength: 512, + reservedPaddingBytes: 4 + ) + ]) + + let entry = try XCTUnwrap(parse(buffer, entryCount: 1).entries.first) + + XCTAssertTrue(entry.hasLayoutMismatch) + XCTAssertFalse(entry.isFullyResolved, "attributes are untrustworthy after mismatch; must trigger fstatat fallback") + // Name is located via attr_dataoffset and remains usable — fallback needs it. + XCTAssertEqual(entry.displayName, "shifted") + XCTAssertNil(entry.devid) + XCTAssertNil(entry.fileType) + XCTAssertNil(entry.fileID) + XCTAssertNil(entry.linkCount) + XCTAssertNil(entry.dataLength) + } + + // MARK: - Multiple entries and truncation + + func testParsesMultipleEntriesInOneBuffer() { + let buffer = EntryEncoder.encode([ + EntryEncoder.Entry( + name: "first", devid: 1, objectType: UInt32(VREG.rawValue), + fileID: 1, linkCount: 1, dataLength: 10 + ), + EntryEncoder.Entry( + name: "second-with-longer-name", devid: 1, objectType: UInt32(VDIR.rawValue), + fileID: 2, linkCount: nil, dataLength: nil + ), + EntryEncoder.Entry( + name: "third", devid: 1, objectType: UInt32(VREG.rawValue), + fileID: 3, linkCount: 1, dataLength: 30 + ) + ]) + + let result = parse(buffer, entryCount: 3) + + XCTAssertFalse(result.isTruncated) + XCTAssertEqual(result.entries.map(\.displayName), ["first", "second-with-longer-name", "third"]) + XCTAssertEqual(result.entries.map(\.dataLength), [10, nil, 30]) + } + + func testReportsTruncationWhenBufferHoldsFewerEntriesThanClaimed() { + let buffer = EntryEncoder.encode([ + EntryEncoder.Entry( + name: "only", devid: 1, objectType: UInt32(VREG.rawValue), + fileID: 1, linkCount: 1, dataLength: 10 + ) + ]) + + // Kernel claims 3 entries; buffer only fits 1. + let result = parse(buffer, entryCount: 3) + + XCTAssertTrue(result.isTruncated) + XCTAssertEqual(result.entries.count, 1) + } + + func testReportsTruncationOnZeroLengthEntry() { + let buffer = [UInt8](repeating: 0, count: 64) + + let result = parse(buffer, entryCount: 1) + + XCTAssertTrue(result.isTruncated) + XCTAssertTrue(result.entries.isEmpty) + } + + func testEmptyEntryCountYieldsNoEntries() { + let result = parse([UInt8](repeating: 0, count: 64), entryCount: 0) + + XCTAssertFalse(result.isTruncated) + XCTAssertTrue(result.entries.isEmpty) + } + + // MARK: - Cross-check against real kernel buffer + + /// Most important case: do not only check "parser agrees with this test's encoder"; + /// verify the **parser matches real kernel output**. A buggy encoder cannot pass this. + func testParsesRealKernelBuffer() throws { + try temporaryDirectory.makeFile("payload.bin", bytes: 10) + try temporaryDirectory.makeDirectory("subdir") + try temporaryDirectory.makeSymlink("link", destination: "payload.bin") + try temporaryDirectory.makeHardLink("hard.bin", to: "payload.bin") + + let descriptor = open(temporaryDirectory.path, O_RDONLY | O_DIRECTORY) + XCTAssertGreaterThanOrEqual(descriptor, 0) + defer { close(descriptor) } + + var attributeList = DiskCleanBulkAttributeParser.makeAttributeList() + let capacity = 64 * 1024 + let raw = UnsafeMutableRawPointer.allocate(byteCount: capacity, alignment: 8) + defer { raw.deallocate() } + + var byName: [String: DiskCleanBulkAttributeEntry] = [:] + while true { + let returnedCount = getattrlistbulk(descriptor, &attributeList, raw, capacity, 0) + XCTAssertGreaterThanOrEqual(returnedCount, 0, "getattrlistbulk failed errno=\(errno)") + if returnedCount <= 0 { break } + + let result = DiskCleanBulkAttributeParser.parse( + buffer: UnsafeRawBufferPointer(start: raw, count: capacity), + entryCount: Int(returnedCount) + ) + XCTAssertFalse(result.isTruncated, "real kernel buffer must not be treated as truncated") + for entry in result.entries { + XCTAssertFalse(entry.hasLayoutMismatch, "real kernel buffer must not report layout mismatch: \(entry.displayName ?? "?")") + byName[try XCTUnwrap(entry.displayName)] = entry + } + } + + XCTAssertEqual(Set(byName.keys), ["payload.bin", "subdir", "link", "hard.bin"]) + + let payload = try XCTUnwrap(byName["payload.bin"]) + XCTAssertEqual(payload.fileType, .regularFile) + XCTAssertEqual(payload.dataLength, 10) + XCTAssertEqual(payload.linkCount, 2, "payload.bin and hard.bin are hard links of each other") + XCTAssertTrue(payload.isFullyResolved) + + let hard = try XCTUnwrap(byName["hard.bin"]) + XCTAssertEqual(hard.fileID, payload.fileID, "hard links share fileID; the dedupe key depends on that") + XCTAssertEqual(hard.devid, payload.devid) + + let directory = try XCTUnwrap(byName["subdir"]) + XCTAssertEqual(directory.fileType, .directory) + XCTAssertNil(directory.dataLength, "kernel does not return ATTR_FILE_* for directories") + XCTAssertTrue(directory.isFullyResolved) + + let link = try XCTUnwrap(byName["link"]) + XCTAssertEqual(link.fileType, .symlink) + XCTAssertEqual(link.dataLength, Int64("payload.bin".utf8.count), "symlink logical size is the target string length") + } + + // MARK: - Helpers + + private func parse(_ bytes: [UInt8], entryCount: Int) -> DiskCleanBulkAttributeParseResult { + bytes.withUnsafeBytes { buffer in + DiskCleanBulkAttributeParser.parse(buffer: buffer, entryCount: entryCount) + } + } + + /// Encode entries in the observed kernel layout: 4-byte length + 20-byte RETURNED_ATTRS + fixed section + /// (attributes in ascending bit order, packed without alignment padding) + variable section (NAME only). + private enum EntryEncoder { + struct Entry { + var name: String? + var devid: UInt32? + var objectType: UInt32? + var fileID: UInt64? + var linkCount: UInt32? + var dataLength: Int64? + /// Simulated bytes for "requested but returned bit is 0 yet still occupies space", inserted at end of fixed section. + var reservedPaddingBytes: Int = 0 + } + + static func encode(_ entries: [Entry]) -> [UInt8] { + entries.flatMap(encodeOne) + } + + private static func encodeOne(_ entry: Entry) -> [UInt8] { + var fixedSize = 4 + 20 + var nameReferenceOffset = -1 + if entry.name != nil { + nameReferenceOffset = fixedSize + fixedSize += 8 + } + if entry.devid != nil { fixedSize += 4 } + if entry.objectType != nil { fixedSize += 4 } + if entry.fileID != nil { fixedSize += 8 } + if entry.linkCount != nil { fixedSize += 4 } + if entry.dataLength != nil { fixedSize += 8 } + fixedSize += entry.reservedPaddingBytes + + let nameBytes: [UInt8] = entry.name.map { Array($0.utf8) + [0] } ?? [] + let totalLength = fixedSize + nameBytes.count + + var bytes: [UInt8] = [] + bytes.reserveCapacity(totalLength) + append(UInt32(totalLength), to: &bytes) + + var commonReturned = UInt32(ATTR_CMN_RETURNED_ATTRS) + if entry.name != nil { commonReturned |= UInt32(ATTR_CMN_NAME) } + if entry.devid != nil { commonReturned |= UInt32(ATTR_CMN_DEVID) } + if entry.objectType != nil { commonReturned |= UInt32(ATTR_CMN_OBJTYPE) } + if entry.fileID != nil { commonReturned |= UInt32(ATTR_CMN_FILEID) } + var fileReturned: UInt32 = 0 + if entry.linkCount != nil { fileReturned |= UInt32(ATTR_FILE_LINKCOUNT) } + if entry.dataLength != nil { fileReturned |= UInt32(ATTR_FILE_DATALENGTH) } + + // attribute_set_t order: common / vol / dir / file / fork + append(commonReturned, to: &bytes) + append(UInt32(0), to: &bytes) + append(UInt32(0), to: &bytes) + append(fileReturned, to: &bytes) + append(UInt32(0), to: &bytes) + + if entry.name != nil { + let dataOffset = Int32(fixedSize - nameReferenceOffset) + append(dataOffset, to: &bytes) + append(UInt32(nameBytes.count), to: &bytes) + } + if let devid = entry.devid { append(devid, to: &bytes) } + if let objectType = entry.objectType { append(objectType, to: &bytes) } + if let fileID = entry.fileID { append(fileID, to: &bytes) } + if let linkCount = entry.linkCount { append(linkCount, to: &bytes) } + if let dataLength = entry.dataLength { append(dataLength, to: &bytes) } + bytes.append(contentsOf: [UInt8](repeating: 0, count: entry.reservedPaddingBytes)) + bytes.append(contentsOf: nameBytes) + + return bytes + } + + private static func append(_ value: T, to bytes: inout [UInt8]) { + withUnsafeBytes(of: value) { bytes.append(contentsOf: $0) } + } + } +} diff --git a/Plugins/DiskClean/Tests/DiskCleanCandidateAssemblerTests.swift b/Plugins/DiskClean/Tests/DiskCleanCandidateAssemblerTests.swift new file mode 100644 index 00000000..0642cca3 --- /dev/null +++ b/Plugins/DiskClean/Tests/DiskCleanCandidateAssemblerTests.swift @@ -0,0 +1,224 @@ +import Foundation +import XCTest +@testable import MacTools +@testable import DiskCleanPlugin + +/// Ownership resolution and ancestor decomposition (design §5.3). +final class DiskCleanCandidateAssemblerTests: XCTestCase { + // MARK: - Path ownership + + func testMoreSpecificGlobPrefixWinsOwnership() { + let generic = DiskCleanRuleTarget.test(id: "cache.generic", risk: .low) + let specific = DiskCleanRuleTarget.test(id: "cache.specific", risk: .low) + let hits = [ + DiskCleanTargetHit(target: generic, item: .testDirectory("/Caches/akd"), specificity: 7), + DiskCleanTargetHit(target: specific, item: .testDirectory("/Caches/akd"), specificity: 11) + ] + + let owners = DiskCleanCandidateAssembler.resolveOwnership(hits: hits) + + XCTAssertEqual(owners["/Caches/akd"]?.target.id, "cache.specific") + } + + /// Losing ownership hits still contribute logical aliases so whitelist rules on either path apply. + func testOwnershipMergesLogicalAliasesFromAllHitsOnSamePhysicalPath() { + let cacheTarget = DiskCleanRuleTarget.test(id: "cache.pip", risk: .low) + let homeCacheTarget = DiskCleanRuleTarget.test(id: "home.cache.pip", risk: .low) + let physical = DiskCleanFileItem.testDirectory("/Volumes/Data/cache/pip") + let owners = DiskCleanCandidateAssembler.resolveOwnership(hits: [ + DiskCleanTargetHit( + target: homeCacheTarget, + item: physical, + logicalPath: "/Users/t/.cache/pip", + specificity: 5 + ), + DiskCleanTargetHit( + target: cacheTarget, + item: physical, + logicalPath: "/Users/t/Library/Caches/pip", + specificity: 12 + ) + ]) + + let hit = owners[physical.path] + XCTAssertEqual(hit?.target.id, "cache.pip") + XCTAssertEqual( + Set(hit?.logicalPaths ?? []), + Set(["/Users/t/.cache/pip", "/Users/t/Library/Caches/pip"]) + ) + } + + func testEqualSpecificityPrefersHigherRisk() { + let low = DiskCleanRuleTarget.test(id: "cache.low", risk: .low) + let medium = DiskCleanRuleTarget.test(id: "cache.medium", risk: .medium) + let hits = [ + DiskCleanTargetHit(target: low, item: .testDirectory("/Caches/x"), specificity: 7), + DiskCleanTargetHit(target: medium, item: .testDirectory("/Caches/x"), specificity: 7) + ] + + let owners = DiskCleanCandidateAssembler.resolveOwnership(hits: hits) + + XCTAssertEqual( + owners["/Caches/x"]?.target.id, + "cache.medium", + "on a tie, prefer higher risk: fail closed (higher risk is unchecked by default)" + ) + } + + func testOwnershipIsIndependentOfHitOrder() { + let first = DiskCleanRuleTarget.test(id: "cache.aaa", risk: .low) + let second = DiskCleanRuleTarget.test(id: "cache.bbb", risk: .low) + let forward = DiskCleanCandidateAssembler.resolveOwnership(hits: [ + DiskCleanTargetHit(target: first, item: .testDirectory("/x"), specificity: 2), + DiskCleanTargetHit(target: second, item: .testDirectory("/x"), specificity: 2) + ]) + let backward = DiskCleanCandidateAssembler.resolveOwnership(hits: [ + DiskCleanTargetHit(target: second, item: .testDirectory("/x"), specificity: 2), + DiskCleanTargetHit(target: first, item: .testDirectory("/x"), specificity: 2) + ]) + + XCTAssertEqual(forward["/x"]?.target.id, backward["/x"]?.target.id) + } + + // MARK: - Ancestor decomposition + + func testAncestorIsDecomposedIntoDirectChildrenWhileDescendantKeepsIdentity() { + let fileSystem = FakeDiskCleanFileSystem() + fileSystem.setChildren( + [.testDirectory("/root/child"), .testDirectory("/root/other")], + of: "/root" + ) + fileSystem.setChildren( + [.testDirectory("/root/child/deep"), .testFile("/root/child/sibling.bin")], + of: "/root/child" + ) + let ancestor = DiskCleanRuleTarget.test(id: "cache.ancestor", risk: .low) + let descendant = DiskCleanRuleTarget.test(id: "cache.descendant", risk: .medium) + let assembler = DiskCleanCandidateAssembler(fileSystem: fileSystem) + + let owned = assembler.assemble(hits: [ + DiskCleanTargetHit(target: ancestor, item: .testDirectory("/root"), specificity: 5), + DiskCleanTargetHit(target: descendant, item: .testDirectory("/root/child/deep"), specificity: 16) + ]) + + XCTAssertEqual( + owned.map(\.item.path), + ["/root/child/deep", "/root/child/sibling.bin", "/root/other"] + ) + XCTAssertEqual( + Dictionary(owned.map { ($0.item.path, $0.target.id) }, uniquingKeysWith: { first, _ in first }), + [ + "/root/child/deep": "cache.descendant", + "/root/child/sibling.bin": "cache.ancestor", + "/root/other": "cache.ancestor" + ], + "decomposed children inherit the ancestor target; descendant candidates keep their own identity and risk" + ) + XCTAssertFalse(owned.contains { $0.item.path == "/root" }, "the ancestor itself is no longer a candidate") + } + + func testCandidateWithoutDescendantsIsKeptAsIs() { + let fileSystem = FakeDiskCleanFileSystem() + let target = DiskCleanRuleTarget.test(id: "cache.a") + let assembler = DiskCleanCandidateAssembler(fileSystem: fileSystem) + + let owned = assembler.assemble(hits: [ + DiskCleanTargetHit(target: target, item: .testDirectory("/root/a"), specificity: 5), + DiskCleanTargetHit(target: target, item: .testDirectory("/root/b"), specificity: 5) + ]) + + XCTAssertEqual(owned.map(\.item.path), ["/root/a", "/root/b"]) + } + + func testPrefixSiblingIsNotTreatedAsDescendant() { + let fileSystem = FakeDiskCleanFileSystem() + let target = DiskCleanRuleTarget.test(id: "cache.a") + let assembler = DiskCleanCandidateAssembler(fileSystem: fileSystem) + + let owned = assembler.assemble(hits: [ + DiskCleanTargetHit(target: target, item: .testDirectory("/root/cache"), specificity: 5), + DiskCleanTargetHit(target: target, item: .testDirectory("/root/cache-backup"), specificity: 5) + ]) + + XCTAssertEqual( + owned.map(\.item.path), + ["/root/cache", "/root/cache-backup"], + "prefix siblings are not descendants and must not trigger decomposition" + ) + } + + func testUnlistableAncestorIsDroppedRatherThanSwallowingDescendant() { + let fileSystem = FakeDiskCleanFileSystem() + fileSystem.markUnlistable("/root") + let ancestor = DiskCleanRuleTarget.test(id: "cache.ancestor") + let descendant = DiskCleanRuleTarget.test(id: "cache.descendant", risk: .medium) + let assembler = DiskCleanCandidateAssembler(fileSystem: fileSystem) + + let owned = assembler.assemble(hits: [ + DiskCleanTargetHit(target: ancestor, item: .testDirectory("/root"), specificity: 5), + DiskCleanTargetHit(target: descendant, item: .testDirectory("/root/deep"), specificity: 10) + ]) + + XCTAssertEqual( + owned.map(\.item.path), + ["/root/deep"], + "if the directory is unlistable, drop the ancestor entirely: keeping it would erase independent descendant decisions" + ) + } + + func testMultipleDescendantsUnderSameAncestorAreAllPreserved() { + let fileSystem = FakeDiskCleanFileSystem() + fileSystem.setChildren( + [.testDirectory("/root/a"), .testDirectory("/root/b"), .testDirectory("/root/c")], + of: "/root" + ) + let ancestor = DiskCleanRuleTarget.test(id: "cache.ancestor") + let first = DiskCleanRuleTarget.test(id: "cache.first", risk: .medium) + let second = DiskCleanRuleTarget.test(id: "cache.second", risk: .medium) + let assembler = DiskCleanCandidateAssembler(fileSystem: fileSystem) + + let owned = assembler.assemble(hits: [ + DiskCleanTargetHit(target: ancestor, item: .testDirectory("/root"), specificity: 5), + DiskCleanTargetHit(target: first, item: .testDirectory("/root/a"), specificity: 7), + DiskCleanTargetHit(target: second, item: .testDirectory("/root/b"), specificity: 7) + ]) + + XCTAssertEqual( + Dictionary(owned.map { ($0.item.path, $0.target.id) }, uniquingKeysWith: { first, _ in first }), + [ + "/root/a": "cache.first", + "/root/b": "cache.second", + "/root/c": "cache.ancestor" + ] + ) + } + + // MARK: - Glob fixed prefix + + func testFixedPrefixIsLastCompleteComponentBeforeFirstWildcard() { + XCTAssertEqual( + DiskCleanGlobPrefix.fixedPrefix(of: "~/Library/Caches/*"), + "~/Library/Caches" + ) + XCTAssertEqual( + DiskCleanGlobPrefix.fixedPrefix(of: "~/Library/Caches/com.apple.iconservices*"), + "~/Library/Caches" + ) + XCTAssertEqual( + DiskCleanGlobPrefix.fixedPrefix(of: "~/Library/Application Support/Google/Chrome/*/Service Worker/*"), + "~/Library/Application Support/Google/Chrome" + ) + XCTAssertEqual( + DiskCleanGlobPrefix.fixedPrefix(of: "~/Library/Caches/com.apple.akd"), + "~/Library/Caches/com.apple.akd", + "a glob without wildcards is itself the fixed prefix, and therefore most specific" + ) + } + + func testMoreSpecificGlobHasLongerFixedPrefix() { + let generic = DiskCleanGlobPrefix.fixedPrefix(of: "~/Library/Caches/*").count + let specific = DiskCleanGlobPrefix.fixedPrefix(of: "~/Library/Caches/com.apple.akd").count + + XCTAssertGreaterThan(specific, generic) + } +} diff --git a/Plugins/DiskClean/Tests/DiskCleanCleanupHistoryTests.swift b/Plugins/DiskClean/Tests/DiskCleanCleanupHistoryTests.swift new file mode 100644 index 00000000..ecfd8a82 --- /dev/null +++ b/Plugins/DiskClean/Tests/DiskCleanCleanupHistoryTests.swift @@ -0,0 +1,101 @@ +import Foundation +import XCTest +@testable import MacTools +@testable import DiskCleanPlugin + +/// Cleanup history classification and pin rules (design §7.5, §13-M4-6). +final class DiskCleanCleanupHistoryTests: XCTestCase { + private let timestamp = Date(timeIntervalSince1970: 1_000) + + func testStatusParsingCoversEveryStatusWrittenByTheAuditLog() { + let expected: [String: DiskCleanCleanupHistoryStatus] = [ + "ok": .ok, + "skipped": .skipped, + "changedSinceScan": .changedSinceScan, + "failed": .failed, + "partiallyDeleted": .partiallyDeleted, + "rollbackBlocked": .rollbackBlocked, + "reconciledRolledBack": .reconciledRolledBack, + "reconciledAbsent": .reconciledAbsent, + // Missing parent directory and missing staged object share the same conclusion: nothing to restore. + "reconciledParentMissing": .reconciledAbsent, + "reconcileFailed": .reconcileFailed + ] + + for (rawValue, status) in expected { + XCTAssertEqual(DiskCleanCleanupHistoryStatus(rawValue: rawValue), status, rawValue) + } + XCTAssertEqual(DiskCleanCleanupHistoryStatus(rawValue: "brandNew"), .unknown("brandNew")) + } + + /// Only the three "leftovers remain on disk" states need attention. Ordinary failures and skips leave nothing and should not interrupt the user. + func testOnlyLeftoverStatesNeedAttention() { + let needsAttention: [DiskCleanCleanupHistoryStatus] = [ + .partiallyDeleted, .rollbackBlocked, .reconcileFailed + ] + let quiet: [DiskCleanCleanupHistoryStatus] = [ + .ok, .skipped, .changedSinceScan, .failed, + .reconciledRolledBack, .reconciledAbsent, .unknown("x") + ] + + for status in needsAttention { + XCTAssertTrue(status.needsAttention, "\(status) must be pinned for attention") + } + for status in quiet { + XCTAssertFalse(status.needsAttention, "\(status) must not be pinned") + } + } + + func testAttentionEntriesArePinnedAboveTheRest() { + let records = [ + record(status: "ok", path: "/cache/a"), + record(status: "skipped", path: "/cache/b"), + record(status: "rollbackBlocked", path: "/cache/c", stagedName: ".mactools-staged-c"), + record(status: "ok", path: "/cache/d"), + record(status: "partiallyDeleted", path: "/cache/e", stagedName: ".mactools-staged-e") + ] + + let entries = DiskCleanCleanupHistoryEntry.entries(from: records) + + XCTAssertEqual( + entries.map(\.path), + ["/cache/c", "/cache/e", "/cache/a", "/cache/b", "/cache/d"], + "leftover entries are pinned first; the rest keep reverse chronological order" + ) + XCTAssertEqual(entries.prefix(2).filter(\.needsAttention).count, 2) + } + + /// Trash objects land under the staged name; without this field the user cannot recover them (design §7.4). + func testEntryCarriesStagedNameAndOriginalPath() throws { + let entries = DiskCleanCleanupHistoryEntry.entries( + from: [record(status: "trashedPlaceholder", path: "/cache/a", stagedName: ".mactools-staged-a")] + ) + + let entry = try XCTUnwrap(entries.first) + XCTAssertEqual(entry.path, "/cache/a") + XCTAssertEqual(entry.stagedName, ".mactools-staged-a") + } + + func testEntriesHaveStableIdentifiersWithinOneLoad() { + let entries = DiskCleanCleanupHistoryEntry.entries( + from: [record(status: "ok", path: "/a"), record(status: "ok", path: "/b")] + ) + + XCTAssertEqual(Set(entries.map(\.id)).count, entries.count, "ids must be unique within a single load") + } + + private func record( + status: String, + path: String, + stagedName: String? = nil + ) -> DiskCleanAuditLog.Record { + DiskCleanAuditLog.Record( + timestamp: timestamp, + action: .trash, + path: path, + stagedName: stagedName, + estimatedBytes: 1_024, + status: status + ) + } +} diff --git a/Plugins/DiskClean/Tests/DiskCleanControllerTests.swift b/Plugins/DiskClean/Tests/DiskCleanControllerTests.swift new file mode 100644 index 00000000..3d32edfd --- /dev/null +++ b/Plugins/DiskClean/Tests/DiskCleanControllerTests.swift @@ -0,0 +1,832 @@ +import Foundation +import XCTest +@testable import MacTools +@testable import DiskCleanPlugin + +@MainActor +final class DiskCleanControllerTests: XCTestCase { + private let observedAt = Date(timeIntervalSince1970: 10_000) + + // MARK: - Event stream consumption + + func testConsumesStreamAndPublishesScannedResult() async throws { + let engine = ControlledDiskCleanScanEngine() + let controller = makeController(engine: engine) + + controller.scan() + await waitUntil("scan started") { controller.snapshot.phase == .scanning } + + let candidate = makeCandidate(id: "a", path: "/cache/a") + engine.emit(.candidateFound(candidate)) + engine.emit(.candidateSized(id: candidate.id, result: .testComplete(bytes: 2_048, observedAt: observedAt))) + engine.emit(.finished(makeSummary(candidates: [candidate.applying(.testComplete(bytes: 2_048, observedAt: observedAt))]))) + + await waitUntil("scan finished") { controller.snapshot.phase == .scanned } + let result = try XCTUnwrap(controller.snapshot.scanResult) + XCTAssertEqual(result.cleanableCandidates.map(\.id), ["a"]) + XCTAssertEqual(result.cleanableSizeBytes, 2_048) + XCTAssertNotNil(result.artifact, "finished scan must carry an artifact — M4 clean entry only accepts artifacts") + XCTAssertTrue(controller.snapshot.canClean) + } + + func testStreamingCandidatesAreVisibleBeforeScanFinishes() async throws { + let engine = ControlledDiskCleanScanEngine() + let controller = makeController(engine: engine) + + controller.scan() + await waitUntil("scan started") { controller.snapshot.phase == .scanning } + engine.emit(.candidateFound(makeCandidate(id: "a", path: "/cache/a"))) + + // Throttled publish: appearing within ~250ms is expected; not required to be immediately visible. + await waitUntil("streaming candidates visible") { controller.snapshot.scanResult?.candidates.count == 1 } + XCTAssertEqual(controller.snapshot.phase, .scanning) + XCTAssertFalse(controller.snapshot.canClean, "cannot clean while scanning") + } + + func testPropagatesLimitationsFromSummary() async throws { + let engine = ControlledDiskCleanScanEngine() + let controller = makeController(engine: engine) + + controller.scan() + await waitUntil("scan started") { controller.snapshot.phase == .scanning } + engine.emit( + .finished( + makeSummary(candidates: [], limitations: [.walkerCircuitBroken, .threadsAbandoned(count: 2)]) + ) + ) + + await waitUntil("scan finished") { controller.snapshot.phase == .scanned } + let result = try XCTUnwrap(controller.snapshot.scanResult) + XCTAssertEqual(result.limitations, [.walkerCircuitBroken, .threadsAbandoned(count: 2)]) + XCTAssertTrue(result.isLimited) + } + + func testScanFailurePublishesErrorMessage() async { + let engine = ControlledDiskCleanScanEngine() + let controller = makeController(engine: engine) + + controller.scan() + await waitUntil("scan started") { controller.snapshot.phase == .scanning } + engine.fail(with: FakeDiskCleanExpansionError(message: "engine exploded")) + + await waitUntil("failure published") { controller.snapshot.errorMessage != nil } + XCTAssertEqual(controller.snapshot.phase, .idle) + XCTAssertEqual(controller.snapshot.errorMessage, "engine exploded") + } + + // MARK: - operationID generations + + func testEventsFromSupersededOperationAreDiscarded() async throws { + let engine = ControlledDiskCleanScanEngine() + let controller = makeController(engine: engine) + + controller.scan() + await waitUntil("scan started") { controller.snapshot.phase == .scanning } + // Cancel scan → previous operationID is invalidated. + controller.cancelCurrentOperation() + XCTAssertEqual(controller.snapshot.phase, .idle) + + // Tail events from a superseded operation must never push state back to scanned. + engine.emit(.candidateFound(makeCandidate(id: "a", path: "/cache/a"))) + engine.emit(.finished(makeSummary(candidates: [makeSizedCandidate(id: "a", path: "/cache/a")]))) + try await Task.sleep(nanoseconds: 400_000_000) + + XCTAssertEqual(controller.snapshot.phase, .idle) + XCTAssertNil(controller.snapshot.scanResult) + } + + func testCancellingScanTerminatesEngineStream() async { + let engine = ControlledDiskCleanScanEngine() + let controller = makeController(engine: engine) + + controller.scan() + await waitUntil("scan started") { controller.snapshot.phase == .scanning } + controller.cancelCurrentOperation() + + await waitUntil("engine stream terminated") { engine.didTerminate } + } + + func testRescanUsesFreshOperationAndResetsLog() async throws { + let engine = ControlledDiskCleanScanEngine() + let controller = makeController(engine: engine) + + controller.scan() + await waitUntil("scan started") { controller.snapshot.phase == .scanning } + engine.emit(.finished(makeSummary(candidates: [makeSizedCandidate(id: "a", path: "/cache/a")]))) + await waitUntil("first round finished") { controller.snapshot.phase == .scanned } + + controller.scan() + + XCTAssertEqual(engine.scanCallCount, 2) + XCTAssertEqual(controller.snapshot.scanLogEntries.count, 1, "a new scan starts with a clean log") + XCTAssertNil(controller.snapshot.scanResult) + } + + // MARK: - Expiry gate + + func testExpiryClockFlipsCanCleanAndNotifiesStateChange() async throws { + let clock = TestDiskCleanClock(now: observedAt) + let engine = ControlledDiskCleanScanEngine() + let controller = makeController(engine: engine, clock: clock) + var stateChangeCount = 0 + controller.onStateChange = { stateChangeCount += 1 } + + controller.scan() + await waitUntil("scan started") { controller.snapshot.phase == .scanning } + engine.emit(.finished(makeSummary(candidates: [makeSizedCandidate(id: "a", path: "/cache/a")]))) + await waitUntil("scan finished") { controller.snapshot.phase == .scanned } + XCTAssertTrue(controller.snapshot.canClean) + + let changesBeforeExpiry = stateChangeCount + clock.advance(by: DiskCleanScanFreshness.window) + + await waitUntil("expiry gate fired") { controller.snapshot.isResultExpired } + XCTAssertFalse(controller.snapshot.canClean, "cannot clean after expiry") + XCTAssertGreaterThan( + stateChangeCount, + changesBeforeExpiry, + "expiry is time-driven: notify host to refresh when due, without waiting for a user click" + ) + XCTAssertEqual(controller.snapshot.subtitle, "结果已过期,请重新扫描") + } + + func testExpiryDeadlineUsesEarliestObservedAtAmongCleanableCandidates() async throws { + let clock = TestDiskCleanClock(now: observedAt) + let engine = ControlledDiskCleanScanEngine() + let controller = makeController(engine: engine, clock: clock) + + controller.scan() + await waitUntil("scan started") { controller.snapshot.phase == .scanning } + engine.emit( + .finished( + makeSummary(candidates: [ + makeSizedCandidate(id: "old", path: "/cache/old", observedAt: observedAt), + makeSizedCandidate(id: "new", path: "/cache/new", observedAt: observedAt.addingTimeInterval(100)) + ]) + ) + ) + await waitUntil("scan finished") { controller.snapshot.phase == .scanned } + + XCTAssertEqual( + controller.snapshot.scanResult?.expiryDeadline, + observedAt.addingTimeInterval(DiskCleanScanFreshness.window) + ) + } + + func testExpiredResultTriggersForceRefreshOnRescan() async throws { + let clock = TestDiskCleanClock(now: observedAt) + let engine = ControlledDiskCleanScanEngine() + let controller = makeController(engine: engine, clock: clock) + + controller.scan() + await waitUntil("scan started") { controller.snapshot.phase == .scanning } + engine.emit(.finished(makeSummary(candidates: [makeSizedCandidate(id: "a", path: "/cache/a")]))) + await waitUntil("scan finished") { controller.snapshot.phase == .scanned } + XCTAssertEqual(engine.lastForceRefresh, false) + + clock.advance(by: DiskCleanScanFreshness.window) + await waitUntil("expiry gate fired") { controller.snapshot.isResultExpired } + controller.scan() + + XCTAssertEqual( + engine.lastForceRefresh, + true, + "rescan after expiry must bypass cache, or it loops: expired → rescan → hit stale cache → still expired" + ) + } + + func testResultObservedBeforeScanStartIsExpiredImmediately() async throws { + let clock = TestDiskCleanClock(now: observedAt.addingTimeInterval(DiskCleanScanFreshness.window + 1)) + let engine = ControlledDiskCleanScanEngine() + let controller = makeController(engine: engine, clock: clock) + + controller.scan() + await waitUntil("scan started") { controller.snapshot.phase == .scanning } + engine.emit(.finished(makeSummary(candidates: [makeSizedCandidate(id: "a", path: "/cache/a")]))) + + await waitUntil("scan finished") { controller.snapshot.phase == .scanned } + XCTAssertTrue(controller.snapshot.isResultExpired, "a cache hit with old observedAt may already be expired at birth") + XCTAssertFalse(controller.snapshot.canClean) + } + + // MARK: - Clean-set invariants + + func testCleanDropsCandidatesWithIncompleteSize() async throws { + let engine = ControlledDiskCleanScanEngine() + let executor = FakeDiskCleanExecutor() + let controller = makeController(engine: engine, executor: executor) + + controller.scan() + await waitUntil("scan started") { controller.snapshot.phase == .scanning } + let complete = makeSizedCandidate(id: "complete", path: "/cache/complete") + let partial = makeCandidate(id: "partial", path: "/cache/partial") + .applying(.testPartial(reasons: [.timedOut], observedAt: observedAt)) + let unsized = makeCandidate(id: "unsized", path: "/cache/unsized") + engine.emit(.finished(makeSummary(candidates: [complete, partial, unsized]))) + await waitUntil("scan finished") { controller.snapshot.phase == .scanned } + + controller.clean() + await waitUntil("clean finished") { controller.snapshot.phase == .completed } + + XCTAssertEqual( + executor.lastSelectedIDs, + ["complete"], + "unknown-size and partial candidates never enter the clean set (first line of §3.1 invariants)" + ) + } + + func testCleanIsIgnoredWhenNothingIsSelectable() async throws { + let engine = ControlledDiskCleanScanEngine() + let executor = FakeDiskCleanExecutor() + let controller = makeController(engine: engine, executor: executor) + + controller.scan() + await waitUntil("scan started") { controller.snapshot.phase == .scanning } + let partial = makeCandidate(id: "partial", path: "/cache/partial") + .applying(.testPartial(reasons: [.permissionDenied], observedAt: observedAt)) + engine.emit(.finished(makeSummary(candidates: [partial]))) + await waitUntil("scan finished") { controller.snapshot.phase == .scanned } + + controller.clean() + + XCTAssertEqual(executor.callCount, 0) + XCTAssertEqual(controller.snapshot.phase, .scanned) + XCTAssertFalse(controller.snapshot.canClean) + } + + func testCleanIsRejectedAfterExpiry() async throws { + let clock = TestDiskCleanClock(now: observedAt) + let engine = ControlledDiskCleanScanEngine() + let executor = FakeDiskCleanExecutor() + let controller = makeController(engine: engine, executor: executor, clock: clock) + + controller.scan() + await waitUntil("scan started") { controller.snapshot.phase == .scanning } + engine.emit(.finished(makeSummary(candidates: [makeSizedCandidate(id: "a", path: "/cache/a")]))) + await waitUntil("scan finished") { controller.snapshot.phase == .scanned } + clock.advance(by: DiskCleanScanFreshness.window) + await waitUntil("expiry gate fired") { controller.snapshot.isResultExpired } + + controller.clean() + + XCTAssertEqual(executor.callCount, 0) + } + + // MARK: - Selection commands (§8.1) + + func testDefaultSelectionCoversLowRiskCandidatesOnly() async throws { + let engine = ControlledDiskCleanScanEngine() + let controller = makeController(engine: engine) + + controller.scan() + await waitUntil("scan started") { controller.snapshot.phase == .scanning } + engine.emit( + .finished( + makeSummary(candidates: [ + makeSizedCandidate(id: "low", path: "/cache/low"), + makeSizedCandidate(id: "medium", path: "/cache/medium", risk: .medium), + makeCandidate(id: "unsized", path: "/cache/unsized") + ]) + ) + ) + await waitUntil("scan finished") { controller.snapshot.phase == .scanned } + + XCTAssertEqual(controller.snapshot.selection.selectedIDs, ["low"]) + XCTAssertEqual(controller.snapshot.selection.selectableIDs, ["low", "medium"]) + XCTAssertEqual(controller.snapshot.selection.selectedEstimatedBytes, 1_024) + } + + func testStreamingCandidateJoinsSelectionOnlyOnceItsSizeIsKnown() async throws { + let engine = ControlledDiskCleanScanEngine() + let controller = makeController(engine: engine) + + controller.scan() + await waitUntil("scan started") { controller.snapshot.phase == .scanning } + let candidate = makeCandidate(id: "a", path: "/cache/a") + engine.emit(.candidateFound(candidate)) + await waitUntil("candidates visible") { controller.snapshot.scanResult?.candidates.count == 1 } + + XCTAssertTrue( + controller.snapshot.selection.isEmpty, + "unknown-size candidates cannot be selected; default policy must not pull them in" + ) + + engine.emit(.candidateSized(id: candidate.id, result: .testComplete(bytes: 512, observedAt: observedAt))) + + await waitUntil("auto-selected by default policy after sizing") { controller.snapshot.selection.selectedIDs == ["a"] } + } + + func testTogglingUnselectableCandidateIsRejected() async throws { + let engine = ControlledDiskCleanScanEngine() + let controller = makeController(engine: engine) + + controller.scan() + await waitUntil("scan started") { controller.snapshot.phase == .scanning } + let locked = makeCandidate(id: "locked", path: "/cache/locked", safety: .inUse(processName: "App")) + .applying(.testComplete(bytes: 1_024, observedAt: observedAt)) + engine.emit(.finished(makeSummary(candidates: [locked]))) + await waitUntil("scan finished") { controller.snapshot.phase == .scanned } + + controller.setCandidateSelected("locked", isSelected: true) + + XCTAssertTrue(controller.snapshot.selection.isEmpty, "locked candidates cannot be selected; the command must be rejected, not only disabled in UI") + XCTAssertFalse(controller.snapshot.canClean) + } + + func testCategorySelectAllNeverPullsInMediumRiskCandidates() async throws { + let engine = ControlledDiskCleanScanEngine() + let controller = makeController(engine: engine) + + controller.scan() + await waitUntil("scan started") { controller.snapshot.phase == .scanning } + engine.emit( + .finished( + makeSummary(candidates: [ + makeSizedCandidate(id: "low", path: "/cache/low"), + makeSizedCandidate(id: "medium", path: "/cache/medium", risk: .medium) + ]) + ) + ) + await waitUntil("scan finished") { controller.snapshot.phase == .scanned } + + controller.setCategorySelection(.appCaches, isSelected: false) + XCTAssertTrue(controller.snapshot.selection.isEmpty) + + controller.setCategorySelection(.appCaches, isSelected: true) + + XCTAssertEqual( + controller.snapshot.selection.selectedIDs, + ["low"], + "\"select all\" selects every low-risk item in the class; medium/high are never pulled in" + ) + XCTAssertEqual(controller.snapshot.selection.state(of: .appCaches), .partiallySelected) + } + + func testCleanSubmitsExactlyTheSelectedSet() async throws { + let engine = ControlledDiskCleanScanEngine() + let executor = FakeDiskCleanExecutor() + let controller = makeController(engine: engine, executor: executor) + + controller.scan() + await waitUntil("scan started") { controller.snapshot.phase == .scanning } + engine.emit( + .finished( + makeSummary(candidates: [ + makeSizedCandidate(id: "keep", path: "/cache/keep"), + makeSizedCandidate(id: "drop", path: "/cache/drop") + ]) + ) + ) + await waitUntil("scan finished") { controller.snapshot.phase == .scanned } + + controller.setCandidateSelected("drop", isSelected: false) + controller.clean() + await waitUntil("clean finished") { controller.snapshot.phase == .completed } + + XCTAssertEqual(executor.lastSelectedIDs, ["keep"], "menu bar and detail page share the same selection set") + } + + func testCleanIsDisabledWhenSelectionIsEmpty() async throws { + let engine = ControlledDiskCleanScanEngine() + let executor = FakeDiskCleanExecutor() + let controller = makeController(engine: engine, executor: executor) + + controller.scan() + await waitUntil("scan started") { controller.snapshot.phase == .scanning } + engine.emit(.finished(makeSummary(candidates: [makeSizedCandidate(id: "a", path: "/cache/a")]))) + await waitUntil("scan finished") { controller.snapshot.phase == .scanned } + XCTAssertTrue(controller.snapshot.canClean) + + controller.setCandidateSelected("a", isSelected: false) + + XCTAssertFalse(controller.snapshot.canClean, "with empty selection the button grays out; it does not fall back to \"clean all\"") + controller.clean() + XCTAssertEqual(executor.callCount, 0) + } + + func testChangingSelectionInvalidatesPendingPlan() async throws { + let executor = FakeDiskCleanExecutor() + let controller = try await makeScannedController(executor: executor, mode: .permanent) + controller.clean() + XCTAssertEqual(controller.snapshot.phase, .confirming) + + controller.setCandidateSelected("a", isSelected: false) + + XCTAssertEqual(controller.snapshot.phase, .scanned) + XCTAssertNil(controller.snapshot.pendingPlan) + controller.confirmPendingClean() + XCTAssertEqual(executor.callCount, 0, "once selection changes, the frozen plan no longer matches any user intent") + } + + func testCategorySelectionChangeInvalidatesPendingPlan() async throws { + let executor = FakeDiskCleanExecutor() + let controller = try await makeScannedController(executor: executor, mode: .permanent) + controller.clean() + XCTAssertEqual(controller.snapshot.phase, .confirming) + + controller.setCategorySelection(.appCaches, isSelected: false) + + XCTAssertEqual(controller.snapshot.phase, .scanned) + XCTAssertNil(controller.snapshot.pendingPlan) + XCTAssertEqual(executor.callCount, 0) + } + + func testRescanStartsFromDefaultSelection() async throws { + let engine = ControlledDiskCleanScanEngine() + let controller = makeController(engine: engine) + + controller.scan() + await waitUntil("scan started") { controller.snapshot.phase == .scanning } + engine.emit(.finished(makeSummary(candidates: [makeSizedCandidate(id: "a", path: "/cache/a")]))) + await waitUntil("scan finished") { controller.snapshot.phase == .scanned } + controller.setCandidateSelected("a", isSelected: false) + XCTAssertTrue(controller.snapshot.selection.isEmpty) + + controller.scan() + await waitUntil("second round started") { controller.snapshot.phase == .scanning } + engine.emit(.finished(makeSummary(candidates: [makeSizedCandidate(id: "a", path: "/cache/a")]))) + await waitUntil("second round finished") { controller.snapshot.phase == .scanned } + + XCTAssertEqual( + controller.snapshot.selection.selectedIDs, + ["a"], + "candidate IDs are stable across scans; previous unchecks must not silently reappear in new results" + ) + } + + // MARK: - Scope selection + + func testChangingScopeMarksResultStale() async throws { + let engine = ControlledDiskCleanScanEngine() + let controller = makeController(engine: engine) + + controller.scan() + await waitUntil("scan started") { controller.snapshot.phase == .scanning } + engine.emit(.finished(makeSummary(candidates: [makeSizedCandidate(id: "a", path: "/cache/a")]))) + await waitUntil("scan finished") { controller.snapshot.phase == .scanned } + + controller.setChoice(.browser, isSelected: false) + + XCTAssertTrue(controller.snapshot.isResultStale) + XCTAssertFalse(controller.snapshot.canClean) + XCTAssertEqual(controller.snapshot.subtitle, "清理范围已变化") + } + + func testScanForwardsSelectedChoicesToEngine() async { + let engine = ControlledDiskCleanScanEngine() + let controller = makeController(engine: engine) + + controller.setChoice(.developer, isSelected: false) + controller.scan() + await waitUntil("scan started") { controller.snapshot.phase == .scanning } + + XCTAssertEqual(engine.lastChoices, [.cache, .browser]) + } + + func testScanIsRejectedWhenNoScopeSelected() { + let engine = ControlledDiskCleanScanEngine() + let controller = makeController(engine: engine) + + for choice in DiskCleanChoice.allCases { + controller.setChoice(choice, isSelected: false) + } + controller.scan() + + XCTAssertEqual(engine.scanCallCount, 0) + XCTAssertEqual(controller.snapshot.phase, .idle) + } + + // MARK: - P2 section scope (design §10) + + /// One Controller type serves three sections; replace the whole scan scope — P2 needs no separate state machine. + func testScanForwardsDeveloperArtifactRootsToEngine() async { + let engine = ControlledDiskCleanScanEngine() + let controller = makeController(engine: engine) + + controller.setScope(.developerArtifacts(roots: ["/code"])) + controller.scan() + await waitUntil("scan started") { controller.snapshot.phase == .scanning } + + XCTAssertEqual(engine.lastScope, .developerArtifacts(roots: ["/code"])) + } + + /// Adding/removing scan roots is the same as changing groups: when results no longer match current scope, prompt for rescan. + func testChangingDeveloperArtifactRootsMarksResultStale() async throws { + let engine = ControlledDiskCleanScanEngine() + let controller = makeController(engine: engine) + controller.setScope(.developerArtifacts(roots: ["/code"])) + + controller.scan() + await waitUntil("scan started") { controller.snapshot.phase == .scanning } + engine.emit( + .finished( + makeSummary( + candidates: [makeSizedCandidate(id: "a", path: "/code/app/node_modules")], + scope: .developerArtifacts(roots: ["/code"]) + ) + ) + ) + await waitUntil("scan finished") { controller.snapshot.phase == .scanned } + XCTAssertTrue(controller.snapshot.canClean) + + controller.setScope(.developerArtifacts(roots: ["/code", "/work"])) + + XCTAssertTrue(controller.snapshot.isResultStale) + XCTAssertFalse(controller.snapshot.canClean) + } + + /// With no scan roots configured, scan entry must be disabled, or the user would click and nothing happens. + func testScanIsRejectedWhenDeveloperArtifactRootsAreEmpty() { + let engine = ControlledDiskCleanScanEngine() + let controller = makeController(engine: engine) + + controller.setScope(.developerArtifacts(roots: [])) + controller.scan() + + XCTAssertFalse(controller.snapshot.canScan) + XCTAssertEqual(engine.scanCallCount, 0) + } + + /// Installer section scope is fixed and always scannable. + func testInstallerScopeIsAlwaysScannable() { + let engine = ControlledDiskCleanScanEngine() + let controller = makeController(engine: engine) + + controller.setScope(.installers) + + XCTAssertTrue(controller.snapshot.canScan) + XCTAssertTrue(controller.snapshot.selectedChoices.isEmpty, "non-rules scopes have no group concept") + } + + /// Grouping only applies to the rules section. Sending `setChoice` to a P2 section would replace the whole section scope, + /// so it must be a no-op. + func testSetChoiceIsIgnoredOutsideRuleScope() { + let engine = ControlledDiskCleanScanEngine() + let controller = makeController(engine: engine) + controller.setScope(.installers) + + controller.setChoice(.cache, isSelected: false) + + XCTAssertEqual(controller.snapshot.scope, .installers) + } + + // MARK: - confirming(§8.4) + + func testTrashModeExecutesInOneStepWithoutConfirmation() async throws { + let executor = FakeDiskCleanExecutor() + let controller = try await makeScannedController(executor: executor, mode: .trash) + + controller.clean() + await waitUntil("clean finished") { controller.snapshot.phase == .completed } + + XCTAssertEqual(executor.callCount, 1, "Trash is recoverable, so execute in one step") + XCTAssertEqual(executor.lastMode, .trash) + } + + func testPermanentModeEntersConfirmingWithFrozenSummary() async throws { + let executor = FakeDiskCleanExecutor() + let controller = try await makeScannedController(executor: executor, mode: .permanent) + + controller.clean() + + XCTAssertEqual(controller.snapshot.phase, .confirming) + XCTAssertEqual(executor.callCount, 0, "not a single byte may be deleted before confirmation") + let pending = try XCTUnwrap(controller.snapshot.pendingPlan) + XCTAssertEqual(pending.itemCount, 1) + XCTAssertEqual(pending.totalEstimatedBytes, 1_024) + XCTAssertEqual(pending.mode, .permanent) + XCTAssertFalse(controller.snapshot.canScan, "no new scans accepted during confirmation") + } + + func testConfirmingExecutesTheFrozenPlan() async throws { + let executor = FakeDiskCleanExecutor() + let controller = try await makeScannedController(executor: executor, mode: .permanent) + controller.clean() + + controller.confirmPendingClean() + await waitUntil("clean finished") { controller.snapshot.phase == .completed } + + XCTAssertEqual(executor.callCount, 1) + XCTAssertEqual(executor.lastMode, .permanent) + XCTAssertEqual(executor.lastSelectedIDs, ["a"]) + XCTAssertNil(controller.snapshot.pendingPlan, "plan is no longer attached to the snapshot after execution") + } + + func testCancellingConfirmationDiscardsPlanAndReturnsToScanned() async throws { + let executor = FakeDiskCleanExecutor() + let controller = try await makeScannedController(executor: executor, mode: .permanent) + controller.clean() + + controller.cancelPendingClean() + + XCTAssertEqual(controller.snapshot.phase, .scanned) + XCTAssertNil(controller.snapshot.pendingPlan) + XCTAssertEqual(executor.callCount, 0) + + controller.confirmPendingClean() + XCTAssertEqual(executor.callCount, 0, "plan is voided; confirmation must not take effect again") + } + + func testChangingScopeInvalidatesPendingPlan() async throws { + let executor = FakeDiskCleanExecutor() + let controller = try await makeScannedController(executor: executor, mode: .permanent) + controller.clean() + + controller.setChoice(.browser, isSelected: false) + + XCTAssertEqual(controller.snapshot.phase, .scanned) + XCTAssertNil(controller.snapshot.pendingPlan) + controller.confirmPendingClean() + XCTAssertEqual(executor.callCount, 0) + } + + func testChangingRemovalModeInvalidatesPendingPlan() async throws { + let executor = FakeDiskCleanExecutor() + let controller = try await makeScannedController(executor: executor, mode: .permanent) + controller.clean() + + controller.setRemovalMode(.trash) + + XCTAssertEqual(controller.snapshot.phase, .scanned) + XCTAssertNil(controller.snapshot.pendingPlan) + XCTAssertEqual(controller.snapshot.removalMode, .trash) + controller.confirmPendingClean() + XCTAssertEqual(executor.callCount, 0, "removal mode is a frozen field; changing it requires reminting") + } + + func testConfirmationWindowExpiresAfterSixtySeconds() async throws { + let clock = TestDiskCleanClock(now: observedAt) + let executor = FakeDiskCleanExecutor() + let controller = try await makeScannedController(executor: executor, mode: .permanent, clock: clock) + controller.clean() + XCTAssertEqual(controller.snapshot.phase, .confirming) + + clock.advance(by: DiskCleanController.confirmationWindow) + + await waitUntil("confirmation window expired") { controller.snapshot.phase == .scanned } + XCTAssertNil(controller.snapshot.pendingPlan) + XCTAssertEqual(controller.snapshot.errorMessage, "确认已超时,请重新发起清理") + XCTAssertEqual(executor.callCount, 0) + } + + /// Confirmation window must not cross expiry: enter confirmation 10s before the gate, window is 10s not 60s. + func testConfirmationWindowNeverOutlivesTheExpiryGate() async throws { + let clock = TestDiskCleanClock(now: observedAt) + let executor = FakeDiskCleanExecutor() + let controller = try await makeScannedController(executor: executor, mode: .permanent, clock: clock) + clock.advance(by: DiskCleanScanFreshness.window - 10) + controller.clean() + XCTAssertEqual(controller.snapshot.phase, .confirming) + + clock.advance(by: 10) + + await waitUntil("voided when expiry gate fires") { controller.snapshot.phase == .scanned } + XCTAssertNil(controller.snapshot.pendingPlan) + XCTAssertEqual(executor.callCount, 0, "confirmation window must never push execution past the expiry gate") + } + + func testCancelCurrentOperationDiscardsPendingPlan() async throws { + let executor = FakeDiskCleanExecutor() + let controller = try await makeScannedController(executor: executor, mode: .permanent) + controller.clean() + + controller.cancelCurrentOperation() + + XCTAssertEqual(controller.snapshot.phase, .scanned) + XCTAssertNil(controller.snapshot.pendingPlan) + } + + // MARK: - Plan minting failure + + /// Minting failure = zero deletes. Report the reason as-is; do not degrade to "partial clean". + func testPlanMintingFailureSurfacesErrorAndSkipsExecution() async throws { + let engine = ControlledDiskCleanScanEngine() + let executor = FakeDiskCleanExecutor() + // Planned path covers a locked candidate → ancestor assertion rejects. + let parent = DiskCleanPlanFactory.candidate(path: "/cache/app", bytes: 1_024) + let lockedChild = DiskCleanPlanFactory.candidate( + path: "/cache/app/inner", + safety: .inUse(processName: "App") + ) + let controller = makeController(engine: engine, executor: executor) + controller.scan() + await waitUntil("scan started") { controller.snapshot.phase == .scanning } + engine.emit(.finished(makeSummary(candidates: [parent, lockedChild]))) + await waitUntil("scan finished") { controller.snapshot.phase == .scanned } + + controller.clean() + + XCTAssertEqual(executor.callCount, 0) + XCTAssertEqual(controller.snapshot.phase, .scanned) + XCTAssertEqual( + controller.snapshot.errorMessage, + "计划路径 /cache/app 覆盖受保护路径 /cache/app/inner" + ) + } + + func testPreflightFailureFromExecutorReturnsToScannedWithMessage() async throws { + let executor = FakeDiskCleanExecutor(failure: DiskCleanExecutionError.planExpired) + let controller = try await makeScannedController(executor: executor, mode: .trash) + + controller.clean() + await waitUntil("execution failure published") { controller.snapshot.errorMessage != nil } + + XCTAssertEqual(controller.snapshot.phase, .scanned) + XCTAssertEqual(controller.snapshot.errorMessage, "扫描结果已过期,请重新扫描") + } + + func testRemovalModeIsLoadedFromStoreAndPersistedOnChange() { + let store = InMemoryDiskCleanRemovalModeStore(mode: .permanent) + let controller = DiskCleanController( + engine: ControlledDiskCleanScanEngine(), + executor: FakeDiskCleanExecutor(), + clock: TestDiskCleanClock(), + removalModeStore: store + ) + + XCTAssertEqual(controller.snapshot.removalMode, .permanent, "load previous choice at startup") + + controller.setRemovalMode(.trash) + + XCTAssertEqual(store.savedMode, .trash) + XCTAssertEqual(controller.snapshot.removalMode, .trash) + } + + // MARK: - Fixtures + + private func makeController( + engine: any DiskCleanScanning, + executor: any DiskCleanExecuting = FakeDiskCleanExecutor(), + clock: any DiskCleanClock = TestDiskCleanClock(), + mode: DiskCleanRemovalMode = .trash + ) -> DiskCleanController { + DiskCleanController( + engine: engine, + executor: executor, + clock: clock, + removalModeStore: InMemoryDiskCleanRemovalModeStore(mode: mode), + catalog: DiskCleanPlanFactory.catalog() + ) + } + + /// Controller after one finished scan with a single cleanable candidate (1024 bytes). + private func makeScannedController( + executor: any DiskCleanExecuting, + mode: DiskCleanRemovalMode, + clock: any DiskCleanClock = TestDiskCleanClock() + ) async throws -> DiskCleanController { + let engine = ControlledDiskCleanScanEngine() + let controller = makeController(engine: engine, executor: executor, clock: clock, mode: mode) + controller.scan() + await waitUntil("scan started") { controller.snapshot.phase == .scanning } + engine.emit(.finished(makeSummary(candidates: [makeSizedCandidate(id: "a", path: "/cache/a")]))) + await waitUntil("scan finished") { controller.snapshot.phase == .scanned } + return controller + } + + private func makeCandidate( + id: String, + path: String, + category: DiskCleanCategoryID = .appCaches, + risk: DiskCleanRisk = .low, + safety: DiskCleanSafetyStatus = .allowed + ) -> DiskCleanCandidate { + DiskCleanCandidate( + id: id, + targetID: DiskCleanPlanFactory.targetID, + legacyRuleID: DiskCleanPlanFactory.targetID, + category: category, + path: path, + risk: risk, + safety: safety + ) + } + + private func makeSizedCandidate( + id: String, + path: String, + bytes: Int64 = 1_024, + risk: DiskCleanRisk = .low, + observedAt: Date? = nil + ) -> DiskCleanCandidate { + makeCandidate(id: id, path: path, risk: risk) + .applying(.testComplete(bytes: bytes, observedAt: observedAt ?? self.observedAt)) + } + + private func makeSummary( + candidates: [DiskCleanCandidate], + limitations: [DiskCleanScanLimitation] = [], + scope: DiskCleanScanScope = .rules(choices: Set(DiskCleanChoice.allCases)) + ) -> DiskCleanScanSummary { + DiskCleanScanSummary( + artifact: DiskCleanScanArtifact( + scope: scope, + candidates: candidates, + reservedRootPaths: [], + limitations: limitations, + startedAt: observedAt, + finishedAt: observedAt + ) + ) + } +} diff --git a/Plugins/DiskClean/Tests/DiskCleanDirectoryTreeWalkerTests.swift b/Plugins/DiskClean/Tests/DiskCleanDirectoryTreeWalkerTests.swift new file mode 100644 index 00000000..3e9835ef --- /dev/null +++ b/Plugins/DiskClean/Tests/DiskCleanDirectoryTreeWalkerTests.swift @@ -0,0 +1,418 @@ +import Darwin +import XCTest +@testable import MacTools +@testable import DiskCleanPlugin + +/// Tests walk-skeleton constraints via injected fake entry streams. +/// +/// Mount crossings cannot be built on a real FS (the test process cannot mount), so design §11 requires +/// "inject devid fakes to prove no descent across mounts" — this file implements that requirement. +final class DiskCleanDirectoryTreeWalkerTests: XCTestCase { + private var temporaryDirectory: DiskCleanTempDirectory! + + override func setUpWithError() throws { + try super.setUpWithError() + temporaryDirectory = try DiskCleanTempDirectory(name: "DiskCleanDirectoryTreeWalkerTests") + } + + override func tearDownWithError() throws { + temporaryDirectory?.remove() + temporaryDirectory = nil + try super.tearDownWithError() + } + + // MARK: - Mount protection + + /// Child directory devid differs from root (= another volume mounted there) → no descent, no count, add crossedMountPoint. + func testDoesNotDescendIntoDirectoryOnAnotherDevice() throws { + let root = try temporaryDirectory.makeDirectory("Root") + try temporaryDirectory.makeFile("Root/Mounted/should-not-be-counted.bin", bytes: 5000) + let rootDevice = try deviceID(of: root.path) + + let factory = ScriptedSourceFactory(scripts: [ + [[ + .resolved(entry(name: "Mounted", type: .directory, devid: rootDevice + 1, fileID: 77)) + ]] + ]) + let walker = DiskCleanDirectoryTreeWalker(sourceFactory: factory) + + let result = walker.size(ofItemAt: root.path, context: .test()) + + XCTAssertEqual(result.completeness, .partial(reasons: [.crossedMountPoint])) + XCTAssertEqual(result.estimatedBytes, 0) + XCTAssertEqual(result.fileCount, 0) + XCTAssertEqual(factory.createdSourceCount, 1, "must not openat-descend into a cross-device directory") + } + + /// Cross-device regular files are likewise not counted. + func testDoesNotCountFileOnAnotherDevice() throws { + let root = try temporaryDirectory.makeDirectory("Root") + let rootDevice = try deviceID(of: root.path) + + let factory = ScriptedSourceFactory(scripts: [ + [[ + .resolved(entry(name: "local.bin", type: .regularFile, devid: rootDevice, fileID: 1, dataLength: 100)), + .resolved(entry(name: "foreign.bin", type: .regularFile, devid: rootDevice + 1, fileID: 2, dataLength: 900)) + ]] + ]) + + let result = DiskCleanDirectoryTreeWalker(sourceFactory: factory) + .size(ofItemAt: root.path, context: .test()) + + XCTAssertEqual(result.completeness, .partial(reasons: [.crossedMountPoint])) + XCTAssertEqual(result.estimatedBytes, 100) + XCTAssertEqual(result.fileCount, 1) + } + + // MARK: - Entry-level failure mapping + + func testUnresolvedEntryWithPermissionErrorReportsPermissionDenied() throws { + let root = try temporaryDirectory.makeDirectory("Root") + let factory = ScriptedSourceFactory(scripts: [[[.unresolved(code: EACCES)]]]) + + let result = DiskCleanDirectoryTreeWalker(sourceFactory: factory) + .size(ofItemAt: root.path, context: .test()) + + XCTAssertEqual(result.completeness, .partial(reasons: [.permissionDenied])) + } + + func testUnresolvedEntryWithOtherErrorReportsWalkError() throws { + let root = try temporaryDirectory.makeDirectory("Root") + let factory = ScriptedSourceFactory(scripts: [[[.unresolved(code: EIO)]]]) + + let result = DiskCleanDirectoryTreeWalker(sourceFactory: factory) + .size(ofItemAt: root.path, context: .test()) + + XCTAssertEqual(result.completeness, .partial(reasons: [.walkError])) + } + + func testThrowingSourceMapsErrnoAndKeepsPartialTotals() throws { + let root = try temporaryDirectory.makeDirectory("Root") + let rootDevice = try deviceID(of: root.path) + let factory = ScriptedSourceFactory( + scripts: [[[ + .resolved(entry(name: "counted.bin", type: .regularFile, devid: rootDevice, fileID: 1, dataLength: 42)) + ]]], + throwAfterScriptedBatches: DiskCleanPOSIXError(code: EACCES) + ) + + let result = DiskCleanDirectoryTreeWalker(sourceFactory: factory) + .size(ofItemAt: root.path, context: .test()) + + XCTAssertEqual(result.completeness, .partial(reasons: [.permissionDenied])) + XCTAssertEqual(result.estimatedBytes, 42, "bytes already accumulated before failure must be kept as-is") + } + + /// Multiple degradation reasons must coexist and must not overwrite each other. + func testAccumulatesMultiplePartialReasons() throws { + let root = try temporaryDirectory.makeDirectory("Root") + let rootDevice = try deviceID(of: root.path) + let factory = ScriptedSourceFactory(scripts: [[[ + .unresolved(code: EACCES), + .unresolved(code: EIO), + .resolved(entry(name: "foreign", type: .directory, devid: rootDevice + 1, fileID: 9)) + ]]]) + + let result = DiskCleanDirectoryTreeWalker(sourceFactory: factory) + .size(ofItemAt: root.path, context: .test()) + + XCTAssertEqual( + result.completeness, + .partial(reasons: [.permissionDenied, .walkError, .crossedMountPoint]) + ) + } + + // MARK: - Hard-link dedupe + + func testDeduplicatesHardLinksByDeviceAndFileID() throws { + let root = try temporaryDirectory.makeDirectory("Root") + let rootDevice = try deviceID(of: root.path) + let factory = ScriptedSourceFactory(scripts: [[[ + .resolved(entry(name: "one", type: .regularFile, devid: rootDevice, fileID: 500, linkCount: 3, dataLength: 800)), + .resolved(entry(name: "two", type: .regularFile, devid: rootDevice, fileID: 500, linkCount: 3, dataLength: 800)), + .resolved(entry(name: "three", type: .regularFile, devid: rootDevice, fileID: 500, linkCount: 3, dataLength: 800)) + ]]]) + + let result = DiskCleanDirectoryTreeWalker(sourceFactory: factory) + .size(ofItemAt: root.path, context: .test()) + + XCTAssertEqual(result.completeness, .complete) + XCTAssertEqual(result.estimatedBytes, 800) + XCTAssertEqual(result.fileCount, 1) + } + + /// Files with linkCount == 1 do not participate in dedupe: even identical fileIDs count separately + /// (the dedupe key is only meaningful when linkCount > 1). + func testDoesNotDeduplicateWhenLinkCountIsOne() throws { + let root = try temporaryDirectory.makeDirectory("Root") + let rootDevice = try deviceID(of: root.path) + let factory = ScriptedSourceFactory(scripts: [[[ + .resolved(entry(name: "one", type: .regularFile, devid: rootDevice, fileID: 42, linkCount: 1, dataLength: 10)), + .resolved(entry(name: "two", type: .regularFile, devid: rootDevice, fileID: 42, linkCount: 1, dataLength: 10)) + ]]]) + + let result = DiskCleanDirectoryTreeWalker(sourceFactory: factory) + .size(ofItemAt: root.path, context: .test()) + + XCTAssertEqual(result.estimatedBytes, 20) + XCTAssertEqual(result.fileCount, 2) + } + + // MARK: - Deadline checked each batch + + /// deadline must apply **between batches**: first batch counted, second batch must not be read. + func testChecksDeadlineBetweenBatches() throws { + let root = try temporaryDirectory.makeDirectory("Root") + let rootDevice = try deviceID(of: root.path) + let factory = ScriptedSourceFactory(scripts: [[ + [.resolved(entry(name: "first.bin", type: .regularFile, devid: rootDevice, fileID: 1, dataLength: 11))], + [.resolved(entry(name: "second.bin", type: .regularFile, devid: rootDevice, fileID: 2, dataLength: 22))] + ]]) + + let start = Date(timeIntervalSince1970: 1_700_000_000) + let clock = SteppingClock(start: start, step: 1) + let context = DiskCleanSizingContext( + deadline: start.addingTimeInterval(1.5), + now: { clock.next() } + ) + + let result = DiskCleanDirectoryTreeWalker(sourceFactory: factory).size( + ofItemAt: root.path, + context: context + ) + + XCTAssertEqual(result.completeness, .partial(reasons: [.timedOut])) + XCTAssertEqual(result.estimatedBytes, 11, "only the batch read before deadline should count") + XCTAssertEqual(result.observedAt, start) + } + + // MARK: - Descent and fd lifecycle + + /// Same-device directory entries must truly openat-descend and create a new source for the child. + func testDescendsIntoSameDeviceDirectory() throws { + let root = try temporaryDirectory.makeDirectory("Root") + try temporaryDirectory.makeDirectory("Root/Child") + let rootDevice = try deviceID(of: root.path) + + let factory = ScriptedSourceFactory(scripts: [ + [[.resolved(entry(name: "Child", type: .directory, devid: rootDevice, fileID: 10))]], + [[.resolved(entry(name: "inner.bin", type: .regularFile, devid: rootDevice, fileID: 11, dataLength: 64))]] + ]) + + let result = DiskCleanDirectoryTreeWalker(sourceFactory: factory) + .size(ofItemAt: root.path, context: .test()) + + XCTAssertEqual(result.completeness, .complete) + XCTAssertEqual(result.estimatedBytes, 64) + XCTAssertEqual(factory.createdSourceCount, 2, "one source each for root and child directory") + XCTAssertTrue(factory.allSourcesClosed, "all sources must be closed; no fd leaks") + } + + /// Descent target already gone (deleted mid-scan) → record walkError, do not crash. + func testMissingChildDirectoryIsReportedAsWalkError() throws { + let root = try temporaryDirectory.makeDirectory("Root") + let rootDevice = try deviceID(of: root.path) + let factory = ScriptedSourceFactory(scripts: [ + [[.resolved(entry(name: "vanished", type: .directory, devid: rootDevice, fileID: 12))]] + ]) + + let result = DiskCleanDirectoryTreeWalker(sourceFactory: factory) + .size(ofItemAt: root.path, context: .test()) + + XCTAssertEqual(result.completeness, .partial(reasons: [.walkError])) + XCTAssertEqual(factory.createdSourceCount, 1) + } + + /// Concurrently open directories must be O(tree depth), not O(children of one directory). + /// + /// If the implementation openat-stacks every child while processing one batch, a wide directory + /// (tens of thousands of children) hits EMFILE. A real FS cannot test this — RLIMIT_NOFILE is millions — + /// so the source-factory seam observes "concurrent live source count". + func testKeepsOpenDirectoryCountBoundedByDepth() throws { + let root = try temporaryDirectory.makeDirectory("Root") + let rootDevice = try deviceID(of: root.path) + + let siblingCount = 20 + var rootBatch: [DiskCleanWalkEntry] = [] + for index in 0.. UInt64 { + var status = stat() + XCTAssertEqual(lstat(path, &status), 0) + return UInt64(UInt32(bitPattern: status.st_dev)) + } + + private func entry( + name: String, + type: DiskCleanRootIdentity.FileType, + devid: UInt64, + fileID: UInt64, + linkCount: UInt32 = 1, + dataLength: Int64 = 0 + ) -> DiskCleanResolvedEntry { + DiskCleanResolvedEntry( + nameBytes: Array(name.utf8).map { CChar(bitPattern: $0) } + [0], + fileType: type, + devid: devid, + fileID: fileID, + linkCount: linkCount, + dataLength: dataLength + ) + } +} + +/// Clock that advances a fixed step on each call. +private final class SteppingClock: @unchecked Sendable { + private let start: Date + private let step: TimeInterval + private let lock = NSLock() + private var callCount = 0 + + init(start: Date, step: TimeInterval) { + self.start = start + self.step = step + } + + func next() -> Date { + lock.lock() + defer { lock.unlock() } + let value = start.addingTimeInterval(step * Double(callCount)) + callCount += 1 + return value + } +} + +/// Fake source factory that replays entry batches from scripts. The n-th created source uses scripts[n]. +private final class ScriptedSourceFactory: DiskCleanDirectoryEntrySourceFactory, @unchecked Sendable { + private let scripts: [[[DiskCleanWalkEntry]]] + private let throwAfterScriptedBatches: DiskCleanPOSIXError? + private let lock = NSLock() + private var sources: [ScriptedSource] = [] + private var peakOpenCount = 0 + + init( + scripts: [[[DiskCleanWalkEntry]]], + throwAfterScriptedBatches: DiskCleanPOSIXError? = nil + ) { + self.scripts = scripts + self.throwAfterScriptedBatches = throwAfterScriptedBatches + } + + var createdSourceCount: Int { + lock.lock() + defer { lock.unlock() } + return sources.count + } + + var allSourcesClosed: Bool { + lock.lock() + defer { lock.unlock() } + return sources.allSatisfy(\.isClosed) + } + + /// Peak concurrent sources in the "created and not yet closed" state. + var peakConcurrentlyOpenSourceCount: Int { + lock.lock() + defer { lock.unlock() } + return peakOpenCount + } + + func makeSource(fileDescriptor: Int32) throws -> any DiskCleanDirectoryEntrySource { + lock.lock() + let index = sources.count + lock.unlock() + + let source = ScriptedSource( + fileDescriptor: fileDescriptor, + batches: index < scripts.count ? scripts[index] : [], + errorAfterBatches: throwAfterScriptedBatches + ) + lock.lock() + sources.append(source) + peakOpenCount = max(peakOpenCount, sources.filter { !$0.isClosed }.count) + lock.unlock() + return source + } +} + +private final class ScriptedSource: DiskCleanDirectoryEntrySource { + let directoryFileDescriptor: Int32 + private var batches: [[DiskCleanWalkEntry]] + private let errorAfterBatches: DiskCleanPOSIXError? + private(set) var isClosed = false + + init(fileDescriptor: Int32, batches: [[DiskCleanWalkEntry]], errorAfterBatches: DiskCleanPOSIXError?) { + self.directoryFileDescriptor = fileDescriptor + self.batches = batches + self.errorAfterBatches = errorAfterBatches + } + + func nextBatch() throws -> [DiskCleanWalkEntry]? { + guard !batches.isEmpty else { + if let errorAfterBatches { + throw errorAfterBatches + } + return nil + } + return batches.removeFirst() + } + + func close() { + guard !isClosed else { return } + isClosed = true + // Honor the protocol: source owns the fd. + Darwin.close(directoryFileDescriptor) + } +} + +private struct FailingSourceFactory: DiskCleanDirectoryEntrySourceFactory { + func makeSource(fileDescriptor: Int32) throws -> any DiskCleanDirectoryEntrySource { + throw DiskCleanPOSIXError(code: EIO) + } +} diff --git a/Plugins/DiskClean/Tests/DiskCleanDynamicRulesTests.swift b/Plugins/DiskClean/Tests/DiskCleanDynamicRulesTests.swift new file mode 100644 index 00000000..8a74f0a0 --- /dev/null +++ b/Plugins/DiskClean/Tests/DiskCleanDynamicRulesTests.swift @@ -0,0 +1,354 @@ +import XCTest +@testable import MacTools +@testable import DiskCleanPlugin + +/// Dynamic rule provider tests (design §5.5, §11 "dynamic rules" row). +/// Always use temporary directories; never touch real user directories. +final class DiskCleanDynamicRulesTests: XCTestCase { + private var root: URL! + + /// **Fixture roots always use `realpath(3)` physical paths**: `NSTemporaryDirectory()` lives under `/var/folders/...`, + /// and `/var` is a symlink to `private/var`. `resolvingSymlinksInPath()` does not fix this + /// (it tends to strip `/private` rather than add it); only realpath yields stable comparable paths. + /// Reuse `DiskCleanTempDirectory.physicalPath(of:)` so two realpath implementations cannot drift. + override func setUpWithError() throws { + try super.setUpWithError() + let created = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent("DiskCleanDynamicRulesTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: created, withIntermediateDirectories: true) + root = URL(fileURLWithPath: DiskCleanTempDirectory.physicalPath(of: created.path), isDirectory: true) + } + + override func tearDownWithError() throws { + if let root, FileManager.default.fileExists(atPath: root.path) { + try FileManager.default.removeItem(at: root) + } + root = nil + try super.tearDownWithError() + } + + // MARK: - Version parsing + + func testVersionNumberAcceptsNumericDottedNamesOnly() { + XCTAssertEqual(DiskCleanVersionNumber("152.0.7933.0")?.components, [152, 0, 7933, 0]) + XCTAssertEqual(DiskCleanVersionNumber("2.1.215")?.components, [2, 1, 215]) + XCTAssertEqual(DiskCleanVersionNumber("223")?.components, [223]) + XCTAssertEqual(DiskCleanVersionNumber("1.0.0-beta.2")?.components, [1, 0, 0, 2]) + + XCTAssertNil(DiskCleanVersionNumber("Current")) + XCTAssertNil(DiskCleanVersionNumber("ch-0")) + XCTAssertNil(DiskCleanVersionNumber("crx_cache")) + XCTAssertNil(DiskCleanVersionNumber("prefs.json")) + XCTAssertNil(DiskCleanVersionNumber("updater.log.old")) + XCTAssertNil(DiskCleanVersionNumber("")) + } + + func testVersionNumberComparesNumericallyNotLexically() throws { + XCTAssertLessThan( + try XCTUnwrap(DiskCleanVersionNumber("2.1.9")), + try XCTUnwrap(DiskCleanVersionNumber("2.1.10")) + ) + XCTAssertLessThan( + try XCTUnwrap(DiskCleanVersionNumber("2.1")), + try XCTUnwrap(DiskCleanVersionNumber("2.1.1")) + ) + } + + // MARK: - Version directory provider + + func testVersionProviderKeepsNewestAndReturnsOlderVersions() async throws { + let container = try makeDirectory("versions") + try makeDirectories(["2.1.9", "2.1.10", "2.1.215", "2.2.0"], in: container) + + let items = try await expand(containerGlobs: [container.path]) + + XCTAssertEqual(names(of: items), ["2.1.215", "2.1.10", "2.1.9"]) + // Candidate paths must stay under the container physical path: full-path compare is where realpath normalization matters. + for item in items { + XCTAssertTrue(item.path.hasPrefix(container.path + "/"), "candidate escaped container: \(item.path)") + } + } + + func testVersionProviderHonoursKeepNewestCount() async throws { + let container = try makeDirectory("versions") + try makeDirectories(["1.0.0", "1.1.0", "1.2.0", "1.3.0"], in: container) + + let items = try await expand(containerGlobs: [container.path], keepNewestCount: 2) + + XCTAssertEqual(names(of: items), ["1.1.0", "1.0.0"]) + } + + func testVersionProviderReturnsNothingWhenOnlyKeptVersionsExist() async throws { + let container = try makeDirectory("versions") + try makeDirectories(["3.0.0"], in: container) + + let items = try await expand(containerGlobs: [container.path]) + + XCTAssertTrue(items.isEmpty) + } + + func testVersionProviderIgnoresNonVersionEntriesAndFiles() async throws { + let container = try makeDirectory("GoogleUpdater") + try makeDirectories(["149.0.7814.0", "152.0.7933.0", "crx_cache"], in: container) + try Data("{}".utf8).write(to: container.appendingPathComponent("prefs.json")) + try Data("log".utf8).write(to: container.appendingPathComponent("updater.log")) + + let items = try await expand(containerGlobs: [container.path]) + + XCTAssertEqual(names(of: items), ["149.0.7814.0"]) + } + + /// The version pointed to by `Current -> ` is in use and must not be a candidate even if it is not the newest. + func testVersionProviderNeverReturnsVersionPinnedBySiblingSymlink() async throws { + let container = try makeDirectory("GoogleUpdater") + try makeDirectories(["149.0.7814.0", "150.0.7863.0", "151.0.7910.0"], in: container) + try FileManager.default.createSymbolicLink( + atPath: container.appendingPathComponent("Current").path, + withDestinationPath: "149.0.7814.0" + ) + + let items = try await expand(containerGlobs: [container.path]) + + XCTAssertEqual(names(of: items), ["150.0.7863.0"]) + } + + /// Symlinks whose names look like versions are never candidates — deleting the link frees no space and may break callers. + func testVersionProviderIgnoresSymlinkedVersionDirectories() async throws { + let container = try makeDirectory("versions") + try makeDirectories(["1.0.0", "1.1.0", "1.2.0", "shared"], in: container) + try FileManager.default.createSymbolicLink( + atPath: container.appendingPathComponent("0.9.0").path, + withDestinationPath: "shared" + ) + + let items = try await expand(containerGlobs: [container.path]) + + XCTAssertEqual(names(of: items), ["1.1.0", "1.0.0"]) + } + + func testVersionProviderExpandsWildcardContainerGlobs() async throws { + let apps = try makeDirectory("apps") + for ide in ["IntelliJ", "GoLand"] { + let channel = apps.appendingPathComponent("\(ide)/ch-0", isDirectory: true) + try FileManager.default.createDirectory(at: channel, withIntermediateDirectories: true) + try makeDirectories(["223.8836.35", "241.14494.240"], in: channel) + } + + let items = try await expand(containerGlobs: [apps.path + "/*/ch-*"]) + + XCTAssertEqual(Set(names(of: items)), ["223.8836.35"]) + XCTAssertEqual(items.count, 2) + } + + func testVersionProviderReturnsEmptyForMissingContainer() async throws { + let items = try await expand(containerGlobs: [root.appendingPathComponent("absent").path]) + + XCTAssertTrue(items.isEmpty) + } + + // MARK: - Unavailable simulator provider + + func testUnavailableSimulatorProviderReturnsOnlyUnavailableExistingDeviceDirectories() async throws { + let devices = try makeDirectory("Devices") + let unavailable = "3A6B1C2D-0000-4000-8000-000000000001" + let available = "3A6B1C2D-0000-4000-8000-000000000002" + // Third device is unavailable in JSON, and its directory was already deleted by the user; it must not appear as a candidate. + let missing = "3A6B1C2D-0000-4000-8000-000000000003" + try makeDirectories([unavailable, available], in: devices) + + let json = """ + {"devices":{"com.apple.CoreSimulator.SimRuntime.iOS-17-0":[ + {"udid":"\(unavailable)","isAvailable":false,"name":"iPhone 15"}, + {"udid":"\(available)","isAvailable":true,"name":"iPhone 16"}, + {"udid":"\(missing)","isAvailable":false,"name":"deleted"} + ]}} + """ + + let items = try await expandSimulators(devicesRoot: devices, outcome: .success(Data(json.utf8))) + + XCTAssertEqual(names(of: items), [unavailable]) + } + + func testUnavailableSimulatorProviderRejectsNonUUIDIdentifiers() async throws { + let devices = try makeDirectory("Devices") + try makeDirectories(["passwd"], in: devices) + + let json = """ + {"devices":{"runtime":[{"udid":"../passwd","isAvailable":false}]}} + """ + + let items = try await expandSimulators(devicesRoot: devices, outcome: .success(Data(json.utf8))) + + XCTAssertTrue(items.isEmpty) + } + + func testUnavailableSimulatorProviderThrowsOnMalformedOutput() async throws { + let devices = try makeDirectory("Devices") + + for payload in ["not json at all", "{}", #"{"devices":42}"#] { + do { + _ = try await expandSimulators(devicesRoot: devices, outcome: .success(Data(payload.utf8))) + XCTFail("malformed output should throw: \(payload)") + } catch let error as DiskCleanDynamicRuleError { + XCTAssertEqual(error, .malformedOutput(command: "simctl list devices -j")) + } + } + } + + func testUnavailableSimulatorProviderPropagatesTimeout() async throws { + let devices = try makeDirectory("Devices") + + do { + _ = try await expandSimulators( + devicesRoot: devices, + outcome: .failure(.timedOut(path: "/usr/bin/xcrun")) + ) + XCTFail("timeout should throw") + } catch let error as DiskCleanSubprocessError { + XCTAssertEqual(error, .timedOut(path: "/usr/bin/xcrun")) + } + } + + func testUnavailableSimulatorProviderReturnsEmptyWhenSimctlUnavailable() async throws { + let devices = try makeDirectory("Devices") + + let missingExecutable = try await expandSimulators( + devicesRoot: devices, + outcome: .failure(.executableUnavailable(path: "/usr/bin/xcrun")) + ) + XCTAssertTrue(missingExecutable.isEmpty) + + let nonZeroExit = try await expandSimulators( + devicesRoot: devices, + outcome: .exit(code: 72, output: Data()) + ) + XCTAssertTrue(nonZeroExit.isEmpty) + } + + // MARK: - Real subprocess executor + + func testLocalSubprocessRunnerCapturesStandardOutput() async throws { + let result = try await LocalDiskCleanSubprocessRunner().run( + executablePath: "/bin/echo", + arguments: ["diskclean"], + timeout: .seconds(5) + ) + + XCTAssertEqual(result.exitCode, 0) + XCTAssertEqual(String(data: result.standardOutput, encoding: .utf8), "diskclean\n") + } + + func testLocalSubprocessRunnerReportsMissingExecutable() async throws { + do { + _ = try await LocalDiskCleanSubprocessRunner().run( + executablePath: root.appendingPathComponent("nope").path, + arguments: [], + timeout: .seconds(1) + ) + XCTFail("missing executable should throw") + } catch let error as DiskCleanSubprocessError { + guard case .executableUnavailable = error else { + return XCTFail("unexpected error type: \(error)") + } + } + } + + func testLocalSubprocessRunnerTimesOutAndTerminatesChild() async throws { + let started = Date() + do { + _ = try await LocalDiskCleanSubprocessRunner().run( + executablePath: "/bin/sleep", + arguments: ["30"], + timeout: .milliseconds(300) + ) + XCTFail("timeout should throw") + } catch let error as DiskCleanSubprocessError { + guard case .timedOut = error else { + return XCTFail("unexpected error type: \(error)") + } + } + XCTAssertLessThan(Date().timeIntervalSince(started), 5) + } + + /// Output larger than the pipe buffer (64KB) must not deadlock: read and wait-for-exit must run in parallel. + func testLocalSubprocessRunnerHandlesOutputLargerThanPipeBuffer() async throws { + let result = try await LocalDiskCleanSubprocessRunner().run( + executablePath: "/usr/bin/head", + arguments: ["-c", "400000", "/dev/zero"], + timeout: .seconds(10) + ) + + XCTAssertEqual(result.exitCode, 0) + XCTAssertEqual(result.standardOutput.count, 400_000) + } + + // MARK: - Helpers + + private struct FakeSubprocessRunner: DiskCleanSubprocessRunning { + enum Outcome: Sendable { + case success(Data) + case exit(code: Int32, output: Data) + case failure(DiskCleanSubprocessError) + } + + let outcome: Outcome + + func run( + executablePath: String, + arguments: [String], + timeout: Duration + ) async throws -> DiskCleanSubprocessResult { + switch outcome { + case let .success(output): + return DiskCleanSubprocessResult(exitCode: 0, standardOutput: output) + case let .exit(code, output): + return DiskCleanSubprocessResult(exitCode: code, standardOutput: output) + case let .failure(error): + throw error + } + } + } + + private func expand( + containerGlobs: [String], + keepNewestCount: Int = 1 + ) async throws -> [DiskCleanFileItem] { + let provider = DiskCleanVersionDirectoryRuleProvider( + containerGlobs: containerGlobs, + keepNewestCount: keepNewestCount + ) + return try await provider.expand() + } + + private func expandSimulators( + devicesRoot: URL, + outcome: FakeSubprocessRunner.Outcome + ) async throws -> [DiskCleanFileItem] { + let provider = DiskCleanUnavailableSimulatorRuleProvider( + devicesRootPath: devicesRoot.path, + executablePath: "/usr/bin/xcrun", + timeout: .seconds(2), + subprocessRunner: FakeSubprocessRunner(outcome: outcome) + ) + return try await provider.expand() + } + + private func makeDirectory(_ name: String) throws -> URL { + let url = root.appendingPathComponent(name, isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + private func makeDirectories(_ names: [String], in parent: URL) throws { + for name in names where !name.isEmpty { + try FileManager.default.createDirectory( + at: parent.appendingPathComponent(name, isDirectory: true), + withIntermediateDirectories: true + ) + } + } + + private func names(of items: [DiskCleanFileItem]) -> [String] { + items.map { ($0.path as NSString).lastPathComponent } + } +} diff --git a/Plugins/DiskClean/Tests/DiskCleanExecutionTestSupport.swift b/Plugins/DiskClean/Tests/DiskCleanExecutionTestSupport.swift new file mode 100644 index 00000000..169cdd50 --- /dev/null +++ b/Plugins/DiskClean/Tests/DiskCleanExecutionTestSupport.swift @@ -0,0 +1,297 @@ +import Darwin +import Foundation +import XCTest +@testable import MacTools +@testable import DiskCleanPlugin + +// MARK: - Plan minting + +/// Plans have a single minting point (`ValidatedPlan.init` is fileprivate); tests are no exception — +/// this uses the same product `makePlan`, so fixtures themselves re-check minting validation. +@MainActor +enum DiskCleanPlanFactory { + static let observedAt = Date(timeIntervalSince1970: 10_000) + static let targetID = "test.target" + + struct Item { + let path: String + let identity: DiskCleanRootIdentity + let bytes: Int64 + + init(path: String, identity: DiskCleanRootIdentity, bytes: Int64 = 0) { + self.path = path + self.identity = identity + self.bytes = bytes + } + } + + static func catalog( + lockedByBundleIDs: [String] = [], + skipWhenProcessIsRunning: [String] = [] + ) -> DiskCleanRuleCatalogV2 { + DiskCleanRuleCatalogV2(targets: [ + .test( + id: targetID, + reservedRootPaths: [], + lockedByBundleIDs: lockedByBundleIDs, + skipWhenProcessIsRunning: skipWhenProcessIsRunning + ) + ]) + } + + static func candidate( + path: String, + identity: DiskCleanRootIdentity? = nil, + bytes: Int64 = 0, + observedAt: Date = DiskCleanPlanFactory.observedAt, + safety: DiskCleanSafetyStatus = .allowed, + sizeResult: DiskCleanSizeResult? = nil + ) -> DiskCleanCandidate { + DiskCleanCandidate( + id: DiskCleanCandidate.makeID(targetID: targetID, path: path), + targetID: targetID, + legacyRuleID: targetID, + category: .appCaches, + path: path, + risk: .low, + safety: safety, + sizeResult: sizeResult ?? .testComplete( + bytes: bytes, + identity: identity ?? .test(), + observedAt: observedAt + ) + ) + } + + static func artifact( + candidates: [DiskCleanCandidate], + reservedRootPaths: [String] = [] + ) -> DiskCleanScanArtifact { + DiskCleanScanArtifact( + scope: .rules(choices: Set(DiskCleanChoice.allCases)), + candidates: candidates, + reservedRootPaths: reservedRootPaths, + limitations: [], + startedAt: observedAt, + finishedAt: observedAt + ) + } + + /// Mint a plan from real filesystem objects: identity comes from current `lstat`, matching the scanner. + static func makePlan( + paths: [String], + mode: DiskCleanRemovalMode = .permanent, + lockedByBundleIDs: [String] = [], + skipWhenProcessIsRunning: [String] = [], + now: Date = DiskCleanPlanFactory.observedAt + ) throws -> DiskCleanValidatedPlan { + let candidates = try paths.map { path -> DiskCleanCandidate in + let identity = try XCTUnwrap( + currentIdentity(ofItemAt: path), + "could not read identity of \(path)" + ) + return candidate( + path: path, + identity: identity, + bytes: currentSize(ofItemAt: path) + ) + } + return try DiskCleanPlanner.makePlan( + artifact: artifact(candidates: candidates), + selectedIDs: Set(candidates.map(\.id)), + mode: mode, + now: now, + catalog: catalog( + lockedByBundleIDs: lockedByBundleIDs, + skipWhenProcessIsRunning: skipWhenProcessIsRunning + ) + ) + } + + /// Mint a plan from in-memory identities (for executor tests that do not need real objects). + static func makePlan( + items: [Item], + mode: DiskCleanRemovalMode = .permanent, + lockedByBundleIDs: [String] = [], + skipWhenProcessIsRunning: [String] = [], + now: Date = DiskCleanPlanFactory.observedAt + ) throws -> DiskCleanValidatedPlan { + let candidates = items.map { + candidate(path: $0.path, identity: $0.identity, bytes: $0.bytes) + } + return try DiskCleanPlanner.makePlan( + artifact: artifact(candidates: candidates), + selectedIDs: Set(candidates.map(\.id)), + mode: mode, + now: now, + catalog: catalog( + lockedByBundleIDs: lockedByBundleIDs, + skipWhenProcessIsRunning: skipWhenProcessIsRunning + ) + ) + } + + static func currentIdentity(ofItemAt path: String) -> DiskCleanRootIdentity? { + var status = stat() + guard lstat(path, &status) == 0 else { return nil } + return DiskCleanRootIdentity(stat: status) + } + + static func currentSize(ofItemAt path: String) -> Int64 { + var status = stat() + guard lstat(path, &status) == 0 else { return 0 } + return status.st_size + } +} + +// MARK: - Primitive seam fakes + +/// Call-recording primitive fake. Executor tests assert "zero calls on preflight failure" with it. +final class FakeDiskCleanRemovalPrimitive: DiskCleanPlanItemRemoving, @unchecked Sendable { + private let lock = NSLock() + private var calls: [(path: String, mode: DiskCleanRemovalMode)] = [] + private var dispositionsByPath: [String: DiskCleanRemovalDisposition] = [:] + private var defaultDisposition: DiskCleanRemovalDisposition + + init(defaultDisposition: DiskCleanRemovalDisposition = .removed) { + self.defaultDisposition = defaultDisposition + } + + func setDisposition(_ disposition: DiskCleanRemovalDisposition, forPath path: String) { + lock.withLock { dispositionsByPath[path] = disposition } + } + + var removedPaths: [String] { lock.withLock { calls.map(\.path) } } + var callCount: Int { lock.withLock { calls.count } } + var lastMode: DiskCleanRemovalMode? { lock.withLock { calls.last?.mode } } + + func remove( + _ item: DiskCleanValidatedPlan.PlanItem, + mode: DiskCleanRemovalMode + ) -> DiskCleanRemovalDisposition { + lock.withLock { + calls.append((path: item.path, mode: mode)) + return dispositionsByPath[item.path] ?? defaultDisposition + } + } +} + +/// Trash fake: records paths and really deletes the object (simulates "left original location"); never touches the real Trash. +final class FakeDiskCleanTrash: DiskCleanTrashing, @unchecked Sendable { + struct TrashFailure: LocalizedError { + var errorDescription: String? { "trash unavailable" } + } + + private let lock = NSLock() + private var paths: [String] = [] + private let shouldFail: Bool + /// Side effect while trashing, to simulate "another process recreates the original path". + private let duringTrash: (@Sendable () -> Void)? + + init(shouldFail: Bool = false, duringTrash: (@Sendable () -> Void)? = nil) { + self.shouldFail = shouldFail + self.duringTrash = duringTrash + } + + var trashedPaths: [String] { lock.withLock { paths } } + + func trashItem(atPath path: String) throws { + lock.withLock { paths.append(path) } + duringTrash?() + if shouldFail { + throw TrashFailure() + } + try FileManager.default.removeItem(atPath: path) + } +} + +/// Device-id fake: forges device ids by entry name to construct mount crossings impossible on a real FS. +struct FakeDiskCleanStagedEntryDeviceResolver: DiskCleanStagedEntryDeviceResolving { + /// Entries with these names are reported on a different device. + let crossedMountEntryNames: Set + + func deviceID(ofEntry nameBytes: [CChar], statResult: stat) -> UInt64 { + let realDevice = UInt64(UInt32(bitPattern: statResult.st_dev)) + let name = nameBytes.withUnsafeBufferPointer { buffer -> String in + buffer.baseAddress.map { String(cString: $0) } ?? "" + } + return crossedMountEntryNames.contains(name) ? realDevice &+ 1 : realDevice + } +} + +/// Records whether reconciliation was triggered. +final class SpyDiskCleanStagingReconciler: DiskCleanStagingReconciling, @unchecked Sendable { + private let lock = NSLock() + private var directories: [URL] = [] + + var reconciledDirectories: [URL] { lock.withLock { directories } } + + func reconcile(storageDirectory: URL) async { + lock.withLock { directories.append(storageDirectory) } + } +} + +// MARK: - Running-app snapshot fake + +/// Snapshot source that can set preflight snapshot and "per-item refresh" results separately. +/// +/// Dual-time locking means "preflight once, then refresh bundle IDs per item"; only separating them makes that testable. +final class ProgrammableDiskCleanRunningAppLock: DiskCleanRunningAppSnapshotting, @unchecked Sendable { + private let lock = NSLock() + private let initialSnapshot: DiskCleanRunningAppSnapshot + private let refreshedBundleIDs: Set? + private var refreshCount = 0 + private var requestedProcessNames: [[String]] = [] + + init( + snapshot: DiskCleanRunningAppSnapshot = DiskCleanRunningAppSnapshot(), + refreshedBundleIDs: Set? = nil + ) { + self.initialSnapshot = snapshot + self.refreshedBundleIDs = refreshedBundleIDs + } + + var refreshCallCount: Int { lock.withLock { refreshCount } } + var lastRequestedProcessNames: [String]? { lock.withLock { requestedProcessNames.last } } + + func makeSnapshot(processNames: [String]) async -> DiskCleanRunningAppSnapshot { + lock.withLock { requestedProcessNames.append(processNames.sorted()) } + return initialSnapshot + } + + func refreshingBundleIDs(in snapshot: DiskCleanRunningAppSnapshot) async -> DiskCleanRunningAppSnapshot { + lock.withLock { refreshCount += 1 } + guard let refreshedBundleIDs else { return snapshot } + return snapshot.replacingBundleIDs(refreshedBundleIDs, observedAt: snapshot.observedAt) + } +} + +// MARK: - Assertion helpers + +extension XCTestCase { + func assertPathExists( + _ path: String, + _ message: String = "", + file: StaticString = #filePath, + line: UInt = #line + ) { + var status = stat() + XCTAssertEqual(lstat(path, &status), 0, message, file: file, line: line) + } + + func assertPathDoesNotExist( + _ path: String, + _ message: String = "", + file: StaticString = #filePath, + line: UInt = #line + ) { + var status = stat() + XCTAssertNotEqual(lstat(path, &status), 0, message, file: file, line: line) + } + + /// Staged leftover names in a directory. `rollbackBlocked` / `partiallyDeleted` are proven by their presence. + func stagedNames(in directoryPath: String) -> [String] { + let names = (try? FileManager.default.contentsOfDirectory(atPath: directoryPath)) ?? [] + return names.filter { $0.hasPrefix(DiskCleanRemovalPrimitive.stagedNamePrefix) }.sorted() + } +} diff --git a/Plugins/DiskClean/Tests/DiskCleanExecutorTests.swift b/Plugins/DiskClean/Tests/DiskCleanExecutorTests.swift index 1af3c802..4ec0bcf1 100644 --- a/Plugins/DiskClean/Tests/DiskCleanExecutorTests.swift +++ b/Plugins/DiskClean/Tests/DiskCleanExecutorTests.swift @@ -1,180 +1,300 @@ +import Foundation import XCTest @testable import MacTools @testable import DiskCleanPlugin +/// Executor preflight and per-item recheck chain (design §7.1, §7.2). +/// +/// Primitives are fakes here: this class tests **ordering and gates**. Real syscall behavior of primitives is covered by +/// `DiskCleanRemovalPrimitiveTests` against a real filesystem. +@MainActor final class DiskCleanExecutorTests: XCTestCase { private let home = "/Users/tester" + private var storage: DiskCleanTempDirectory! + private var auditLog: DiskCleanAuditLog! - func testCleanRemovesOnlySelectedAllowedCandidatesAndCountsReclaimedBytes() async throws { - let removable = candidate(id: "remove", path: "\(home)/Library/Caches/Remove", size: 10) - let unselected = candidate(id: "unselected", path: "\(home)/Library/Caches/Unselected", size: 20) - let protected = candidate( - id: "protected", - path: "\(home)/Library/Caches/Protected", - size: 30, - safety: .protected(reason: "protected") - ) - let failed = candidate(id: "failed", path: "\(home)/Library/Caches/Failed", size: 40) - let fileSystem = FakeDiskCleanExecutorFileSystem( - items: [ - removable.path: item(removable.path), - unselected.path: item(unselected.path), - protected.path: item(protected.path), - failed.path: item(failed.path) - ], - removeErrors: [failed.path: TestRemoveError()] - ) - let executor = makeExecutor(fileSystem: fileSystem) - - let result = try await executor.clean( - candidates: [removable, unselected, protected, failed], - selectedCandidateIDs: [removable.id, protected.id, failed.id] - ) - - XCTAssertEqual(fileSystem.removedPaths, [removable.path, failed.path]) - XCTAssertEqual(result.removedCount, 1) - XCTAssertEqual(result.skippedCount, 1) - XCTAssertEqual(result.failedCount, 1) - XCTAssertEqual(result.reclaimedBytes, 10) - XCTAssertEqual(result.itemResults.map(\.candidateID), [removable.id, protected.id, failed.id]) + override func setUpWithError() throws { + try super.setUpWithError() + storage = try DiskCleanTempDirectory(name: "diskclean-executor") + auditLog = DiskCleanAuditLog(directory: storage.url) + } + + override func tearDown() { + storage?.remove() + storage = nil + auditLog = nil + super.tearDown() + } + + // MARK: - preflight: any failure means zero deletes + + func testPreflightRejectsExpiredPlanWithoutTouchingAnything() async throws { + let plan = try makePlan(paths: ["\(home)/Library/Caches/A"]) + let primitive = FakeDiskCleanRemovalPrimitive() + let executor = makeExecutor(primitive: primitive, now: plan.expiryDeadline) + + await assertThrows(.planExpired) { try await executor.execute(plan: plan) } + + XCTAssertEqual(primitive.callCount, 0, "preflight failure must mean zero deletes") } - func testCleanRevalidatesEachPathImmediatelyBeforeDeletion() async throws { - let safe = candidate(id: "safe", path: "\(home)/Library/Caches/Safe", size: 10) - let changed = candidate(id: "changed", path: "\(home)/Library/Caches/Changed", size: 20) - let missing = candidate(id: "missing", path: "\(home)/Library/Caches/Missing", size: 30) - let fileSystem = FakeDiskCleanExecutorFileSystem( - items: [ - safe.path: item(safe.path), - changed.path: item(changed.path, isSymlink: true, resolvedSymlinkTarget: "/System") - ] + func testPreflightRejectsWholePlanWhenAnyItemIsLockedByRunningApp() async throws { + let plan = try makePlan( + paths: ["\(home)/Library/Caches/A", "\(home)/Library/Caches/B"], + lockedByBundleIDs: ["com.example.app"] ) - let executor = makeExecutor(fileSystem: fileSystem) + let primitive = FakeDiskCleanRemovalPrimitive() + let executor = makeExecutor( + primitive: primitive, + runningAppLock: ProgrammableDiskCleanRunningAppLock( + snapshot: DiskCleanRunningAppSnapshot(runningBundleIDs: ["com.example.app"]) + ) + ) + + await assertThrows( + .lockedDuringPreflight(path: "\(home)/Library/Caches/A", processName: "com.example.app") + ) { try await executor.execute(plan: plan) } + + XCTAssertEqual(primitive.callCount, 0, "one locked item aborts the whole run; it does not skip just that item") + } + + func testPreflightRejectsPlanWhenSafetyPolicyNowRefusesAPath() async throws { + // Path added to the allowlist only after plan minting: preflight must stop the whole run. + let plan = try makePlan(paths: ["\(home)/Library/Caches/A", "\(home)/.ssh"]) + let primitive = FakeDiskCleanRemovalPrimitive() + let executor = makeExecutor(primitive: primitive) + + await assertThrowsSafetyRejection(path: "\(home)/.ssh") { try await executor.execute(plan: plan) } - let result = try await executor.clean( - candidates: [safe, changed, missing], - selectedCandidateIDs: [safe.id, changed.id, missing.id] + XCTAssertEqual(primitive.callCount, 0) + } + + /// Plan-carried evidence must independently re-detect ancestor conflicts — this gate catches Planner bugs. + func testPreflightRerunsAncestorAssertionFromPlanEvidence() async throws { + let parent = DiskCleanPlanFactory.candidate(path: "\(home)/Library/Caches/App") + let lockedChild = DiskCleanPlanFactory.candidate( + path: "\(home)/Library/Caches/App/inner", + safety: .inUse(processName: "App") + ) + // Mint a valid plan first, then use the same evidence to prove the assertion itself catches conflicts. + let plan = try DiskCleanPlanner.makePlan( + artifact: DiskCleanPlanFactory.artifact(candidates: [parent]), + selectedIDs: [parent.id], + mode: .permanent, + now: DiskCleanPlanFactory.observedAt, + catalog: DiskCleanPlanFactory.catalog() ) - XCTAssertEqual(fileSystem.removedPaths, [safe.path]) - XCTAssertEqual(result.removedCount, 1) - XCTAssertEqual(result.skippedCount, 2) - XCTAssertEqual(result.failedCount, 0) - XCTAssertEqual(outcome(for: changed.id, in: result), .skipped(.invalid(reason: "symlink points to protected system path"))) - XCTAssertEqual(outcome(for: missing.id, in: result), .skipped(.invalid(reason: "path no longer exists"))) + XCTAssertThrowsError( + try DiskCleanPlanner.assertNoAncestorViolation( + plannedPaths: plan.items.map(\.path), + exclusionPaths: [lockedChild.path], + reservedPrefixes: plan.reservedPrefixes + ) + ) { error in + XCTAssertEqual( + error as? DiskCleanPlanError, + .ancestorViolation( + plannedPath: "\(home)/Library/Caches/App", + protectedPath: "\(home)/Library/Caches/App/inner" + ) + ) + } } - func testCleanSkipsEveryNonCleanableSafetyStatus() async throws { - let candidates = [ - candidate(id: "whitelisted", path: "\(home)/Library/Caches/White", safety: .whitelisted(rule: "rule")), - candidate(id: "protected", path: "\(home)/Library/Caches/Protected", safety: .protected(reason: "protected")), - candidate(id: "invalid", path: "\(home)/Library/Caches/Invalid", safety: .invalid(reason: "invalid")), - candidate(id: "admin", path: "\(home)/Library/Caches/Admin", safety: .requiresAdmin(reason: "admin")), - candidate(id: "in-use", path: "\(home)/Library/Caches/InUse", safety: .inUse(processName: "App")) - ] - let fileSystem = FakeDiskCleanExecutorFileSystem( - items: Dictionary(uniqueKeysWithValues: candidates.map { ($0.path, item($0.path)) }) + // MARK: - Per-item chain + + func testExecutesEveryItemInPlanOrderAndCountsTerminalStates() async throws { + let paths = (0..<6).map { "\(home)/Library/Caches/Item\($0)" } + let plan = try makePlan(paths: paths, bytes: 100) + let primitive = FakeDiskCleanRemovalPrimitive() + primitive.setDisposition(.trashed(stagedName: ".mactools-staged-1"), forPath: paths[1]) + primitive.setDisposition(.changedSinceScan, forPath: paths[2]) + primitive.setDisposition(.failed(reason: "boom"), forPath: paths[3]) + primitive.setDisposition(.partiallyDeleted(stagedName: ".mactools-staged-4", reason: "io"), forPath: paths[4]) + primitive.setDisposition(.rollbackBlocked(stagedName: ".mactools-staged-5", reason: "occupied"), forPath: paths[5]) + let executor = makeExecutor(primitive: primitive) + + let result = try await executor.execute(plan: plan) + + XCTAssertEqual(primitive.removedPaths, paths, "items execute in plan order") + XCTAssertEqual(result.removedCount, 2, "removed + trashed both count as disposed") + XCTAssertEqual(result.skippedCount, 1, "changedSinceScan counts as skip: object was not touched") + XCTAssertEqual(result.failedCount, 3, "failed / partiallyDeleted / rollbackBlocked are not success") + XCTAssertEqual(result.changedSinceScanCount, 1) + XCTAssertEqual(result.reclaimedBytes, 200, "only successfully disposed items count toward estimated reclaimed bytes") + XCTAssertEqual( + result.attentionResults.map(\.path), + [paths[4], paths[5]], + "partial delete and blocked rollback must be pinned in cleanup history" ) - let executor = makeExecutor(fileSystem: fileSystem) + XCTAssertEqual(result.mode, .permanent) + } - let result = try await executor.clean( - candidates: candidates, - selectedCandidateIDs: Set(candidates.map(\.id)) + /// Per-item lock recheck: an app started after preflight must still block later items. + func testItemIsSkippedWhenBundleIDBecomesRunningAfterPreflight() async throws { + let plan = try makePlan( + paths: ["\(home)/Library/Caches/A"], + lockedByBundleIDs: ["com.example.app"] + ) + let primitive = FakeDiskCleanRemovalPrimitive() + let runningAppLock = ProgrammableDiskCleanRunningAppLock( + snapshot: DiskCleanRunningAppSnapshot(), + refreshedBundleIDs: ["com.example.app"] ) + let executor = makeExecutor(primitive: primitive, runningAppLock: runningAppLock) - XCTAssertTrue(fileSystem.removedPaths.isEmpty) - XCTAssertEqual(result.removedCount, 0) - XCTAssertEqual(result.skippedCount, candidates.count) - XCTAssertEqual(result.failedCount, 0) - XCTAssertEqual(result.reclaimedBytes, 0) + let result = try await executor.execute(plan: plan) + + XCTAssertEqual(primitive.callCount, 0, "per-item recheck finds lock → skip item; do not hand to primitive") + XCTAssertEqual(result.skippedCount, 1) + XCTAssertEqual( + result.itemResults.first?.outcome, + .skipped(.inUse(processName: "com.example.app")) + ) + XCTAssertGreaterThan(runningAppLock.refreshCallCount, 0, "bundle IDs must be refreshed per item") } - private func makeExecutor(fileSystem: FakeDiskCleanExecutorFileSystem) -> DiskCleanExecutor { - DiskCleanExecutor( - fileSystem: fileSystem, - safetyPolicy: DiskCleanSafetyPolicy( - homeDirectory: home, - whitelistStore: DiskCleanWhitelistStore(homeDirectory: home, includeDefaults: false) - ) + /// Process-name snapshot comes from the preflight pass; do not re-run pgrep per item. + func testProcessNamesAreQueriedOnceDuringPreflight() async throws { + let plan = try makePlan( + paths: ["\(home)/Library/Caches/A", "\(home)/Library/Caches/B"], + skipWhenProcessIsRunning: ["exampled"] ) + let runningAppLock = ProgrammableDiskCleanRunningAppLock() + let executor = makeExecutor(runningAppLock: runningAppLock) + + _ = try await executor.execute(plan: plan) + + XCTAssertEqual(runningAppLock.lastRequestedProcessNames, ["exampled"]) + XCTAssertEqual(runningAppLock.refreshCallCount, 2, "each of the two plan items refreshes bundle IDs once") } - private func candidate( - id: String, - path: String, - size: Int64 = 1, - safety: DiskCleanSafetyStatus = .allowed - ) -> DiskCleanCandidate { - DiskCleanCandidate( - id: id, - ruleID: "rule", - choice: .cache, - title: id, - path: path, - sizeBytes: size, - safety: safety, - risk: .low - ) - } - - private func item( - _ path: String, - isSymlink: Bool = false, - resolvedSymlinkTarget: String? = nil - ) -> DiskCleanFileItem { - DiskCleanFileItem( - path: path, - isDirectory: true, - isSymlink: isSymlink, - resolvedSymlinkTarget: resolvedSymlinkTarget - ) - } - - private func outcome( - for candidateID: String, - in result: DiskCleanExecutionResult - ) -> DiskCleanExecutionItemResult.Outcome? { - result.itemResults.first { $0.candidateID == candidateID }?.outcome + func testTrashModeIsForwardedToPrimitive() async throws { + let plan = try makePlan(paths: ["\(home)/Library/Caches/A"], mode: .trash) + let primitive = FakeDiskCleanRemovalPrimitive( + defaultDisposition: .trashed(stagedName: ".mactools-staged-x") + ) + let executor = makeExecutor(primitive: primitive) + + let result = try await executor.execute(plan: plan) + + XCTAssertEqual(primitive.lastMode, .trash) + XCTAssertEqual(result.mode, .trash) + XCTAssertEqual( + result.itemResults.first?.outcome, + .trashed(reclaimedBytes: 0, stagedName: ".mactools-staged-x") + ) } -} -private struct TestRemoveError: Error {} + // MARK: - Audit -private final class FakeDiskCleanExecutorFileSystem: DiskCleanFileSystemProviding, @unchecked Sendable { - var items: [String: DiskCleanFileItem] - var removeErrors: [String: Error] + func testWritesOneAuditRecordPerItemWithTerminalStatus() async throws { + let paths = ["\(home)/Library/Caches/A", "\(home)/Library/Caches/B"] + let plan = try makePlan(paths: paths, bytes: 42) + let primitive = FakeDiskCleanRemovalPrimitive() + primitive.setDisposition( + .partiallyDeleted(stagedName: ".mactools-staged-b", reason: "io error"), + forPath: paths[1] + ) + let executor = makeExecutor(primitive: primitive) - private(set) var removedPaths: [String] = [] + _ = try await executor.execute(plan: plan) - init( - items: [String: DiskCleanFileItem], - removeErrors: [String: Error] = [:] - ) { - self.items = items - self.removeErrors = removeErrors + let records = auditLog.recentRecords(limit: 10) + XCTAssertEqual(records.count, 2) + let byPath = Dictionary(uniqueKeysWithValues: records.map { ($0.path ?? "", $0) }) + XCTAssertEqual(byPath[paths[0]]?.status, "ok") + XCTAssertEqual(byPath[paths[0]]?.action, .delete) + XCTAssertEqual(byPath[paths[0]]?.estimatedBytes, 42) + XCTAssertEqual(byPath[paths[0]]?.targetID, DiskCleanPlanFactory.targetID) + XCTAssertEqual(byPath[paths[1]]?.status, "partiallyDeleted") + XCTAssertEqual(byPath[paths[1]]?.stagedName, ".mactools-staged-b") + XCTAssertEqual(byPath[paths[1]]?.error, "io error") } - func expandPathPattern(_ pattern: String) throws -> [DiskCleanFileItem] { - [] + func testSkippedItemAuditRecordsReason() async throws { + let plan = try makePlan( + paths: ["\(home)/Library/Caches/A"], + lockedByBundleIDs: ["com.example.app"] + ) + let executor = makeExecutor( + runningAppLock: ProgrammableDiskCleanRunningAppLock( + snapshot: DiskCleanRunningAppSnapshot(), + refreshedBundleIDs: ["com.example.app"] + ) + ) + + _ = try await executor.execute(plan: plan) + + let record = try XCTUnwrap(auditLog.recentRecords(limit: 1).first) + XCTAssertEqual(record.status, "skipped") + XCTAssertEqual(record.skipReason, "inUse(com.example.app)") } - func itemInfo(at path: String) throws -> DiskCleanFileItem? { - items[path] + // MARK: - Fixtures + + private func makePlan( + paths: [String], + mode: DiskCleanRemovalMode = .permanent, + bytes: Int64 = 0, + lockedByBundleIDs: [String] = [], + skipWhenProcessIsRunning: [String] = [] + ) throws -> DiskCleanValidatedPlan { + try DiskCleanPlanFactory.makePlan( + items: paths.map { DiskCleanPlanFactory.Item(path: $0, identity: .test(), bytes: bytes) }, + mode: mode, + lockedByBundleIDs: lockedByBundleIDs, + skipWhenProcessIsRunning: skipWhenProcessIsRunning + ) } - func sizeOfItem(at path: String) throws -> Int64 { - 0 + private func makeExecutor( + primitive: any DiskCleanPlanItemRemoving = FakeDiskCleanRemovalPrimitive(), + runningAppLock: any DiskCleanRunningAppSnapshotting = ProgrammableDiskCleanRunningAppLock(), + now: Date = DiskCleanPlanFactory.observedAt + ) -> DiskCleanExecutor { + DiskCleanExecutor( + primitive: primitive, + safetyPolicy: DiskCleanSafetyPolicy( + homeDirectory: home, + whitelistStore: DiskCleanWhitelistStore(homeDirectory: home, includeDefaults: false) + ), + runningAppLock: runningAppLock, + auditLog: auditLog, + now: { now } + ) } - func removeItem(at path: String) throws { - removedPaths.append(path) - if let error = removeErrors[path] { - throw error + private func assertThrows( + _ expected: DiskCleanExecutionError, + file: StaticString = #filePath, + line: UInt = #line, + _ body: () async throws -> DiskCleanExecutionResult + ) async { + do { + _ = try await body() + XCTFail("expected throw \(expected), but execution succeeded", file: file, line: line) + } catch let error as DiskCleanExecutionError { + XCTAssertEqual(error, expected, file: file, line: line) + } catch { + XCTFail("expected DiskCleanExecutionError, got: \(error)", file: file, line: line) } - items[path] = nil } - func deduplicatedParentChildPaths(_ paths: [String]) -> [String] { - paths + private func assertThrowsSafetyRejection( + path: String, + file: StaticString = #filePath, + line: UInt = #line, + _ body: () async throws -> DiskCleanExecutionResult + ) async { + do { + _ = try await body() + XCTFail("expected safety rejection for \(path)", file: file, line: line) + } catch let DiskCleanExecutionError.safetyRejected(rejectedPath, _) { + XCTAssertEqual(rejectedPath, path, file: file, line: line) + } catch { + XCTFail("expected safetyRejected, got: \(error)", file: file, line: line) + } } } diff --git a/Plugins/DiskClean/Tests/DiskCleanExternalExpansionTests.swift b/Plugins/DiskClean/Tests/DiskCleanExternalExpansionTests.swift new file mode 100644 index 00000000..f6ee1192 --- /dev/null +++ b/Plugins/DiskClean/Tests/DiskCleanExternalExpansionTests.swift @@ -0,0 +1,264 @@ +import XCTest +import MacToolsPluginKit +@testable import MacTools +@testable import DiskCleanPlugin + +/// Adapter from specialized scanners into the unified pipeline (design §10). +/// +/// Two concerns: candidates attach to **real synthetic targets** (else `makePlan` rejects them), +/// and default-selection facts are translated faithfully into risk overrides and notes. +final class DiskCleanExternalExpansionTests: XCTestCase { + private let catalog = DiskCleanRuleCatalogV2.current + private let localization = PluginLocalization(bundle: .main) + + // MARK: - Developer artifacts: risk overrides and notes + + func testCleanRepositoryCandidateBecomesLowRiskWithProjectNote() { + let facts = DiskCleanDeveloperArtifactExpansion.facts( + for: candidate(gitState: .clean(repositoryPath: "/code/app")) + ) + + XCTAssertEqual(facts.risk, .low, "artifacts from a clean repo should be selected by default") + XCTAssertEqual( + facts.notes, + [.developerProject(path: "/code/app", marker: "package.json")] + ) + } + + func testCandidateOutsideRepositoryIsStillLowRisk() { + let facts = DiskCleanDeveloperArtifactExpansion.facts(for: candidate(gitState: .notInRepository)) + + XCTAssertEqual(facts.risk, .low) + XCTAssertEqual(facts.notes.count, 1, "no repo badge when not inside a repository") + } + + /// Dirty repo yields no override → keep synthetic target medium → not selected by default (design §10.1). + func testDirtyRepositoryCandidateKeepsTargetRiskAndCarriesBadge() { + let facts = DiskCleanDeveloperArtifactExpansion.facts( + for: candidate( + gitState: .dirty(repositoryPath: "/code/app", reason: .uncommittedChanges) + ) + ) + + XCTAssertNil(facts.risk, "no override keeps target medium; fail-safe direction") + XCTAssertEqual( + facts.notes, + [ + .developerProject(path: "/code/app", marker: "package.json"), + .repositoryHasChanges(repositoryPath: "/code/app", reason: .uncommittedChanges) + ] + ) + } + + /// Failed git inspection is also treated as "has changes", but the badge must say "could not check", not "definitely dirty". + func testGitInspectionFailureIsTreatedAsDirtyWithItsOwnReason() { + let facts = DiskCleanDeveloperArtifactExpansion.facts( + for: candidate( + gitState: .dirty(repositoryPath: "/code/app", reason: .inspectionFailed("git 检查超时")) + ) + ) + + XCTAssertNil(facts.risk) + XCTAssertTrue( + facts.notes.contains( + .repositoryHasChanges(repositoryPath: "/code/app", reason: .inspectionFailed("git 检查超时")) + ) + ) + } + + // MARK: - Developer artifacts: full expansion + + func testExpandsDiscoveredCandidatesOntoCatalogTargets() async throws { + let root = try makeTemporaryDirectory() + try makeProject(in: root, named: "app", artifact: "node_modules", marker: "package.json") + + let expansion = await DiskCleanDeveloperArtifactExpansion( + scanner: DiskCleanPurgeScanner(inspector: DiskCleanGitStatusInspector(runner: cleanGitRunner())) + ).expand( + scope: .developerArtifacts(roots: [root]), + catalog: catalog, + localization: localization + ) + + XCTAssertEqual(expansion.hits.count, 1) + let hit = try XCTUnwrap(expansion.hits.first) + XCTAssertEqual(hit.target.id, DiskCleanPurgeKind.nodeModules.targetID) + XCTAssertTrue(hit.target.isExternallyDiscovered) + XCTAssertEqual(hit.item.path, root + "/app/node_modules") + XCTAssertTrue(hit.item.isDirectory) + } + + /// Reserved-root semantics (design §10.1): the root itself is not a deletion target, but + /// "parts under the root not covered by candidates were never reviewed", so every configured root + /// enters the reserved set regardless of walk success. + func testReservesEveryConfiguredRootRegardlessOfOutcome() async throws { + let root = try makeTemporaryDirectory() + let missing = root + "/does-not-exist" + + let expansion = await DiskCleanDeveloperArtifactExpansion().expand( + scope: .developerArtifacts(roots: [root, missing]), + catalog: catalog, + localization: localization + ) + + XCTAssertEqual(expansion.reservedRootPaths, [root, missing]) + } + + /// Unreadable roots must be reported — "scanned with no candidates" and "never scanned" are different for the user. + func testUnreadableRootIsReportedAsLimitation() async throws { + let root = try makeTemporaryDirectory() + let missing = root + "/does-not-exist" + + let expansion = await DiskCleanDeveloperArtifactExpansion().expand( + scope: .developerArtifacts(roots: [missing]), + catalog: catalog, + localization: localization + ) + + XCTAssertTrue(expansion.hits.isEmpty) + XCTAssertEqual(expansion.limitations.count, 1) + guard case let .scanRootUnreadable(path, _) = try XCTUnwrap(expansion.limitations.first) else { + return XCTFail("should report scanRootUnreadable") + } + XCTAssertEqual(path, missing) + } + + func testEmptyRootListProducesNothingAtAll() async { + let expansion = await DiskCleanDeveloperArtifactExpansion().expand( + scope: .developerArtifacts(roots: []), + catalog: catalog, + localization: localization + ) + + XCTAssertTrue(expansion.hits.isEmpty) + XCTAssertTrue(expansion.reservedRootPaths.isEmpty) + XCTAssertTrue(expansion.limitations.isEmpty) + } + + // MARK: - Leftover installers + + func testStaleInstallerBecomesLowRiskWithoutNotes() { + let facts = DiskCleanInstallerExpansion.facts( + for: installerCandidate(kind: .diskImage, isSelectedByDefault: true, note: nil) + ) + + XCTAssertEqual(facts.risk, .low) + XCTAssertTrue(facts.notes.isEmpty) + } + + func testZipArchiveKeepsTargetRiskAndCarriesItsOwnNote() { + let facts = DiskCleanInstallerExpansion.facts( + for: installerCandidate(kind: .zipArchive, isSelectedByDefault: false, note: .mayNotBeInstaller) + ) + + XCTAssertNil(facts.risk, ".zip is never selected by default") + XCTAssertEqual(facts.notes, [.mayNotBeInstaller]) + } + + func testRecentlyDownloadedInstallerKeepsTargetRisk() { + let modifiedAt = Date(timeIntervalSince1970: 5_000) + let facts = DiskCleanInstallerExpansion.facts( + for: installerCandidate( + kind: .installerPackage, + isSelectedByDefault: false, + note: .recentlyModified, + modifiedAt: modifiedAt + ) + ) + + XCTAssertNil(facts.risk) + XCTAssertEqual(facts.notes, [.recentlyDownloaded(modifiedAt: modifiedAt)]) + } + + func testExpandsInstallersOntoCatalogTargetsAndReservesDownloads() async throws { + let downloads = try makeTemporaryDirectory() + try Data().write(to: URL(fileURLWithPath: downloads + "/Tool.dmg")) + + let expansion = await DiskCleanInstallerExpansion( + scanner: DiskCleanInstallerScanner( + downloadsPath: downloads, + now: { Date(timeIntervalSince1970: 10_000_000) } + ) + ).expand(scope: .installers, catalog: catalog, localization: localization) + + XCTAssertEqual(expansion.hits.map(\.target.id), [DiskCleanInstallerKind.diskImage.targetID]) + XCTAssertEqual(expansion.hits.first?.item.isDirectory, false) + XCTAssertEqual( + expansion.reservedRootPaths, + [DiskCleanRuleTarget.expandHome(in: "~/Downloads", homeDirectory: NSHomeDirectory())], + "five targets declare the same ~/Downloads; dedupe keeps one" + ) + } + + /// On TCC denial the directory may hold tens of GB; must not degrade to "nothing cleanable". + func testDeniedDownloadsIsReportedAsPermissionLimitation() async throws { + let parent = try makeTemporaryDirectory() + let downloads = parent + "/Downloads" + try FileManager.default.createDirectory(atPath: downloads, withIntermediateDirectories: true) + try FileManager.default.setAttributes([.posixPermissions: 0o000], ofItemAtPath: downloads) + addTeardownBlock { + try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: downloads) + } + + let expansion = await DiskCleanInstallerExpansion( + scanner: DiskCleanInstallerScanner(downloadsPath: downloads) + ).expand(scope: .installers, catalog: catalog, localization: localization) + + XCTAssertTrue(expansion.hits.isEmpty) + XCTAssertEqual( + expansion.limitations, + [.scanRootUnreadable(path: downloads, reason: .permissionDenied)] + ) + } + + // MARK: - Helpers + + private func candidate(gitState: DiskCleanPurgeGitState) -> DiskCleanPurgeCandidate { + DiskCleanPurgeCandidate( + item: DiskCleanPurgeDiscoveredItem( + path: "/code/app/node_modules", + kind: .nodeModules, + projectMarker: "package.json", + projectPath: "/code/app", + repositoryPath: gitState.repositoryPath + ), + gitState: gitState + ) + } + + private func installerCandidate( + kind: DiskCleanInstallerKind, + isSelectedByDefault: Bool, + note: DiskCleanInstallerCandidate.Note?, + modifiedAt: Date = Date(timeIntervalSince1970: 1_000) + ) -> DiskCleanInstallerCandidate { + DiskCleanInstallerCandidate( + path: "/downloads/Tool." + kind.fileExtension, + kind: kind, + byteSize: 1_024, + modifiedAt: modifiedAt, + isSelectedByDefault: isSelectedByDefault, + note: note + ) + } + + /// Exit code 0 + empty output = clean worktree and no unpushed commits. + private func cleanGitRunner() -> FakeDiskCleanSubprocessRunner { + FakeDiskCleanSubprocessRunner(exitCode: 0, standardOutput: "") + } + + private func makeTemporaryDirectory() throws -> String { + let url = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("diskclean-expansion-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + addTeardownBlock { try? FileManager.default.removeItem(at: url) } + // Temp dirs often live under /var (itself a symlink), while scan roots always open with O_NOFOLLOW_ANY. + return DiskCleanPhysicalPath.realpath(of: url.path) ?? url.path + } + + private func makeProject(in root: String, named name: String, artifact: String, marker: String) throws { + let project = root + "/" + name + try FileManager.default.createDirectory(atPath: project + "/" + artifact, withIntermediateDirectories: true) + try Data().write(to: URL(fileURLWithPath: project + "/" + marker)) + } +} diff --git a/Plugins/DiskClean/Tests/DiskCleanFastWalkerTests.swift b/Plugins/DiskClean/Tests/DiskCleanFastWalkerTests.swift new file mode 100644 index 00000000..8d4bd2ff --- /dev/null +++ b/Plugins/DiskClean/Tests/DiskCleanFastWalkerTests.swift @@ -0,0 +1,176 @@ +import Darwin +import XCTest +@testable import MacTools +@testable import DiskCleanPlugin + +final class DiskCleanFastWalkerTests: XCTestCase { + private var temporaryDirectory: DiskCleanTempDirectory! + private var walker: DiskCleanFastWalker! + + override func setUpWithError() throws { + try super.setUpWithError() + temporaryDirectory = try DiskCleanTempDirectory(name: "DiskCleanFastWalkerTests") + walker = DiskCleanFastWalker() + } + + override func tearDownWithError() throws { + temporaryDirectory?.remove() + temporaryDirectory = nil + walker = nil + try super.tearDownWithError() + } + + // MARK: - Behavioral contract (shared assertions with SlowWalker) + + func testSumsKnownTree() throws { + try DiskCleanWalkerContract.assertSumsKnownTree(walker, in: temporaryDirectory) + } + + func testDoesNotFollowDirectorySymlink() throws { + try DiskCleanWalkerContract.assertDoesNotFollowDirectorySymlink(walker, in: temporaryDirectory) + } + + func testReportsPermissionDenied() throws { + try XCTSkipIf(getuid() == 0, "cannot construct EACCES when running as root") + try DiskCleanWalkerContract.assertReportsPermissionDenied(walker, in: temporaryDirectory) + } + + func testHandlesEmptyDirectory() throws { + try DiskCleanWalkerContract.assertHandlesEmptyDirectory(walker, in: temporaryDirectory) + } + + func testExpiredDeadlineReportsTimeout() throws { + try DiskCleanWalkerContract.assertExpiredDeadlineReportsTimeout(walker, in: temporaryDirectory) + } + + func testCancellationReportsTimeout() throws { + try DiskCleanWalkerContract.assertCancellationReportsTimeout(walker, in: temporaryDirectory) + } + + func testBlockedDeviceIsRefused() throws { + try DiskCleanWalkerContract.assertBlockedDeviceIsRefused(walker, in: temporaryDirectory) + } + + func testMissingPathReportsWalkError() { + DiskCleanWalkerContract.assertMissingPathReportsWalkError(walker, in: temporaryDirectory) + } + + func testSizesRegularFileRoot() throws { + try DiskCleanWalkerContract.assertSizesRegularFileRoot(walker, in: temporaryDirectory) + } + + // MARK: - FastWalker-specific + + /// Focused hard-link dedupe assertion: the same inode twice in one directory counts once. + func testCountsHardLinkedFileOnce() throws { + try temporaryDirectory.makeFile("Root/original.bin", bytes: 512) + try temporaryDirectory.makeHardLink("Root/alias.bin", to: "Root/original.bin") + + let result = walker.size(ofItemAt: temporaryDirectory.resolve("Root").path, context: .test()) + + XCTAssertEqual(result.completeness, .complete) + XCTAssertEqual(result.estimatedBytes, 512) + XCTAssertEqual(result.fileCount, 1) + } + + /// Regular files with link count 1 must not be hurt by dedupe (same size still counts separately). + func testCountsDistinctFilesWithIdenticalSizes() throws { + try temporaryDirectory.makeFile("Root/one.bin", bytes: 256) + try temporaryDirectory.makeFile("Root/two.bin", bytes: 256) + + let result = walker.size(ofItemAt: temporaryDirectory.resolve("Root").path, context: .test()) + + XCTAssertEqual(result.estimatedBytes, 512) + XCTAssertEqual(result.fileCount, 2) + } + + /// Directories exceeding the single-batch 64KB buffer must accumulate correctly across batches. + func testSumsDirectoryLargerThanOneBatch() throws { + let fileCount = 900 + for index in 0..