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.. DiskCleanFullDiskAccessProbe {
+ DiskCleanFullDiskAccessProbe(
+ probePaths: DiskCleanFullDiskAccessProbe.defaultProbePaths(
+ homeDirectory: "/Users/diskclean-tests"
+ ),
+ readability: readability
+ )
+ }
+}
diff --git a/Plugins/DiskClean/Tests/DiskCleanInstallerScannerTests.swift b/Plugins/DiskClean/Tests/DiskCleanInstallerScannerTests.swift
new file mode 100644
index 00000000..968fbb7b
--- /dev/null
+++ b/Plugins/DiskClean/Tests/DiskCleanInstallerScannerTests.swift
@@ -0,0 +1,268 @@
+import Darwin
+import Foundation
+import XCTest
+@testable import MacTools
+@testable import DiskCleanPlugin
+
+final class DiskCleanInstallerScannerTests: XCTestCase {
+ /// Fixed observation time so the "7 days" check is independent of wall-clock time.
+ private let observationDate = Date(timeIntervalSince1970: 1_800_000_000)
+ private var temporaryDirectory: DiskCleanTempDirectory!
+
+ override func setUpWithError() throws {
+ try super.setUpWithError()
+ temporaryDirectory = try DiskCleanTempDirectory(name: "DiskCleanInstallerScannerTests")
+ try temporaryDirectory.makeDirectory("Downloads")
+ }
+
+ override func tearDownWithError() throws {
+ temporaryDirectory?.remove()
+ temporaryDirectory = nil
+ try super.tearDownWithError()
+ }
+
+ // MARK: - Extensions
+
+ func testSelectsStaleInstallerPackagesByDefault() throws {
+ try makeDownload("Xcode.xip", bytes: 40, ageDays: 30)
+ try makeDownload("App.dmg", bytes: 30, ageDays: 30)
+ try makeDownload("Driver.pkg", bytes: 20, ageDays: 30)
+ try makeDownload("Ubuntu.iso", bytes: 10, ageDays: 30)
+
+ let candidates = try scanCandidates()
+
+ XCTAssertEqual(
+ candidates.map(\.kind),
+ [.diskImage, .installerPackage, .discImage, .signedArchive],
+ "results are sorted by path: App.dmg / Driver.pkg / Ubuntu.iso / Xcode.xip"
+ )
+ XCTAssertTrue(candidates.allSatisfy(\.isSelectedByDefault))
+ XCTAssertTrue(candidates.allSatisfy { $0.note == nil })
+ }
+
+ /// `.zip` may be a user archive; never selected by default at any age.
+ func testZipIsListedButNeverSelectedByDefault() throws {
+ try makeDownload("Tool.zip", bytes: 12, ageDays: 400)
+
+ let candidates = try scanCandidates()
+
+ XCTAssertEqual(candidates.map(\.kind), [.zipArchive])
+ XCTAssertFalse(candidates[0].isSelectedByDefault)
+ XCTAssertEqual(candidates[0].note, .mayNotBeInstaller)
+ }
+
+ func testMatchesExtensionCaseInsensitively() throws {
+ try makeDownload("Legacy.DMG", bytes: 8, ageDays: 30)
+
+ let candidates = try scanCandidates()
+
+ XCTAssertEqual(candidates.map(\.kind), [.diskImage])
+ }
+
+ func testIgnoresUnrelatedFiles() throws {
+ try makeDownload("notes.txt", bytes: 4, ageDays: 30)
+ try makeDownload("README", bytes: 4, ageDays: 30)
+ try makeDownload("archive.dmg.part", bytes: 4, ageDays: 30)
+
+ XCTAssertTrue(try scanCandidates().isEmpty)
+ }
+
+ // MARK: - Age boundary
+
+ /// Default-selected only when strictly older than 7 days: a fresh download may not be installed yet.
+ func testRecentInstallerIsListedWithoutDefaultSelection() throws {
+ try makeDownload("Fresh.dmg", bytes: 8, ageDays: 3)
+
+ let candidates = try scanCandidates()
+
+ XCTAssertEqual(candidates.count, 1)
+ XCTAssertFalse(candidates[0].isSelectedByDefault)
+ XCTAssertEqual(candidates[0].note, .recentlyModified)
+ }
+
+ func testAgeBoundaryIsExclusive() throws {
+ try makeDownload("Exactly.dmg", bytes: 8, age: DiskCleanInstallerScanner.defaultStaleAge)
+ try makeDownload("JustOver.dmg", bytes: 8, age: DiskCleanInstallerScanner.defaultStaleAge + 1)
+
+ let candidates = try scanCandidates()
+
+ XCTAssertEqual(
+ candidates.map { [$0.displayName, "\($0.isSelectedByDefault)"] },
+ [["Exactly.dmg", "false"], ["JustOver.dmg", "true"]]
+ )
+ }
+
+ func testStaleAgeIsConfigurable() throws {
+ try makeDownload("TwoDays.dmg", bytes: 8, ageDays: 2)
+
+ let candidates = try scanCandidates(staleAge: 24 * 60 * 60)
+
+ XCTAssertTrue(candidates[0].isSelectedByDefault)
+ }
+
+ // MARK: - Top-level and type constraints
+
+ /// Top-level only, no recursion: subdirectories are often user-organized material.
+ func testDoesNotRecurseIntoSubdirectories() throws {
+ try makeDownload("Archive/Old.dmg", bytes: 8, ageDays: 100)
+
+ XCTAssertTrue(try scanCandidates().isEmpty)
+ }
+
+ func testIgnoresDirectoriesAndSymlinksNamedLikeInstallers() throws {
+ try temporaryDirectory.makeDirectory("Downloads/Bundle.pkg")
+ try makeDownload("real.dmg", bytes: 8, ageDays: 100)
+ try temporaryDirectory.makeSymlink("Downloads/alias.dmg", destination: "real.dmg")
+
+ let candidates = try scanCandidates()
+
+ XCTAssertEqual(candidates.map(\.displayName), ["real.dmg"])
+ }
+
+ // MARK: - Metadata
+
+ func testReportsSizeModificationTimeAndPath() throws {
+ try makeDownload("App.dmg", bytes: 2048, ageDays: 10)
+
+ let candidate = try XCTUnwrap(scanCandidates().first)
+
+ XCTAssertEqual(candidate.path, temporaryDirectory.resolve("Downloads/App.dmg").path)
+ XCTAssertEqual(candidate.id, candidate.path)
+ XCTAssertEqual(candidate.byteSize, 2048)
+ XCTAssertEqual(
+ candidate.modifiedAt.timeIntervalSince1970,
+ observationDate.addingTimeInterval(-10 * 24 * 60 * 60).timeIntervalSince1970,
+ accuracy: 1
+ )
+ }
+
+ // MARK: - Unreachable
+
+ /// TCC denial is not the same as "no installers in the directory": the former needs authorization guidance.
+ func testPermissionDeniedIsDistinctFromEmptyResult() throws {
+ try makeDownload("App.dmg", bytes: 8, ageDays: 100)
+ try temporaryDirectory.denyAccess(to: "Downloads")
+
+ let outcome = makeScanner().scan()
+
+ XCTAssertEqual(outcome, .denied(path: temporaryDirectory.resolve("Downloads").path))
+ XCTAssertTrue(outcome.candidates.isEmpty)
+ }
+
+ func testMissingDownloadsDirectoryIsUnavailable() throws {
+ let missing = temporaryDirectory.resolve("Missing").path
+
+ let outcome = makeScanner(downloadsPath: missing).scan()
+
+ XCTAssertEqual(outcome, .unavailable(path: missing, reason: .walkError))
+ }
+
+ func testEmptyDirectoryScansSuccessfully() {
+ XCTAssertEqual(makeScanner().scan(), .scanned(candidates: []))
+ }
+
+ /// Mid-stream readdir failure must not look like a successful partial scan.
+ func testEnumerationErrorIsNotReportedAsSuccessfulScan() {
+ let downloads = temporaryDirectory.resolve("Downloads").path
+ let observationDate = observationDate
+ let scanner = DiskCleanInstallerScanner(
+ downloadsPath: downloads,
+ staleAge: DiskCleanInstallerScanner.defaultStaleAge,
+ sourceFactory: ThrowingInstallerSourceFactory(code: EIO),
+ now: { observationDate }
+ )
+
+ let outcome = scanner.scan()
+
+ guard case let .unavailable(path, reason) = outcome else {
+ return XCTFail("expected unavailable, got \(outcome)")
+ }
+ XCTAssertEqual(path, downloads)
+ XCTAssertEqual(reason, .walkError)
+ }
+
+ func testPermissionDeniedEnumerationIsDenied() {
+ let downloads = temporaryDirectory.resolve("Downloads").path
+ let observationDate = observationDate
+ let scanner = DiskCleanInstallerScanner(
+ downloadsPath: downloads,
+ staleAge: DiskCleanInstallerScanner.defaultStaleAge,
+ sourceFactory: ThrowingInstallerSourceFactory(code: EACCES),
+ now: { observationDate }
+ )
+
+ let outcome = scanner.scan()
+
+ guard case let .denied(path) = outcome else {
+ return XCTFail("expected denied, got \(outcome)")
+ }
+ XCTAssertEqual(path, downloads)
+ }
+
+ // MARK: - Fixtures
+
+ private func makeScanner(
+ downloadsPath: String? = nil,
+ staleAge: TimeInterval = DiskCleanInstallerScanner.defaultStaleAge
+ ) -> DiskCleanInstallerScanner {
+ let observationDate = observationDate
+ return DiskCleanInstallerScanner(
+ downloadsPath: downloadsPath ?? temporaryDirectory.resolve("Downloads").path,
+ staleAge: staleAge,
+ now: { observationDate }
+ )
+ }
+
+ private func scanCandidates(
+ staleAge: TimeInterval = DiskCleanInstallerScanner.defaultStaleAge
+ ) throws -> [DiskCleanInstallerCandidate] {
+ let outcome = makeScanner(staleAge: staleAge).scan()
+ guard case let .scanned(candidates) = outcome else {
+ XCTFail("expected scanned, got \(outcome)")
+ return []
+ }
+ return candidates
+ }
+
+ private func makeDownload(_ relativePath: String, bytes: Int, ageDays: Double) throws {
+ try makeDownload(relativePath, bytes: bytes, age: ageDays * 24 * 60 * 60)
+ }
+
+ private func makeDownload(_ relativePath: String, bytes: Int, age: TimeInterval) throws {
+ let file = try temporaryDirectory.makeFile("Downloads/\(relativePath)", bytes: bytes)
+ try FileManager.default.setAttributes(
+ [.modificationDate: observationDate.addingTimeInterval(-age)],
+ ofItemAtPath: file.path
+ )
+ }
+}
+
+/// Source factory that owns the fd and fails the first `nextBatch`, simulating mid-stream readdir error.
+private struct ThrowingInstallerSourceFactory: DiskCleanDirectoryEntrySourceFactory {
+ let code: Int32
+
+ func makeSource(fileDescriptor: Int32) throws -> any DiskCleanDirectoryEntrySource {
+ ThrowingInstallerSource(fileDescriptor: fileDescriptor, code: code)
+ }
+}
+
+private final class ThrowingInstallerSource: DiskCleanDirectoryEntrySource {
+ let directoryFileDescriptor: Int32
+ private let code: Int32
+ private var isClosed = false
+
+ init(fileDescriptor: Int32, code: Int32) {
+ self.directoryFileDescriptor = fileDescriptor
+ self.code = code
+ }
+
+ func nextBatch() throws -> [DiskCleanWalkEntry]? {
+ throw DiskCleanPOSIXError(code: code)
+ }
+
+ func close() {
+ guard !isClosed else { return }
+ isClosed = true
+ Darwin.close(directoryFileDescriptor)
+ }
+}
diff --git a/Plugins/DiskClean/Tests/DiskCleanModelsTests.swift b/Plugins/DiskClean/Tests/DiskCleanModelsTests.swift
index 904157c9..2820b2b2 100644
--- a/Plugins/DiskClean/Tests/DiskCleanModelsTests.swift
+++ b/Plugins/DiskClean/Tests/DiskCleanModelsTests.swift
@@ -10,36 +10,162 @@ final class DiskCleanModelsTests: XCTestCase {
XCTAssertEqual(DiskCleanChoice.allCases, [.cache, .developer, .browser])
}
- func testScanResultTotalsOnlyAllowedCandidates() {
+ // MARK: - Panel equivalence mapping
+
+ /// The three v1 panel groups are decided by legacyRuleID prefix — the only equivalence check for v1/v2 scan scope.
+ func testChoiceIsDerivedFromLegacyRuleIDPrefix() {
+ XCTAssertEqual(DiskCleanChoice(legacyRuleID: "cache.user-essentials"), .cache)
+ XCTAssertEqual(DiskCleanChoice(legacyRuleID: "developer.homebrew"), .developer)
+ XCTAssertEqual(DiskCleanChoice(legacyRuleID: "browser.safari"), .browser)
+ XCTAssertNil(DiskCleanChoice(legacyRuleID: "unknown.thing"))
+ }
+
+ /// Every v2 target must map to a panel group, or it would silently drop out of scan scope.
+ func testEveryRuleTargetMapsToAPanelChoice() {
+ for target in DiskCleanRuleCatalogV2.current.ruleTargets {
+ XCTAssertNotNil(
+ DiskCleanChoice(legacyRuleID: target.legacyRuleID),
+ "target \(target.id) legacyRuleID \(target.legacyRuleID) has no panel mapping"
+ )
+ }
+ }
+
+ /// P2 synthetic targets **must not** have a panel mapping: regular three-group scans
+ /// filter targets by `DiskCleanChoice`, which is the second safeguard against
+ /// accidentally including developer artifacts and installers
+ /// (the first is `ScanEngine.scopedTargets(for:)` splitting by scope).
+ func testExternalTargetsHaveNoPanelChoice() {
+ let external = DiskCleanRuleCatalogV2.current.targets.filter(\.isExternallyDiscovered)
+ XCTAssertFalse(external.isEmpty)
+ for target in external {
+ XCTAssertNil(
+ DiskCleanChoice(legacyRuleID: target.legacyRuleID),
+ "P2 target \(target.id) must not have a panel mapping, or regular scans would include it"
+ )
+ }
+ }
+
+ /// Category cannot replace legacy prefix for scope: some targets have category and panel
+ /// from different sources (`browser.service-worker.editors` is developer category;
+ /// `aiTools` spans both cache.* and developer.*).
+ func testCategoryIsNotIsomorphicToPanelChoice() {
+ func choices(in category: DiskCleanCategoryID) -> Set {
+ Set(
+ DiskCleanRuleCatalogV2.current
+ .targets(in: category)
+ .compactMap { DiskCleanChoice(legacyRuleID: $0.legacyRuleID) }
+ )
+ }
+
+ XCTAssertEqual(choices(in: .developer), [.developer, .browser])
+ XCTAssertEqual(choices(in: .aiTools), [.cache, .developer])
+ }
+
+ // MARK: - Candidate invariants (§3.1)
+
+ func testCandidateWithoutSizeResultIsNotCleanable() {
+ XCTAssertFalse(makeCandidate(sizeResult: nil).isCleanable)
+ }
+
+ func testCandidateWithPartialSizeIsNotCleanable() {
+ let reasons: [DiskCleanScanCompleteness.PartialReason] = [
+ .timedOut, .permissionDenied, .unsupportedVolume, .crossedMountPoint, .walkError
+ ]
+ for reason in reasons {
+ XCTAssertFalse(
+ makeCandidate(sizeResult: .testPartial(reasons: [reason], identity: .test())).isCleanable,
+ "partial(\(reason)) candidates are not cleanable"
+ )
+ }
+ }
+
+ func testCandidateWithoutRootIdentityIsNotCleanable() {
+ let result = DiskCleanSizeResult(
+ estimatedBytes: 100,
+ fileCount: 1,
+ completeness: .complete,
+ rootIdentity: nil,
+ observedAt: Date()
+ )
+
+ XCTAssertFalse(makeCandidate(sizeResult: result).isCleanable)
+ }
+
+ func testCandidateWithBlockedSafetyIsNotCleanable() {
+ XCTAssertFalse(
+ makeCandidate(safety: .inUse(processName: "Docker"), sizeResult: .testComplete()).isCleanable
+ )
+ }
+
+ func testCompleteAndAllowedCandidateIsCleanable() {
+ XCTAssertTrue(makeCandidate(sizeResult: .testComplete()).isCleanable)
+ }
+
+ // MARK: - Scan result projection
+
+ func testScanResultTotalsOnlyCleanableCandidates() {
let result = DiskCleanScanResult(
- choices: [.cache],
+ scope: .rules(choices: [.cache]),
candidates: [
- DiskCleanCandidate(
- id: "a",
- ruleID: "r1",
- choice: .cache,
- title: "A",
- path: "/tmp/a",
- sizeBytes: 10,
- safety: .allowed,
- risk: .low
- ),
- DiskCleanCandidate(
+ makeCandidate(id: "a", path: "/tmp/a", sizeResult: .testComplete(bytes: 10)),
+ makeCandidate(
id: "b",
- ruleID: "r2",
- choice: .cache,
- title: "B",
path: "/tmp/b",
- sizeBytes: 20,
safety: .protected(reason: "protected"),
- risk: .high
- )
+ sizeResult: .testComplete(bytes: 20)
+ ),
+ makeCandidate(id: "c", path: "/tmp/c", sizeResult: nil)
],
scannedAt: Date(timeIntervalSince1970: 0)
)
XCTAssertEqual(result.cleanableSizeBytes, 10)
XCTAssertEqual(result.cleanableCandidates.map(\.id), ["a"])
- XCTAssertEqual(result.protectedCount, 1)
+ }
+
+ func testExpiryDeadlineIsNilWithoutCleanableCandidates() {
+ let result = DiskCleanScanResult(
+ scope: .rules(choices: [.cache]),
+ candidates: [makeCandidate(sizeResult: nil)],
+ scannedAt: Date()
+ )
+
+ XCTAssertNil(result.expiryDeadline)
+ }
+
+ func testExpiryDeadlineIsEarliestObservedAtPlusWindow() {
+ let base = Date(timeIntervalSince1970: 10_000)
+ let result = DiskCleanScanResult(
+ scope: .rules(choices: [.cache]),
+ candidates: [
+ makeCandidate(id: "a", path: "/tmp/a", sizeResult: .testComplete(observedAt: base)),
+ makeCandidate(
+ id: "b",
+ path: "/tmp/b",
+ sizeResult: .testComplete(observedAt: base.addingTimeInterval(60))
+ )
+ ],
+ scannedAt: base
+ )
+
+ XCTAssertEqual(result.expiryDeadline, base.addingTimeInterval(DiskCleanScanFreshness.window))
+ }
+
+ private func makeCandidate(
+ id: String = "a",
+ path: String = "/tmp/a",
+ safety: DiskCleanSafetyStatus = .allowed,
+ sizeResult: DiskCleanSizeResult?
+ ) -> DiskCleanCandidate {
+ DiskCleanCandidate(
+ id: id,
+ targetID: "cache.test",
+ legacyRuleID: "cache.test",
+ category: .appCaches,
+ path: path,
+ risk: .low,
+ safety: safety,
+ sizeResult: sizeResult
+ )
}
}
diff --git a/Plugins/DiskClean/Tests/DiskCleanPlanTests.swift b/Plugins/DiskClean/Tests/DiskCleanPlanTests.swift
new file mode 100644
index 00000000..66a0ac3b
--- /dev/null
+++ b/Plugins/DiskClean/Tests/DiskCleanPlanTests.swift
@@ -0,0 +1,260 @@
+import Foundation
+import XCTest
+@testable import MacTools
+@testable import DiskCleanPlugin
+
+/// Plan minting validation matrix (design §6.1).
+///
+/// Any failed check must `throw` — no plan means no deletes; the cheapest reliable gate on the execution path.
+@MainActor
+final class DiskCleanPlanTests: XCTestCase {
+ private let now = Date(timeIntervalSince1970: 10_000)
+ private let targetID = DiskCleanPlanFactory.targetID
+
+ // MARK: - Happy path
+
+ func testMintsPlanWithFrozenModeTotalsAndEvidence() throws {
+ let first = candidate(path: "/cache/a", bytes: 100)
+ let second = candidate(path: "/cache/b", bytes: 250)
+ let excluded = candidate(path: "/cache/locked", safety: .inUse(processName: "App"))
+
+ let plan = try makePlan(
+ candidates: [first, second, excluded],
+ selectedIDs: [first.id, second.id],
+ reservedRootPaths: ["/reserved/root"]
+ )
+
+ XCTAssertEqual(plan.items.map(\.path), ["/cache/a", "/cache/b"], "execution order follows artifact order and is reproducible")
+ XCTAssertEqual(plan.totalEstimatedBytes, 350)
+ XCTAssertEqual(plan.itemCount, 2)
+ XCTAssertEqual(plan.mode, .permanent)
+ XCTAssertEqual(plan.minObservedAt, DiskCleanPlanFactory.observedAt)
+ XCTAssertEqual(
+ plan.expiryDeadline,
+ DiskCleanPlanFactory.observedAt.addingTimeInterval(DiskCleanScanFreshness.window)
+ )
+ XCTAssertEqual(plan.exclusionPaths, ["/cache/locked"], "candidates not in the plan all go into the exclusion set")
+ XCTAssertEqual(plan.reservedPrefixes, ["/reserved/root"], "reserved prefixes are derived from the artifact so callers cannot omit them")
+ }
+
+ func testPlanItemCarriesTargetLockDeclarations() throws {
+ let candidate = candidate(path: "/cache/a")
+
+ let plan = try makePlan(
+ candidates: [candidate],
+ selectedIDs: [candidate.id],
+ lockedByBundleIDs: ["com.example.app"],
+ skipWhenProcessIsRunning: ["exampled"]
+ )
+
+ XCTAssertEqual(plan.items[0].lockedByBundleIDs, ["com.example.app"])
+ XCTAssertEqual(plan.items[0].skipWhenProcessIsRunning, ["exampled"])
+ XCTAssertEqual(plan.items[0].parentPath, "/cache")
+ XCTAssertEqual(plan.items[0].name, "a")
+ }
+
+ func testMinObservedAtUsesEarliestSelectedItem() throws {
+ let older = candidate(path: "/cache/old", observedAt: now.addingTimeInterval(-100))
+ let newer = candidate(path: "/cache/new", observedAt: now)
+
+ let plan = try makePlan(candidates: [older, newer], selectedIDs: [older.id, newer.id])
+
+ XCTAssertEqual(plan.minObservedAt, now.addingTimeInterval(-100), "expiry gate uses the earliest observation time")
+ }
+
+ // MARK: - Rejection matrix
+
+ func testEmptySelectionIsRejected() {
+ let candidate = candidate(path: "/cache/a")
+
+ assertThrows(.emptySelection) {
+ try makePlan(candidates: [candidate], selectedIDs: [])
+ }
+ }
+
+ /// Unauthorized selection: the provided id is not in the artifact.
+ func testSelectionOutsideArtifactIsRejected() {
+ let candidate = candidate(path: "/cache/a")
+
+ assertThrows(.invalidSelection(candidateID: "ghost")) {
+ try makePlan(candidates: [candidate], selectedIDs: [candidate.id, "ghost"])
+ }
+ }
+
+ /// Unauthorized selection: candidate exists but is not cleanable (locked / protected / allowlisted).
+ func testSelectingNonCleanableCandidateIsRejected() {
+ let locked = candidate(path: "/cache/locked", safety: .inUse(processName: "App"))
+
+ assertThrows(.invalidSelection(candidateID: locked.id)) {
+ try makePlan(candidates: [locked], selectedIDs: [locked.id])
+ }
+ }
+
+ /// Unauthorized selection: completeness is not complete, even if safety allows it.
+ func testSelectingPartialCandidateIsRejected() {
+ let partial = DiskCleanPlanFactory.candidate(
+ path: "/cache/partial",
+ sizeResult: .testPartial(reasons: [.crossedMountPoint], identity: .test())
+ )
+
+ assertThrows(.invalidSelection(candidateID: partial.id)) {
+ try makePlan(candidates: [partial], selectedIDs: [partial.id])
+ }
+ }
+
+ /// Size is known but root identity is missing → executor cannot re-verify; reject.
+ func testSelectingCandidateWithoutRootIdentityIsRejected() {
+ let noIdentity = DiskCleanPlanFactory.candidate(
+ path: "/cache/no-identity",
+ sizeResult: DiskCleanSizeResult(
+ estimatedBytes: 10,
+ fileCount: 1,
+ completeness: .complete,
+ rootIdentity: nil,
+ observedAt: DiskCleanPlanFactory.observedAt
+ )
+ )
+
+ assertThrows(.invalidSelection(candidateID: noIdentity.id)) {
+ try makePlan(candidates: [noIdentity], selectedIDs: [noIdentity.id])
+ }
+ }
+
+ func testUnknownTargetIsRejected() {
+ let orphan = DiskCleanCandidate(
+ id: "orphan",
+ targetID: "target.that.does.not.exist",
+ legacyRuleID: "legacy",
+ category: .appCaches,
+ path: "/cache/orphan",
+ risk: .low,
+ safety: .allowed,
+ sizeResult: .testComplete(identity: .test())
+ )
+
+ assertThrows(.unknownTarget(targetID: "target.that.does.not.exist")) {
+ try makePlan(candidates: [orphan], selectedIDs: [orphan.id])
+ }
+ }
+
+ /// Ancestor conflict: planned path covers a candidate not in the plan.
+ func testPlannedPathCoveringExcludedCandidateIsRejected() {
+ let parent = candidate(path: "/cache/app")
+ let lockedChild = candidate(path: "/cache/app/inner", safety: .inUse(processName: "App"))
+
+ assertThrows(.ancestorViolation(plannedPath: "/cache/app", protectedPath: "/cache/app/inner")) {
+ try makePlan(candidates: [parent, lockedChild], selectedIDs: [parent.id])
+ }
+ }
+
+ /// Ancestor conflict: planned path covers a reserved root of a skipped target (that subtree was never scanned).
+ func testPlannedPathCoveringReservedPrefixIsRejected() {
+ let parent = candidate(path: "/cache/app")
+
+ assertThrows(.ancestorViolation(plannedPath: "/cache/app", protectedPath: "/cache/app/unscanned")) {
+ try makePlan(
+ candidates: [parent],
+ selectedIDs: [parent.id],
+ reservedRootPaths: ["/cache/app/unscanned"]
+ )
+ }
+ }
+
+ /// Equal paths also conflict: the same path both planned and reserved means contradictory evidence.
+ func testPlannedPathEqualToReservedPrefixIsRejected() {
+ let parent = candidate(path: "/cache/app")
+
+ assertThrows(.ancestorViolation(plannedPath: "/cache/app", protectedPath: "/cache/app")) {
+ try makePlan(candidates: [parent], selectedIDs: [parent.id], reservedRootPaths: ["/cache/app"])
+ }
+ }
+
+ /// Shared name prefix is not ancestry: `/cache/app-extra` is not under `/cache/app`.
+ func testSiblingWithSharedNamePrefixIsNotAnAncestorViolation() throws {
+ let parent = candidate(path: "/cache/app")
+ let sibling = candidate(path: "/cache/app-extra", safety: .inUse(processName: "App"))
+
+ let plan = try makePlan(candidates: [parent, sibling], selectedIDs: [parent.id])
+
+ XCTAssertEqual(plan.items.map(\.path), ["/cache/app"])
+ }
+
+ func testExpiredResultIsRejected() {
+ let candidate = candidate(path: "/cache/a", observedAt: now)
+
+ assertThrows(.resultExpired) {
+ try makePlan(
+ candidates: [candidate],
+ selectedIDs: [candidate.id],
+ now: now.addingTimeInterval(DiskCleanScanFreshness.window)
+ )
+ }
+ }
+
+ func testResultOneSecondInsideWindowIsAccepted() throws {
+ let candidate = candidate(path: "/cache/a", observedAt: now)
+
+ let plan = try makePlan(
+ candidates: [candidate],
+ selectedIDs: [candidate.id],
+ now: now.addingTimeInterval(DiskCleanScanFreshness.window - 1)
+ )
+
+ XCTAssertEqual(plan.itemCount, 1)
+ }
+
+ // MARK: - Fixtures
+
+ private func candidate(
+ path: String,
+ bytes: Int64 = 10,
+ safety: DiskCleanSafetyStatus = .allowed,
+ observedAt: Date = DiskCleanPlanFactory.observedAt
+ ) -> DiskCleanCandidate {
+ DiskCleanPlanFactory.candidate(
+ path: path,
+ bytes: bytes,
+ observedAt: observedAt,
+ safety: safety
+ )
+ }
+
+ private func makePlan(
+ candidates: [DiskCleanCandidate],
+ selectedIDs: Set,
+ reservedRootPaths: [String] = [],
+ lockedByBundleIDs: [String] = [],
+ skipWhenProcessIsRunning: [String] = [],
+ now: Date? = nil
+ ) throws -> DiskCleanValidatedPlan {
+ try DiskCleanPlanner.makePlan(
+ artifact: DiskCleanPlanFactory.artifact(
+ candidates: candidates,
+ reservedRootPaths: reservedRootPaths
+ ),
+ selectedIDs: selectedIDs,
+ mode: .permanent,
+ now: now ?? self.now,
+ catalog: DiskCleanPlanFactory.catalog(
+ lockedByBundleIDs: lockedByBundleIDs,
+ skipWhenProcessIsRunning: skipWhenProcessIsRunning
+ )
+ )
+ }
+
+ private func assertThrows(
+ _ expected: DiskCleanPlanError,
+ file: StaticString = #filePath,
+ line: UInt = #line,
+ _ body: () throws -> DiskCleanValidatedPlan
+ ) {
+ do {
+ _ = try body()
+ XCTFail("expected throw \(expected), but plan minting succeeded", file: file, line: line)
+ } catch let error as DiskCleanPlanError {
+ XCTAssertEqual(error, expected, file: file, line: line)
+ } catch {
+ XCTFail("expected DiskCleanPlanError, got: \(error)", file: file, line: line)
+ }
+ }
+}
diff --git a/Plugins/DiskClean/Tests/DiskCleanPluginTests.swift b/Plugins/DiskClean/Tests/DiskCleanPluginTests.swift
index 0f969e60..4ea96c58 100644
--- a/Plugins/DiskClean/Tests/DiskCleanPluginTests.swift
+++ b/Plugins/DiskClean/Tests/DiskCleanPluginTests.swift
@@ -27,11 +27,63 @@ final class DiskCleanPluginTests: XCTestCase {
DiskCleanPlugin.ControlID.openDetails
]
)
- XCTAssertEqual(controls.map(\.actionTitle), ["扫描", "清理", "打开详情"])
+ XCTAssertEqual(controls.map(\.actionTitle), ["扫描", "移到废纸篓", "打开详情"])
XCTAssertFalse(controls.contains { $0.id.hasPrefix("disk-clean-choice.") })
XCTAssertFalse(controls.contains { $0.id == "disk-clean-test-mode" })
}
+ // MARK: - P2 section wiring (design §10)
+
+ /// When scan roots change, the new scope must be pushed to the developer-artifact section.
+ /// If this wire breaks, users clean with stale results against folders just removed —
+ /// the Controller only marks "rescan needed" after receiving the new scope.
+ func testAddingPurgeRootUpdatesDeveloperArtifactScope() {
+ let purgeRoots = DiskCleanPurgeRootsModel(
+ store: DiskCleanPurgeRootsStore(
+ persistence: EphemeralPurgeRootsPersistence(),
+ resolvePhysicalPath: { $0 }
+ )
+ )
+ let developerArtifacts = DiskCleanController(
+ engine: ControlledDiskCleanScanEngine(),
+ initialSnapshot: DiskCleanControllerSnapshot(
+ phase: .idle,
+ scope: .developerArtifacts(roots: []),
+ scanResult: nil,
+ executionResult: nil,
+ isResultStale: false,
+ errorMessage: nil
+ ),
+ removalModeStore: InMemoryDiskCleanRemovalModeStore(mode: .trash)
+ )
+ _ = DiskCleanPlugin(
+ controller: FakeDiskCleanPluginController(),
+ developerArtifactsController: developerArtifacts,
+ purgeRoots: purgeRoots
+ )
+
+ purgeRoots.add("/code")
+
+ XCTAssertEqual(developerArtifacts.snapshot.scope, .developerArtifacts(roots: ["/code"]))
+ XCTAssertTrue(developerArtifacts.snapshot.canScan)
+ }
+
+ /// Menu bar only reflects the rules section: P2 candidate inflow must not rebuild the host menu.
+ func testMenuBarPanelIgnoresSectionControllers() {
+ let controller = FakeDiskCleanPluginController()
+ let installers = DiskCleanController(
+ engine: ControlledDiskCleanScanEngine(),
+ removalModeStore: InMemoryDiskCleanRemovalModeStore(mode: .trash)
+ )
+ let plugin = DiskCleanPlugin(controller: controller, installersController: installers)
+ var stateChanges = 0
+ plugin.onStateChange = { stateChanges += 1 }
+
+ installers.setScope(.installers)
+
+ XCTAssertEqual(stateChanges, 0)
+ }
+
func testInvokingScanForwardsToController() {
let controller = FakeDiskCleanPluginController()
let plugin = DiskCleanPlugin(controller: controller)
@@ -41,46 +93,298 @@ final class DiskCleanPluginTests: XCTestCase {
XCTAssertEqual(controller.scanCallCount, 1)
}
- func testInvokingCleanForwardsAllCleanableCandidates() {
+ /// The panel no longer decides "what to clean": it only forwards commands to the Controller; selection is Controller-owned state.
+ func testInvokingCleanForwardsToControllerWithoutComposingItsOwnSelection() {
+ let controller = FakeDiskCleanPluginController()
+ let plugin = DiskCleanPlugin(controller: controller)
+ controller.snapshot = makeScannedSnapshot(
+ candidates: [
+ makePluginTestCandidate(
+ id: "allowed",
+ path: "/Users/tester/Library/Caches/App",
+ sizeResult: .testComplete(bytes: 10)
+ )
+ ],
+ selection: makeSelection(selected: ["allowed"], bytes: 10)
+ )
+
+ plugin.handleAction(.invokeAction(controlID: DiskCleanPlugin.ControlID.clean))
+
+ XCTAssertEqual(controller.cleanCallCount, 1)
+ }
+
+ /// Button copy must state how many items and roughly how much, and say what the removal mode will do (design §8.2, §8.4).
+ func testCleanActionTitleReportsSelectionAndRemovalMode() throws {
+ let controller = FakeDiskCleanPluginController()
+ let plugin = DiskCleanPlugin(controller: controller)
+ let candidates = [
+ makePluginTestCandidate(
+ id: "a",
+ path: "/Users/tester/Library/Caches/App",
+ sizeResult: .testComplete(bytes: 5_368_709_120)
+ )
+ ]
+
+ controller.snapshot = makeScannedSnapshot(
+ candidates: candidates,
+ selection: makeSelection(selected: ["a"], bytes: 5_368_709_120)
+ )
+ plugin.handleAction(.setDisclosureExpanded(true))
+ let trashTitle = try XCTUnwrap(try cleanControl(of: plugin).actionTitle)
+ XCTAssertTrue(trashTitle.hasPrefix("移到废纸篓 · 1 项 · 约"), "actual: \(trashTitle)")
+ XCTAssertTrue(trashTitle.contains("GB"), "actual: \(trashTitle)")
+
+ controller.snapshot = makeScannedSnapshot(
+ candidates: candidates,
+ removalMode: .permanent,
+ selection: makeSelection(selected: ["a"], bytes: 5_368_709_120)
+ )
+ let permanentTitle = try XCTUnwrap(try cleanControl(of: plugin).actionTitle)
+ XCTAssertTrue(permanentTitle.hasPrefix("清理 · 1 项 · 约"), "actual: \(permanentTitle)")
+ }
+
+ func testCleanIsDisabledWhenNothingIsSelected() throws {
+ let controller = FakeDiskCleanPluginController()
+ let plugin = DiskCleanPlugin(controller: controller)
+ controller.snapshot = makeScannedSnapshot(
+ candidates: [
+ makePluginTestCandidate(
+ id: "a",
+ path: "/Users/tester/Library/Caches/App",
+ sizeResult: .testComplete(bytes: 10)
+ )
+ ],
+ selection: makeSelection(selected: [], selectable: ["a"], bytes: 0)
+ )
+
+ plugin.handleAction(.setDisclosureExpanded(true))
+
+ XCTAssertEqual(try cleanControl(of: plugin).isEnabled, false)
+ XCTAssertEqual(try cleanControl(of: plugin).actionTitle, "移到废纸篓")
+ }
+
+ /// "Open Details" must actually switch the settings window; before M4 it was a no-op.
+ func testOpenDetailsRequestsConfigurationPresentation() {
+ let plugin = DiskCleanPlugin(controller: FakeDiskCleanPluginController())
+ var presentationRequests = 0
+ plugin.requestConfigurationPresentation = { presentationRequests += 1 }
+
+ plugin.handleAction(.invokeAction(controlID: DiskCleanPlugin.ControlID.openDetails))
+
+ XCTAssertEqual(presentationRequests, 1)
+ }
+
+ /// The "(limited)" suffix always derives from limitations, not from happening to scan a protected candidate (design §4.5, §8.2).
+ func testPanelSubtitleAppendsLimitedSuffixWhenScanReportsLimitations() {
+ let controller = FakeDiskCleanPluginController()
+ let plugin = DiskCleanPlugin(controller: controller)
+ let candidates = [
+ makePluginTestCandidate(
+ id: "allowed",
+ path: "/Users/tester/Library/Caches/App",
+ sizeResult: .testComplete(bytes: 1_024)
+ )
+ ]
+
+ controller.snapshot = makeScannedSnapshot(candidates: candidates)
+ let plain = plugin.primaryPanelState.subtitle
+
+ controller.snapshot = makeScannedSnapshot(
+ candidates: candidates,
+ limitations: [.fdaRestricted(skippedTargetIDs: ["cache.system"])]
+ )
+ let limited = plugin.primaryPanelState.subtitle
+
+ XCTAssertFalse(plain.hasSuffix("(受限)"))
+ XCTAssertEqual(limited, plain + "(受限)")
+ }
+
+ func testPanelDisablesCleanWhenResultExpired() {
+ let controller = FakeDiskCleanPluginController()
+ let plugin = DiskCleanPlugin(controller: controller)
+ controller.snapshot = makeScannedSnapshot(
+ candidates: [
+ makePluginTestCandidate(
+ id: "allowed",
+ path: "/Users/tester/Library/Caches/App",
+ sizeResult: .testComplete(bytes: 10)
+ )
+ ],
+ isResultExpired: true
+ )
+
+ plugin.handleAction(.setDisclosureExpanded(true))
+ let controls = plugin.primaryPanelState.detail?.primaryControls ?? []
+
+ XCTAssertEqual(controls.first { $0.id == DiskCleanPlugin.ControlID.clean }?.isEnabled, false)
+ }
+
+ /// Permanent-delete confirmation: panel swaps to Confirm/Cancel, and Clean must disappear —
+ /// the same button must not swing between two meanings.
+ func testConfirmingPhaseReplacesCleanActionWithConfirmAndCancel() throws {
+ let controller = FakeDiskCleanPluginController()
+ let plugin = DiskCleanPlugin(controller: controller)
+ controller.snapshot = makeConfirmingSnapshot(itemCount: 3, totalEstimatedBytes: 5_368_709_120)
+
+ plugin.handleAction(.setDisclosureExpanded(true))
+ let controls = try XCTUnwrap(plugin.primaryPanelState.detail?.primaryControls)
+
+ XCTAssertEqual(
+ controls.map(\.id),
+ [
+ DiskCleanPlugin.ControlID.scan,
+ DiskCleanPlugin.ControlID.confirmClean,
+ DiskCleanPlugin.ControlID.cancelClean,
+ DiskCleanPlugin.ControlID.openDetails
+ ]
+ )
+ let confirm = try XCTUnwrap(controls.first { $0.id == DiskCleanPlugin.ControlID.confirmClean })
+ let confirmTitle = try XCTUnwrap(confirm.actionTitle)
+ XCTAssertTrue(confirmTitle.hasPrefix("确认永久清理 3 项 · 约"), "actual: \(confirmTitle)")
+ XCTAssertTrue(confirmTitle.contains("GB"), "frozen byte count must appear in the confirmation copy")
+ }
+
+ func testConfirmAndCancelActionsForwardToController() {
+ let controller = FakeDiskCleanPluginController()
+ let plugin = DiskCleanPlugin(controller: controller)
+ controller.snapshot = makeConfirmingSnapshot(itemCount: 1, totalEstimatedBytes: 1_024)
+
+ plugin.handleAction(.invokeAction(controlID: DiskCleanPlugin.ControlID.confirmClean))
+ plugin.handleAction(.invokeAction(controlID: DiskCleanPlugin.ControlID.cancelClean))
+
+ XCTAssertEqual(controller.confirmCallCount, 1)
+ XCTAssertEqual(controller.cancelPendingCallCount, 1)
+ }
+
+ /// Trash completion copy must not say "freed": objects still sit in Trash, space is not reclaimed yet (design §7.7).
+ func testTrashCompletionSubtitleDoesNotClaimSpaceWasReclaimed() {
let controller = FakeDiskCleanPluginController()
let plugin = DiskCleanPlugin(controller: controller)
controller.snapshot = DiskCleanControllerSnapshot(
- phase: .scanned,
- selectedChoices: Set(DiskCleanChoice.allCases),
- scanResult: DiskCleanScanResult(
- choices: Set(DiskCleanChoice.allCases),
- candidates: [
- DiskCleanCandidate(
- id: "allowed",
- ruleID: "rule",
- choice: .cache,
- title: "Cache",
- path: "/Users/tester/Library/Caches/App",
- sizeBytes: 10,
- safety: .allowed,
- risk: .low
- ),
- DiskCleanCandidate(
- id: "protected",
- ruleID: "rule",
- choice: .cache,
- title: "Cache",
- path: "/Users/tester/Library/Keychains/login.keychain-db",
- sizeBytes: 10,
- safety: .protected(reason: "credentials"),
- risk: .low
+ phase: .completed,
+ scope: .rules(choices: Set(DiskCleanChoice.allCases)),
+ scanResult: nil,
+ executionResult: DiskCleanExecutionResult(
+ itemResults: [
+ DiskCleanExecutionItemResult(
+ candidateID: "a",
+ path: "/cache/a",
+ outcome: .trashed(reclaimedBytes: 1_024, stagedName: ".mactools-staged-a")
)
],
- scannedAt: Date(timeIntervalSince1970: 0)
+ mode: .trash
),
- executionResult: nil,
isResultStale: false,
errorMessage: nil
)
- plugin.handleAction(.invokeAction(controlID: DiskCleanPlugin.ControlID.clean))
+ let subtitle = plugin.primaryPanelState.subtitle
+ XCTAssertTrue(subtitle.hasPrefix("已移到废纸篓约"), "actual: \(subtitle)")
+ XCTAssertFalse(subtitle.contains("已释放"), "objects in Trash have not truly freed space")
+ XCTAssertTrue(subtitle.contains("KB"), "actual: \(subtitle)")
+ }
- XCTAssertEqual(controller.cleanSelectedCalls, [["allowed"]])
+ /// Startup reconciliation must run on activate, or orphan staged objects have no second discovery path.
+ func testActivateTriggersStagingReconciliation() async {
+ let reconciler = SpyDiskCleanStagingReconciler()
+ let storage = URL(fileURLWithPath: "/tmp/diskclean-plugin-activate-test")
+ let plugin = DiskCleanPlugin(
+ controller: FakeDiskCleanPluginController(),
+ storageDirectory: storage,
+ reconciler: reconciler
+ )
+
+ plugin.activate(context: PluginRuntimeContext(pluginID: "disk-clean"))
+
+ await waitUntil("reconciliation was triggered") { !reconciler.reconciledDirectories.isEmpty }
+ XCTAssertEqual(reconciler.reconciledDirectories, [storage])
+ }
+
+ private func makeConfirmingSnapshot(
+ itemCount: Int,
+ totalEstimatedBytes: Int64
+ ) -> DiskCleanControllerSnapshot {
+ DiskCleanControllerSnapshot(
+ phase: .confirming,
+ scope: .rules(choices: Set(DiskCleanChoice.allCases)),
+ scanResult: nil,
+ executionResult: nil,
+ isResultStale: false,
+ errorMessage: nil,
+ removalMode: .permanent,
+ pendingPlan: DiskCleanPendingPlanSummary(
+ itemCount: itemCount,
+ totalEstimatedBytes: totalEstimatedBytes,
+ mode: .permanent
+ )
+ )
+ }
+
+ private func cleanControl(of plugin: DiskCleanPlugin) throws -> PluginPanelControl {
+ let controls = plugin.primaryPanelState.detail?.primaryControls ?? []
+ return try XCTUnwrap(controls.first { $0.id == DiskCleanPlugin.ControlID.clean })
+ }
+
+ /// Default-select all cleanable candidates so Clean-button availability is decided by the condition under test, not masked by an empty selection.
+ private func makeScannedSnapshot(
+ candidates: [DiskCleanCandidate],
+ limitations: [DiskCleanScanLimitation] = [],
+ isResultExpired: Bool = false,
+ removalMode: DiskCleanRemovalMode = .trash,
+ selection: DiskCleanSelectionProjection? = nil
+ ) -> DiskCleanControllerSnapshot {
+ let cleanable = candidates.filter(\.isCleanable)
+ let resolvedSelection = selection ?? makeSelection(
+ selected: Set(cleanable.map(\.id)),
+ bytes: cleanable.reduce(0) { $0 + $1.estimatedBytes }
+ )
+ return DiskCleanControllerSnapshot(
+ phase: .scanned,
+ scope: .rules(choices: Set(DiskCleanChoice.allCases)),
+ scanResult: DiskCleanScanResult(
+ scope: .rules(choices: Set(DiskCleanChoice.allCases)),
+ candidates: candidates,
+ scannedAt: Date(timeIntervalSince1970: 0),
+ limitations: limitations
+ ),
+ executionResult: nil,
+ isResultStale: false,
+ isResultExpired: isResultExpired,
+ errorMessage: nil,
+ removalMode: removalMode,
+ selection: resolvedSelection
+ )
+ }
+
+ private func makeSelection(
+ selected: Set,
+ selectable: Set? = nil,
+ bytes: Int64
+ ) -> DiskCleanSelectionProjection {
+ DiskCleanSelectionProjection(
+ selectedIDs: selected,
+ selectableIDs: selectable ?? selected,
+ selectedEstimatedBytes: bytes,
+ categoryStates: [.appCaches: selected.isEmpty ? .noneSelected : .allSelected]
+ )
+ }
+
+ private func makePluginTestCandidate(
+ id: String,
+ path: String,
+ safety: DiskCleanSafetyStatus = .allowed,
+ sizeResult: DiskCleanSizeResult?
+ ) -> DiskCleanCandidate {
+ DiskCleanCandidate(
+ id: id,
+ targetID: "cache.rule",
+ legacyRuleID: "cache.rule",
+ category: .appCaches,
+ path: path,
+ risk: .low,
+ safety: safety,
+ sizeResult: sizeResult
+ )
}
func testOpenDetailsActionUsesMenuBarStableActionID() throws {
@@ -132,34 +436,72 @@ private final class FakeDiskCleanPluginController: DiskCleanControlling {
private(set) var scanCallCount = 0
private(set) var canceledOperationCount = 0
private(set) var selectedChoiceChanges: [(choice: DiskCleanChoice, isSelected: Bool)] = []
- private(set) var cleanSelectedCalls: [Set] = []
+ private(set) var cleanCallCount = 0
+ private(set) var confirmCallCount = 0
+ private(set) var cancelPendingCallCount = 0
+ private(set) var removalModeChanges: [DiskCleanRemovalMode] = []
+ private(set) var candidateSelectionChanges: [(id: DiskCleanCandidate.ID, isSelected: Bool)] = []
+ private(set) var categorySelectionChanges: [(category: DiskCleanCategoryID, isSelected: Bool)] = []
- func setChoice(_ choice: DiskCleanChoice, isSelected: Bool) {
- selectedChoiceChanges.append((choice: choice, isSelected: isSelected))
- var nextChoices = snapshot.selectedChoices
- if isSelected {
- nextChoices.insert(choice)
- } else {
- nextChoices.remove(choice)
- }
+ private(set) var scopeChanges: [DiskCleanScanScope] = []
+
+ func setScope(_ scope: DiskCleanScanScope) {
+ scopeChanges.append(scope)
snapshot = DiskCleanControllerSnapshot(
phase: snapshot.phase,
- selectedChoices: nextChoices,
+ scope: scope,
scanResult: snapshot.scanResult,
executionResult: snapshot.executionResult,
isResultStale: snapshot.isResultStale,
+ isResultExpired: snapshot.isResultExpired,
errorMessage: snapshot.errorMessage
)
onStateChange?()
}
+ func setChoice(_ choice: DiskCleanChoice, isSelected: Bool) {
+ selectedChoiceChanges.append((choice: choice, isSelected: isSelected))
+ var nextChoices = snapshot.selectedChoices
+ if isSelected {
+ nextChoices.insert(choice)
+ } else {
+ nextChoices.remove(choice)
+ }
+ setScope(.rules(choices: nextChoices))
+ }
+
func scan() {
scanCallCount += 1
onStateChange?()
}
- func cleanSelected(candidateIDs: Set) {
- cleanSelectedCalls.append(candidateIDs)
+ func clean() {
+ cleanCallCount += 1
+ onStateChange?()
+ }
+
+ func setCandidateSelected(_ candidateID: DiskCleanCandidate.ID, isSelected: Bool) {
+ candidateSelectionChanges.append((id: candidateID, isSelected: isSelected))
+ onStateChange?()
+ }
+
+ func setCategorySelection(_ category: DiskCleanCategoryID, isSelected: Bool) {
+ categorySelectionChanges.append((category: category, isSelected: isSelected))
+ onStateChange?()
+ }
+
+ func confirmPendingClean() {
+ confirmCallCount += 1
+ onStateChange?()
+ }
+
+ func cancelPendingClean() {
+ cancelPendingCallCount += 1
+ onStateChange?()
+ }
+
+ func setRemovalMode(_ mode: DiskCleanRemovalMode) {
+ removalModeChanges.append(mode)
onStateChange?()
}
diff --git a/Plugins/DiskClean/Tests/DiskCleanPurgeRootsModelTests.swift b/Plugins/DiskClean/Tests/DiskCleanPurgeRootsModelTests.swift
new file mode 100644
index 00000000..8c375731
--- /dev/null
+++ b/Plugins/DiskClean/Tests/DiskCleanPurgeRootsModelTests.swift
@@ -0,0 +1,176 @@
+import XCTest
+@testable import MacTools
+@testable import DiskCleanPlugin
+
+/// UI state for purge-root management (design §10.1 settings).
+@MainActor
+final class DiskCleanPurgeRootsModelTests: XCTestCase {
+ func testLoadsPersistedRootsOnInit() {
+ let model = makeModel(persisted: ["/code", "/work"])
+
+ XCTAssertEqual(model.roots, ["/code", "/work"])
+ XCTAssertEqual(model.scope, .developerArtifacts(roots: ["/code", "/work"]))
+ XCTAssertFalse(model.isEmpty)
+ }
+
+ func testStartsEmptyByDefault() {
+ let model = makeModel()
+
+ XCTAssertTrue(model.isEmpty)
+ XCTAssertEqual(model.scope, .developerArtifacts(roots: []))
+ }
+
+ // MARK: - Scope linkage
+
+ /// Any root change must push the new scope to the section controller, or users may clean with stale results against a just-removed folder.
+ func testAddingRootNotifiesScopeChange() {
+ let model = makeModel()
+ var observed: [[String]] = []
+ model.onRootsChange = { observed.append($0) }
+
+ model.add("/code")
+
+ XCTAssertEqual(model.roots, ["/code"])
+ XCTAssertEqual(observed, [["/code"]])
+ }
+
+ func testRemovingRootNotifiesScopeChange() {
+ let model = makeModel(persisted: ["/code", "/work"])
+ var observed: [[String]] = []
+ model.onRootsChange = { observed.append($0) }
+
+ model.remove("/code")
+
+ XCTAssertEqual(model.roots, ["/work"])
+ XCTAssertEqual(observed, [["/work"]])
+ }
+
+ /// Rejected additions leave the root set unchanged; do not fake a scope change or the UI will spuriously prompt to rescan.
+ func testRejectedAdditionDoesNotNotifyScopeChange() {
+ let model = makeModel(persisted: ["/code"])
+ var observed: [[String]] = []
+ model.onRootsChange = { observed.append($0) }
+
+ model.add("/code")
+
+ XCTAssertEqual(model.roots, ["/code"])
+ XCTAssertTrue(observed.isEmpty)
+ }
+
+ // MARK: - Rejection feedback
+
+ func testDuplicateAdditionSurfacesReason() {
+ let model = makeModel(persisted: ["/code"])
+
+ model.add("/code")
+
+ XCTAssertEqual(model.rejections, [.duplicate(path: "/code")])
+ }
+
+ /// Ancestor adjudication keeps the ancestor and drops the descendant; the dropped path needs a readable reason.
+ /// Silent discard makes users think they mis-clicked and try again.
+ func testDescendantAdditionSurfacesCoveringAncestor() {
+ let model = makeModel(persisted: ["/code"])
+
+ model.add("/code/app")
+
+ XCTAssertEqual(model.roots, ["/code"])
+ XCTAssertEqual(model.rejections, [.coveredByAncestor(path: "/code/app", ancestor: "/code")])
+ }
+
+ func testUnresolvablePathSurfacesReason() {
+ let model = makeModel(resolvePhysicalPath: { _ in nil })
+
+ model.add("/nowhere")
+
+ XCTAssertTrue(model.roots.isEmpty)
+ XCTAssertEqual(model.rejections, [.unresolvable(path: "/nowhere")])
+ }
+
+ func testTooBroadRootSurfacesReasonWithoutChangingScope() {
+ let model = makeModel(persisted: ["/code"])
+ var observed: [[String]] = []
+ model.onRootsChange = { observed.append($0) }
+
+ model.add(NSHomeDirectory())
+
+ XCTAssertEqual(model.roots, ["/code"])
+ XCTAssertEqual(model.rejections, [.tooBroad(path: NSHomeDirectory())])
+ XCTAssertTrue(observed.isEmpty)
+ }
+
+ func testSuccessfulAdditionClearsPreviousRejections() {
+ let model = makeModel(persisted: ["/code"])
+ model.add("/code")
+ XCTAssertFalse(model.rejections.isEmpty)
+
+ model.add("/work")
+
+ XCTAssertTrue(model.rejections.isEmpty)
+ XCTAssertEqual(model.roots, ["/code", "/work"])
+ }
+
+ func testDismissClearsRejections() {
+ let model = makeModel(persisted: ["/code"])
+ model.add("/code")
+
+ model.dismissRejections()
+
+ XCTAssertTrue(model.rejections.isEmpty)
+ }
+
+ // MARK: - Persistence
+
+ func testAdditionsAndRemovalsArePersisted() {
+ let persistence = InMemoryPurgeRootsPersistence(roots: [])
+ let model = makeModel(persistence: persistence)
+
+ model.add("/code")
+ model.add("/work")
+ model.remove("/code")
+
+ XCTAssertEqual(persistence.loadRoots(), ["/work"])
+ }
+
+ // MARK: - Helpers
+
+ private func makeModel(
+ persisted: [String] = [],
+ resolvePhysicalPath: @escaping @Sendable (String) -> String? = { $0 }
+ ) -> DiskCleanPurgeRootsModel {
+ makeModel(
+ persistence: InMemoryPurgeRootsPersistence(roots: persisted),
+ resolvePhysicalPath: resolvePhysicalPath
+ )
+ }
+
+ private func makeModel(
+ persistence: InMemoryPurgeRootsPersistence,
+ resolvePhysicalPath: @escaping @Sendable (String) -> String? = { $0 }
+ ) -> DiskCleanPurgeRootsModel {
+ DiskCleanPurgeRootsModel(
+ store: DiskCleanPurgeRootsStore(
+ persistence: persistence,
+ resolvePhysicalPath: resolvePhysicalPath
+ )
+ )
+ }
+}
+
+/// In-memory persistence. Never touch `UserDefaults.standard` — that is the user's real config.
+private final class InMemoryPurgeRootsPersistence: DiskCleanPurgeRootsPersisting, @unchecked Sendable {
+ private let lock = NSLock()
+ private var storedRoots: [String]
+
+ init(roots: [String]) {
+ self.storedRoots = roots
+ }
+
+ func loadRoots() -> [String] {
+ lock.withLock { storedRoots }
+ }
+
+ func saveRoots(_ roots: [String]) {
+ lock.withLock { storedRoots = roots }
+ }
+}
diff --git a/Plugins/DiskClean/Tests/DiskCleanPurgeRootsStoreTests.swift b/Plugins/DiskClean/Tests/DiskCleanPurgeRootsStoreTests.swift
new file mode 100644
index 00000000..f9b35b27
--- /dev/null
+++ b/Plugins/DiskClean/Tests/DiskCleanPurgeRootsStoreTests.swift
@@ -0,0 +1,263 @@
+import XCTest
+@testable import MacTools
+@testable import DiskCleanPlugin
+
+final class DiskCleanPurgeRootsStoreTests: XCTestCase {
+ private var temporaryDirectory: DiskCleanTempDirectory!
+ private var persistence: InMemoryDiskCleanPurgeRootsPersistence!
+ private var store: DiskCleanPurgeRootsStore!
+
+ override func setUpWithError() throws {
+ try super.setUpWithError()
+ temporaryDirectory = try DiskCleanTempDirectory(name: "DiskCleanPurgeRootsStoreTests")
+ persistence = InMemoryDiskCleanPurgeRootsPersistence()
+ store = DiskCleanPurgeRootsStore(persistence: persistence)
+ }
+
+ override func tearDownWithError() throws {
+ temporaryDirectory?.remove()
+ temporaryDirectory = nil
+ persistence = nil
+ store = nil
+ try super.tearDownWithError()
+ }
+
+ // MARK: - Normalization
+
+ /// Users may pick a symlinked folder; store the physical path or `O_NOFOLLOW_ANY` rejects the whole tree.
+ func testStoresPhysicalPathForSymlinkedRoot() throws {
+ let real = try temporaryDirectory.makeDirectory("Projects")
+ let link = try temporaryDirectory.makeSymlink("ProjectsLink", destination: "Projects")
+
+ let update = store.add(link.path)
+
+ XCTAssertEqual(update.roots, [real.path])
+ XCTAssertEqual(persistence.storedRoots, [real.path])
+ }
+
+ /// Two spellings of the same directory keep only one root.
+ func testRejectsDuplicateAfterNormalization() throws {
+ let real = try temporaryDirectory.makeDirectory("Code")
+ try temporaryDirectory.makeSymlink("CodeLink", destination: "Code")
+
+ store.add(real.path)
+ let update = store.add(temporaryDirectory.resolve("CodeLink").path)
+
+ XCTAssertEqual(update.roots, [real.path])
+ XCTAssertEqual(update.rejections.count, 1)
+ guard case .duplicate = update.rejections[0] else {
+ return XCTFail("duplicate root should report .duplicate, got \(update.rejections[0])")
+ }
+ }
+
+ /// A trailing slash must not create a second root.
+ func testTrailingSlashIsNotADistinctRoot() throws {
+ let real = try temporaryDirectory.makeDirectory("Work")
+
+ store.add(real.path)
+ let update = store.add(real.path + "/")
+
+ XCTAssertEqual(update.roots, [real.path])
+ }
+
+ // MARK: - Ancestor adjudication
+
+ func testDropsDescendantWhenAncestorIsAdded() throws {
+ let ancestor = try temporaryDirectory.makeDirectory("Repos")
+ let descendant = try temporaryDirectory.makeDirectory("Repos/app")
+
+ let update = store.replaceAll(with: [ancestor.path, descendant.path])
+
+ XCTAssertEqual(update.roots, [ancestor.path])
+ XCTAssertEqual(update.rejections, [.coveredByAncestor(path: descendant.path, ancestor: ancestor.path)])
+ }
+
+ /// Adjudication is order-independent: descendant then ancestor still keeps the ancestor. Shrinking the user's explicit scope is the worse failure.
+ func testDropsDescendantEvenWhenItWasAddedFirst() throws {
+ let ancestor = try temporaryDirectory.makeDirectory("Repos")
+ let descendant = try temporaryDirectory.makeDirectory("Repos/app")
+
+ store.add(descendant.path)
+ let update = store.add(ancestor.path)
+
+ XCTAssertEqual(update.roots, [ancestor.path])
+ XCTAssertEqual(persistence.storedRoots, [ancestor.path])
+ }
+
+ /// Shared prefix is not ancestry: `/tmp/x/Repos` does not cover `/tmp/x/ReposBackup`.
+ func testKeepsSiblingWithSharedPrefix() throws {
+ let first = try temporaryDirectory.makeDirectory("Repos")
+ let second = try temporaryDirectory.makeDirectory("ReposBackup")
+
+ let update = store.replaceAll(with: [first.path, second.path])
+
+ XCTAssertEqual(update.roots, [first.path, second.path])
+ XCTAssertTrue(update.rejections.isEmpty)
+ }
+
+ func testAncestorCheckIgnoresSelf() {
+ XCTAssertFalse(DiskCleanPurgeRootNormalizer.isStrictAncestor("/a/b", of: "/a/b"))
+ XCTAssertTrue(DiskCleanPurgeRootNormalizer.isStrictAncestor("/a/b", of: "/a/b/c"))
+ XCTAssertFalse(DiskCleanPurgeRootNormalizer.isStrictAncestor("/a/b", of: "/a/bc"))
+ XCTAssertTrue(DiskCleanPurgeRootNormalizer.isStrictAncestor("/", of: "/a"))
+ }
+
+ // MARK: - Unresolvable
+
+ func testRejectsMissingPath() {
+ let missing = temporaryDirectory.resolve("NotThere").path
+
+ let update = store.add(missing)
+
+ XCTAssertTrue(update.roots.isEmpty)
+ XCTAssertEqual(update.rejections, [.unresolvable(path: missing)])
+ }
+
+ /// Relative paths would be resolved by `realpath` against cwd into a directory the user never chose; reject them outright.
+ func testRejectsRelativePath() {
+ let update = store.add("Documents/Code")
+
+ XCTAssertTrue(update.roots.isEmpty)
+ XCTAssertEqual(update.rejections, [.unresolvable(path: "Documents/Code")])
+ }
+
+ func testExpandsTildePrefix() throws {
+ let resolved = DiskCleanPurgeRootNormalizer.normalize(["~/Anywhere"]) { path in
+ path.hasPrefix(NSHomeDirectory() + "/") ? path : nil
+ }
+
+ XCTAssertEqual(resolved.roots, [NSHomeDirectory() + "/Anywhere"])
+ }
+
+ // MARK: - Too-broad denylist
+
+ func testRejectsHomeDirectoryAsRoot() {
+ let home = NSHomeDirectory()
+ let update = DiskCleanPurgeRootNormalizer.normalize([home]) { $0 }
+
+ XCTAssertTrue(update.roots.isEmpty)
+ XCTAssertEqual(update.rejections, [.tooBroad(path: home)])
+ }
+
+ func testRejectsSystemAndApplicationsRoots() {
+ for path in ["/", "/Applications", "/System", "/Users", "/Library", "/Volumes"] {
+ let update = DiskCleanPurgeRootNormalizer.normalize([path]) { $0 }
+ XCTAssertEqual(update.rejections, [.tooBroad(path: path)], path)
+ XCTAssertTrue(update.roots.isEmpty, path)
+ }
+ }
+
+ func testRejectsTopLevelHomeFoldersButAllowsProjectSubfolders() {
+ let home = NSHomeDirectory()
+ let documents = home + "/Documents"
+ let project = documents + "/MyApp"
+
+ let rejected = DiskCleanPurgeRootNormalizer.normalize([documents]) { $0 }
+ XCTAssertEqual(rejected.rejections, [.tooBroad(path: documents)])
+
+ let allowed = DiskCleanPurgeRootNormalizer.normalize([project]) { $0 }
+ XCTAssertEqual(allowed.roots, [project])
+ XCTAssertTrue(allowed.rejections.isEmpty)
+ }
+
+ /// Denylist covers any account's home, not just the current user's: `/Users//Documents`
+ /// is exactly as broad as the current user's own Documents folder.
+ func testRejectsOtherUsersTopLevelPersonalFoldersButAllowsSubfolders() {
+ let rejectedHome = DiskCleanPurgeRootNormalizer.normalize(["/Users/alice"]) { $0 }
+ XCTAssertEqual(rejectedHome.rejections, [.tooBroad(path: "/Users/alice")])
+
+ let rejectedDocuments = DiskCleanPurgeRootNormalizer.normalize(["/Users/alice/Documents"]) { $0 }
+ XCTAssertEqual(rejectedDocuments.rejections, [.tooBroad(path: "/Users/alice/Documents")])
+
+ let allowedProject = DiskCleanPurgeRootNormalizer.normalize(["/Users/alice/Documents/MyApp"]) { $0 }
+ XCTAssertEqual(allowedProject.roots, ["/Users/alice/Documents/MyApp"])
+ XCTAssertTrue(allowedProject.rejections.isEmpty)
+ }
+
+ func testSanitizeDropsPersistedTooBroadRoots() throws {
+ let home = NSHomeDirectory()
+ let project = try temporaryDirectory.makeDirectory("Code").path
+ persistence.storedRoots = [home, "/Applications", project, "/"]
+
+ XCTAssertEqual(store.roots(), [project])
+ XCTAssertEqual(persistence.storedRoots, [project])
+ }
+
+ func testAddingValidRootDoesNotResurfaceSanitizedTooBroadEntries() throws {
+ let home = NSHomeDirectory()
+ let project = try temporaryDirectory.makeDirectory("App").path
+ persistence.storedRoots = [home]
+
+ let update = store.add(project)
+
+ XCTAssertEqual(update.roots, [project])
+ XCTAssertTrue(update.rejections.isEmpty)
+ XCTAssertEqual(persistence.storedRoots, [project])
+ }
+
+ // MARK: - Add/remove
+
+ func testRemoveMatchesOriginalSpellingWhenPathIsGone() throws {
+ let directory = try temporaryDirectory.makeDirectory("Gone")
+ store.add(directory.path)
+ try FileManager.default.removeItem(at: directory)
+
+ let remaining = store.remove(directory.path)
+
+ XCTAssertTrue(remaining.isEmpty)
+ XCTAssertTrue(persistence.storedRoots.isEmpty)
+ }
+
+ func testRemoveMatchesPhysicalPathForSymlinkedSpelling() throws {
+ let real = try temporaryDirectory.makeDirectory("Live")
+ let link = try temporaryDirectory.makeSymlink("LiveLink", destination: "Live")
+ store.add(real.path)
+
+ let remaining = store.remove(link.path)
+
+ XCTAssertTrue(remaining.isEmpty)
+ }
+
+ func testRootsReturnsStoredValuesUnchanged() {
+ persistence.storedRoots = ["/tmp/one", "/tmp/two"]
+
+ XCTAssertEqual(store.roots(), ["/tmp/one", "/tmp/two"])
+ }
+
+ // MARK: - UserDefaults implementation
+
+ func testUserDefaultsPersistenceRoundTrip() throws {
+ let suiteName = "DiskCleanPurgeRootsStoreTests-\(UUID().uuidString)"
+ let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName))
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+ let persistence = UserDefaultsDiskCleanPurgeRootsPersistence(defaults: defaults)
+
+ XCTAssertTrue(persistence.loadRoots().isEmpty)
+ persistence.saveRoots(["/tmp/a", "/tmp/b"])
+
+ XCTAssertEqual(persistence.loadRoots(), ["/tmp/a", "/tmp/b"])
+ XCTAssertEqual(
+ defaults.stringArray(forKey: UserDefaultsDiskCleanPurgeRootsPersistence.defaultsKey),
+ ["/tmp/a", "/tmp/b"]
+ )
+ }
+}
+
+/// In-memory persistence: purge-root tests never touch `UserDefaults.standard`.
+final class InMemoryDiskCleanPurgeRootsPersistence: DiskCleanPurgeRootsPersisting, @unchecked Sendable {
+ private let lock = NSLock()
+ private var roots: [String] = []
+
+ var storedRoots: [String] {
+ get { lock.withLock { roots } }
+ set { lock.withLock { roots = newValue } }
+ }
+
+ func loadRoots() -> [String] {
+ storedRoots
+ }
+
+ func saveRoots(_ roots: [String]) {
+ storedRoots = roots
+ }
+}
diff --git a/Plugins/DiskClean/Tests/DiskCleanPurgeScannerTests.swift b/Plugins/DiskClean/Tests/DiskCleanPurgeScannerTests.swift
new file mode 100644
index 00000000..6e30cdb1
--- /dev/null
+++ b/Plugins/DiskClean/Tests/DiskCleanPurgeScannerTests.swift
@@ -0,0 +1,611 @@
+import Darwin
+import Foundation
+import XCTest
+@testable import MacTools
+@testable import DiskCleanPlugin
+
+final class DiskCleanPurgeScannerTests: XCTestCase {
+ private var temporaryDirectory: DiskCleanTempDirectory!
+ private var discovery: DiskCleanPurgeDiscovery!
+
+ override func setUpWithError() throws {
+ try super.setUpWithError()
+ temporaryDirectory = try DiskCleanTempDirectory(name: "DiskCleanPurgeScannerTests")
+ discovery = DiskCleanPurgeDiscovery()
+ }
+
+ override func tearDownWithError() throws {
+ temporaryDirectory?.remove()
+ temporaryDirectory = nil
+ discovery = nil
+ try super.tearDownWithError()
+ }
+
+ // MARK: - Project-marker decision matrix
+
+ func testFindsNodeModulesWithSiblingPackageManifest() throws {
+ try temporaryDirectory.makeFile("root/app/package.json", bytes: 10)
+ try temporaryDirectory.makeDirectory("root/app/node_modules")
+
+ let items = try discoverItems()
+
+ XCTAssertEqual(items.map(\.path), [path("root/app/node_modules")])
+ XCTAssertEqual(items[0].kind, .nodeModules)
+ XCTAssertEqual(items[0].projectMarker, "package.json")
+ XCTAssertEqual(items[0].projectPath, path("root/app"))
+ }
+
+ /// Without a project marker it is not a build artifact: photo folders like `~/Documents/build` must yield zero hits.
+ func testIgnoresBuildDirectoryWithoutProjectMarker() throws {
+ try temporaryDirectory.makeFile("root/Documents/build/photo.jpg", bytes: 8)
+ try temporaryDirectory.makeFile("root/Documents/dist/poster.png", bytes: 8)
+ try temporaryDirectory.makeDirectory("root/Documents/node_modules")
+ try temporaryDirectory.makeDirectory("root/Documents/target")
+
+ let items = try discoverItems()
+
+ XCTAssertTrue(items.isEmpty, "unmarked directories must not match, got \(items.map(\.path))")
+ }
+
+ func testMatchesEachKindWithItsMarker() throws {
+ try temporaryDirectory.makeFile("root/rust/Cargo.toml", bytes: 10)
+ try temporaryDirectory.makeDirectory("root/rust/target")
+ try temporaryDirectory.makeFile("root/web/package.json", bytes: 10)
+ try temporaryDirectory.makeDirectory("root/web/build")
+ try temporaryDirectory.makeFile("root/py/setup.py", bytes: 10)
+ try temporaryDirectory.makeDirectory("root/py/dist")
+ try temporaryDirectory.makeFile("root/modern/pyproject.toml", bytes: 10)
+ try temporaryDirectory.makeDirectory("root/modern/build")
+
+ let items = try discoverItems()
+
+ XCTAssertEqual(
+ items.map { [$0.path, $0.kind.rawValue, $0.projectMarker ?? "-"] },
+ [
+ [path("root/modern/build"), "buildOutput", "pyproject.toml"],
+ [path("root/py/dist"), "distOutput", "setup.py"],
+ [path("root/rust/target"), "rustTarget", "Cargo.toml"],
+ [path("root/web/build"), "buildOutput", "package.json"]
+ ]
+ )
+ }
+
+ /// `target` only counts with `Cargo.toml`: an Xcode `target` directory must not look like a Rust artifact.
+ func testTargetRequiresCargoManifest() throws {
+ try temporaryDirectory.makeFile("root/xcode/package.json", bytes: 10)
+ try temporaryDirectory.makeDirectory("root/xcode/target")
+
+ let items = try discoverItems()
+
+ XCTAssertTrue(items.isEmpty)
+ }
+
+ func testPythonCacheMatchesUnconditionally() throws {
+ try temporaryDirectory.makeDirectory("root/scripts/__pycache__")
+
+ let items = try discoverItems()
+
+ XCTAssertEqual(items.map(\.path), [path("root/scripts/__pycache__")])
+ XCTAssertEqual(items[0].kind, .pythonCache)
+ XCTAssertNil(items[0].projectMarker, "unconditional hits have no marker to display")
+ }
+
+ /// A **directory** named `package.json` is coincidence, not a project root.
+ func testMarkerMustNotBeADirectory() throws {
+ try temporaryDirectory.makeDirectory("root/app/package.json")
+ try temporaryDirectory.makeDirectory("root/app/node_modules")
+
+ let items = try discoverItems()
+
+ XCTAssertTrue(items.isEmpty)
+ }
+
+ /// In a monorepo, `package.json` may be a symlink to a shared manifest and still counts as a project root.
+ func testMarkerMayBeSymlink() throws {
+ try temporaryDirectory.makeFile("root/shared.json", bytes: 10)
+ try temporaryDirectory.makeSymlink("root/app/package.json", destination: "../shared.json")
+ try temporaryDirectory.makeDirectory("root/app/node_modules")
+
+ let items = try discoverItems()
+
+ XCTAssertEqual(items.map(\.path), [path("root/app/node_modules")])
+ }
+
+ // MARK: - Pruning and depth
+
+ /// Hit-and-prune: nested dependency trees inside `node_modules` make reporting deeper hits meaningless.
+ func testPrunesNestedCandidatesInsideAHit() throws {
+ try temporaryDirectory.makeFile("root/app/package.json", bytes: 10)
+ try temporaryDirectory.makeFile("root/app/node_modules/lib/package.json", bytes: 10)
+ try temporaryDirectory.makeDirectory("root/app/node_modules/lib/node_modules")
+ try temporaryDirectory.makeDirectory("root/app/node_modules/lib/__pycache__")
+
+ let items = try discoverItems()
+
+ XCTAssertEqual(items.map(\.path), [path("root/app/node_modules")])
+ }
+
+ /// Depth cap 6: root is 0, depth 6 still participates, depth 7 is not enumerated.
+ func testHonorsDepthLimit() throws {
+ try temporaryDirectory.makeFile("root/a/b/c/d/e/package.json", bytes: 10)
+ try temporaryDirectory.makeDirectory("root/a/b/c/d/e/node_modules")
+ try temporaryDirectory.makeFile("root/a/b/c/d/e/f/package.json", bytes: 10)
+ try temporaryDirectory.makeDirectory("root/a/b/c/d/e/f/node_modules")
+
+ let items = try discoverItems()
+
+ XCTAssertEqual(items.map(\.path), [path("root/a/b/c/d/e/node_modules")])
+ }
+
+ func testDepthLimitIsConfigurable() throws {
+ try temporaryDirectory.makeFile("root/a/b/package.json", bytes: 10)
+ try temporaryDirectory.makeDirectory("root/a/b/node_modules")
+ discovery = DiskCleanPurgeDiscovery(maximumDepth: 2)
+
+ let items = try discoverItems()
+
+ XCTAssertTrue(items.isEmpty, "depth-3 candidates must not be found under max depth 2")
+ }
+
+ // MARK: - Symlinks
+
+ /// Never follow directory symlinks: following leaves the scan root and pulls in unauthorized directories.
+ func testDoesNotFollowDirectorySymlink() throws {
+ try temporaryDirectory.makeFile("outside/app/package.json", bytes: 10)
+ try temporaryDirectory.makeDirectory("outside/app/node_modules")
+ try temporaryDirectory.makeDirectory("root")
+ try temporaryDirectory.makeSymlink("root/link", destination: "../outside")
+
+ let items = try discoverItems()
+
+ XCTAssertTrue(items.isEmpty)
+ }
+
+ /// A symlink named `node_modules` is not a candidate: deleting it removes the link, not the dependency tree.
+ func testDoesNotReportSymlinkNamedLikeATarget() throws {
+ try temporaryDirectory.makeFile("root/app/package.json", bytes: 10)
+ try temporaryDirectory.makeDirectory("outside/real_modules")
+ try temporaryDirectory.makeSymlink("root/app/node_modules", destination: "../../outside/real_modules")
+
+ let items = try discoverItems()
+
+ XCTAssertTrue(items.isEmpty)
+ }
+
+ // MARK: - Root status
+
+ func testReportsUnreadableRootForMissingDirectory() {
+ let report = discovery.discover(root: path("root"))
+
+ XCTAssertEqual(report.status, .unreadable(reason: .walkError))
+ XCTAssertTrue(report.items.isEmpty)
+ }
+
+ func testReportsPermissionDeniedRoot() throws {
+ try temporaryDirectory.makeDirectory("root")
+ try temporaryDirectory.denyAccess(to: "root")
+
+ let report = discovery.discover(root: path("root"))
+
+ XCTAssertEqual(report.status, .unreadable(reason: .permissionDenied))
+ }
+
+ func testReportsUnreadableWhenRootIsAFile() throws {
+ try temporaryDirectory.makeFile("root", bytes: 4)
+
+ let report = discovery.discover(root: path("root"))
+
+ XCTAssertEqual(report.status, .unreadable(reason: .walkError))
+ }
+
+ /// An unreadable subtree only degrades completeness; other subtrees still yield candidates.
+ func testUnreadableSubtreeDegradesCompleteness() throws {
+ try temporaryDirectory.makeFile("root/app/package.json", bytes: 10)
+ try temporaryDirectory.makeDirectory("root/app/node_modules")
+ try temporaryDirectory.makeDirectory("root/locked/inner")
+ try temporaryDirectory.denyAccess(to: "root/locked")
+
+ let report = discovery.discover(root: path("root"))
+
+ XCTAssertEqual(report.items.map(\.path), [path("root/app/node_modules")])
+ XCTAssertEqual(report.status, .traversed(completeness: .partial(reasons: [.permissionDenied])))
+ }
+
+ /// Cancellation must not masquerade as a finished scan: the result must carry timedOut.
+ func testCancellationMarksResultIncomplete() throws {
+ try temporaryDirectory.makeDirectory("root/a")
+
+ let report = discovery.discover(root: path("root"), isCancelled: { true })
+
+ XCTAssertEqual(report.status, .traversed(completeness: .partial(reasons: [.timedOut])))
+ XCTAssertTrue(report.items.isEmpty)
+ }
+
+ // MARK: - Repository attribution
+
+ func testAttributesCandidateToNearestRepository() throws {
+ try temporaryDirectory.makeDirectory("root/repo/.git")
+ try temporaryDirectory.makeFile("root/repo/package.json", bytes: 10)
+ try temporaryDirectory.makeDirectory("root/repo/node_modules")
+ try temporaryDirectory.makeDirectory("root/repo/vendor/inner/.git")
+ try temporaryDirectory.makeFile("root/repo/vendor/inner/package.json", bytes: 10)
+ try temporaryDirectory.makeDirectory("root/repo/vendor/inner/node_modules")
+
+ let items = try discoverItems()
+
+ XCTAssertEqual(
+ items.map(\.repositoryPath),
+ [path("root/repo"), path("root/repo/vendor/inner")]
+ )
+ }
+
+ /// Worktree/submodule `.git` is a file, not a directory, and still counts as a repository.
+ func testTreatsGitFileAsRepository() throws {
+ try temporaryDirectory.makeFile("root/repo/.git", bytes: 20)
+ try temporaryDirectory.makeFile("root/repo/package.json", bytes: 10)
+ try temporaryDirectory.makeDirectory("root/repo/node_modules")
+
+ let items = try discoverItems()
+
+ XCTAssertEqual(items.map(\.repositoryPath), [path("root/repo")])
+ }
+
+ func testRootItselfCanBeTheRepository() throws {
+ try temporaryDirectory.makeDirectory("root/.git")
+ try temporaryDirectory.makeFile("root/package.json", bytes: 10)
+ try temporaryDirectory.makeDirectory("root/node_modules")
+
+ let items = try discoverItems()
+
+ XCTAssertEqual(items.map(\.repositoryPath), [path("root")])
+ }
+
+ func testReportsNoRepositoryWhenGitIsAboveTheRoot() throws {
+ try temporaryDirectory.makeDirectory(".git")
+ try temporaryDirectory.makeFile("root/app/package.json", bytes: 10)
+ try temporaryDirectory.makeDirectory("root/app/node_modules")
+
+ let items = try discoverItems()
+
+ XCTAssertEqual(items.map(\.repositoryPath), [nil])
+ }
+
+ // MARK: - Git three-state
+
+ func testMarksRepositoryCleanWhenBothChecksAreEmpty() async throws {
+ try makeSingleRepositoryLayout()
+ let runner = ScriptedDiskCleanSubprocessRunner()
+
+ let candidate = try await scanSingleCandidate(runner: runner)
+
+ XCTAssertEqual(candidate.gitState, .clean(repositoryPath: path("root/repo")))
+ XCTAssertTrue(candidate.isSelectedByDefault)
+ XCTAssertEqual(runner.invocations.count, 2)
+ XCTAssertEqual(
+ runner.invocations[0],
+ ["-C", path("root/repo"), "status", "--porcelain", "-unormal"]
+ )
+ XCTAssertEqual(
+ runner.invocations[1],
+ ["-C", path("root/repo"), "log", "--branches", "--not", "--remotes", "-n", "1"]
+ )
+ }
+
+ func testMarksRepositoryDirtyOnUncommittedChanges() async throws {
+ try makeSingleRepositoryLayout()
+ let runner = ScriptedDiskCleanSubprocessRunner(statusOutput: " M src/main.rs\n")
+
+ let candidate = try await scanSingleCandidate(runner: runner)
+
+ XCTAssertEqual(
+ candidate.gitState,
+ .dirty(repositoryPath: path("root/repo"), reason: .uncommittedChanges)
+ )
+ XCTAssertFalse(candidate.isSelectedByDefault)
+ XCTAssertEqual(runner.invocations.count, 1, "status already dirty; skip unpushed-commit check")
+ }
+
+ func testMarksRepositoryDirtyOnUnpushedCommits() async throws {
+ try makeSingleRepositoryLayout()
+ let runner = ScriptedDiskCleanSubprocessRunner(logOutput: "9f1c2ab\n")
+
+ let candidate = try await scanSingleCandidate(runner: runner)
+
+ XCTAssertEqual(
+ candidate.gitState,
+ .dirty(repositoryPath: path("root/repo"), reason: .unpushedCommits)
+ )
+ XCTAssertFalse(candidate.isSelectedByDefault)
+ }
+
+ /// Timeouts are treated as dirty (fail-safe): mistaking unknown state for "clean" risks deleting needed files.
+ func testTimeoutIsTreatedAsDirty() async throws {
+ try makeSingleRepositoryLayout()
+ let runner = ScriptedDiskCleanSubprocessRunner(
+ error: DiskCleanSubprocessError.timedOut(path: DiskCleanGitStatusInspector.executablePath)
+ )
+
+ let candidate = try await scanSingleCandidate(runner: runner)
+
+ XCTAssertEqual(
+ candidate.gitState,
+ .dirty(repositoryPath: path("root/repo"), reason: .inspectionFailed("git 检查超时"))
+ )
+ XCTAssertFalse(candidate.isSelectedByDefault)
+ }
+
+ func testMissingGitExecutableIsTreatedAsDirty() async throws {
+ try makeSingleRepositoryLayout()
+ let runner = ScriptedDiskCleanSubprocessRunner(
+ error: DiskCleanSubprocessError.executableUnavailable(path: "/usr/bin/git")
+ )
+
+ let candidate = try await scanSingleCandidate(runner: runner)
+
+ XCTAssertEqual(
+ candidate.gitState,
+ .dirty(repositoryPath: path("root/repo"), reason: .inspectionFailed("未找到 git"))
+ )
+ }
+
+ func testNonZeroExitIsTreatedAsDirty() async throws {
+ try makeSingleRepositoryLayout()
+ let runner = ScriptedDiskCleanSubprocessRunner(statusExitCode: 128)
+
+ let candidate = try await scanSingleCandidate(runner: runner)
+
+ XCTAssertEqual(
+ candidate.gitState,
+ .dirty(repositoryPath: path("root/repo"), reason: .inspectionFailed("status 退出码 128"))
+ )
+ }
+
+ /// Candidates outside a repository must not spawn any subprocess.
+ func testSkipsGitInspectionOutsideRepositories() async throws {
+ try temporaryDirectory.makeFile("root/app/package.json", bytes: 10)
+ try temporaryDirectory.makeDirectory("root/app/node_modules")
+ let runner = ScriptedDiskCleanSubprocessRunner()
+
+ let candidate = try await scanSingleCandidate(runner: runner)
+
+ XCTAssertEqual(candidate.gitState, .notInRepository)
+ XCTAssertTrue(candidate.isSelectedByDefault)
+ XCTAssertTrue(runner.invocations.isEmpty)
+ }
+
+ /// A repo often has dozens of `node_modules`; inspecting each multiplies the 2s timeout.
+ func testInspectsEachRepositoryOnlyOnce() async throws {
+ try temporaryDirectory.makeDirectory("root/repo/.git")
+ try temporaryDirectory.makeFile("root/repo/a/package.json", bytes: 10)
+ try temporaryDirectory.makeDirectory("root/repo/a/node_modules")
+ try temporaryDirectory.makeFile("root/repo/b/package.json", bytes: 10)
+ try temporaryDirectory.makeDirectory("root/repo/b/node_modules")
+ let runner = ScriptedDiskCleanSubprocessRunner()
+
+ let result = await makeScanner(runner: runner).scan(roots: [path("root")])
+
+ XCTAssertEqual(result.candidates.count, 2)
+ XCTAssertEqual(runner.invocations.count, 2, "one call per command, independent of candidate count")
+ }
+
+ // MARK: - Scanner aggregation
+
+ func testReportsUnreadableRootsSeparatelyFromEmptyResults() async throws {
+ try temporaryDirectory.makeDirectory("present")
+ let runner = ScriptedDiskCleanSubprocessRunner()
+
+ let result = await makeScanner(runner: runner).scan(
+ roots: [path("present"), path("missing")]
+ )
+
+ XCTAssertTrue(result.candidates.isEmpty)
+ XCTAssertEqual(result.unreadableRoots, [path("missing")])
+ }
+
+ // MARK: - Mount protection
+
+ /// Child devid differs from the root (another volume is mounted there) → no descent, no hits, completeness records crossedMountPoint.
+ /// Real FS mounts are not available in tests, so inject a scripted entry source (same pattern as DirectoryTreeWalker).
+ ///
+ /// On the real FS under `Mounted/` we deliberately place a matchable `node_modules`: the scripted source will not see it,
+ /// so assertions rely on `createdSourceCount` — a bad descent creates an extra `makeSource`.
+ func testDoesNotDescendIntoDirectoryOnAnotherDevice() throws {
+ // Real directories must exist: the scripted source only controls what is seen; descent still uses real openat.
+ // Under `Mounted` we place a matchable node_modules — a bad descent would discover it on the real FS.
+ let root = try temporaryDirectory.makeDirectory("root")
+ try temporaryDirectory.makeFile("root/Mounted/package.json", bytes: 10)
+ try temporaryDirectory.makeDirectory("root/Mounted/node_modules")
+ try temporaryDirectory.makeDirectory("root/local")
+ let rootDevice = try deviceID(of: root.path)
+
+ let factory = ScriptedPurgeSourceFactory(scripts: [
+ [[
+ .resolved(entry(
+ name: "Mounted",
+ type: .directory,
+ devid: rootDevice &+ 1,
+ fileID: 77
+ )),
+ .resolved(entry(
+ name: "local",
+ type: .directory,
+ devid: rootDevice,
+ fileID: 78
+ )),
+ ]],
+ // The second source is created only if same-device `local` is descended into.
+ [[
+ .resolved(entry(
+ name: "__pycache__",
+ type: .directory,
+ devid: rootDevice,
+ fileID: 79
+ )),
+ ]],
+ ])
+ discovery = DiskCleanPurgeDiscovery(sourceFactory: factory)
+
+ let report = discovery.discover(root: root.path)
+
+ guard case let .traversed(completeness) = report.status else {
+ return XCTFail("expected traversed, got \(report.status)")
+ }
+ XCTAssertEqual(completeness, .partial(reasons: [.crossedMountPoint]))
+ XCTAssertEqual(report.items.map(\.path), [root.path + "/local/__pycache__"])
+ XCTAssertFalse(
+ report.items.contains { $0.path.contains("/Mounted/") },
+ "candidates inside a cross-device subtree must not be discovered"
+ )
+ XCTAssertEqual(factory.createdSourceCount, 2, "cross-device directories must not be openat-descended")
+ }
+
+ // MARK: - Fixtures
+
+ private func path(_ relativePath: String) -> String {
+ temporaryDirectory.resolve(relativePath).path
+ }
+
+ private func discoverItems() throws -> [DiskCleanPurgeDiscoveredItem] {
+ discovery.discover(root: path("root")).items
+ }
+
+ private func makeSingleRepositoryLayout() throws {
+ try temporaryDirectory.makeDirectory("root/repo/.git")
+ try temporaryDirectory.makeFile("root/repo/package.json", bytes: 10)
+ try temporaryDirectory.makeDirectory("root/repo/node_modules")
+ }
+
+ private func makeScanner(runner: ScriptedDiskCleanSubprocessRunner) -> DiskCleanPurgeScanner {
+ DiskCleanPurgeScanner(
+ discovery: discovery,
+ inspector: DiskCleanGitStatusInspector(runner: runner)
+ )
+ }
+
+ private func scanSingleCandidate(
+ runner: ScriptedDiskCleanSubprocessRunner
+ ) async throws -> DiskCleanPurgeCandidate {
+ let result = await makeScanner(runner: runner).scan(roots: [path("root")])
+ return try XCTUnwrap(result.candidates.first)
+ }
+
+ private func deviceID(of path: String) throws -> 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
+ )
+ }
+}
+
+/// Fake source factory that replays scripted entry batches. The n-th created source uses scripts[n].
+/// Same pattern as `DiskCleanDirectoryTreeWalkerTests`, intentionally not shared — each suite owns its constraints.
+private final class ScriptedPurgeSourceFactory: DiskCleanDirectoryEntrySourceFactory, @unchecked Sendable {
+ private let scripts: [[[DiskCleanWalkEntry]]]
+ private let lock = NSLock()
+ private var sources: [ScriptedPurgeSource] = []
+
+ init(scripts: [[[DiskCleanWalkEntry]]]) {
+ self.scripts = scripts
+ }
+
+ var createdSourceCount: Int {
+ lock.lock()
+ defer { lock.unlock() }
+ return sources.count
+ }
+
+ func makeSource(fileDescriptor: Int32) throws -> any DiskCleanDirectoryEntrySource {
+ lock.lock()
+ let index = sources.count
+ lock.unlock()
+
+ let source = ScriptedPurgeSource(
+ fileDescriptor: fileDescriptor,
+ batches: index < scripts.count ? scripts[index] : []
+ )
+ lock.lock()
+ sources.append(source)
+ lock.unlock()
+ return source
+ }
+}
+
+private final class ScriptedPurgeSource: DiskCleanDirectoryEntrySource {
+ let directoryFileDescriptor: Int32
+ private var batches: [[DiskCleanWalkEntry]]
+ private(set) var isClosed = false
+
+ init(fileDescriptor: Int32, batches: [[DiskCleanWalkEntry]]) {
+ self.directoryFileDescriptor = fileDescriptor
+ self.batches = batches
+ }
+
+ func nextBatch() throws -> [DiskCleanWalkEntry]? {
+ guard !batches.isEmpty else { return nil }
+ return batches.removeFirst()
+ }
+
+ func close() {
+ guard !isClosed else { return }
+ isClosed = true
+ Darwin.close(directoryFileDescriptor)
+ }
+}
+
+/// Subprocess fake programmable per command. Existing `FakeDiskCleanSubprocessRunner` returns one result for all
+/// calls and cannot cover "status clean but log has output".
+final class ScriptedDiskCleanSubprocessRunner: DiskCleanSubprocessRunning, @unchecked Sendable {
+ private let lock = NSLock()
+ private var recorded: [[String]] = []
+ private let statusResult: Result
+ private let logResult: Result
+
+ init(
+ statusOutput: String = "",
+ statusExitCode: Int32 = 0,
+ logOutput: String = "",
+ logExitCode: Int32 = 0
+ ) {
+ self.statusResult = .success(
+ DiskCleanSubprocessResult(exitCode: statusExitCode, standardOutput: Data(statusOutput.utf8))
+ )
+ self.logResult = .success(
+ DiskCleanSubprocessResult(exitCode: logExitCode, standardOutput: Data(logOutput.utf8))
+ )
+ }
+
+ init(error: Error) {
+ self.statusResult = .failure(error)
+ self.logResult = .failure(error)
+ }
+
+ var invocations: [[String]] { lock.withLock { recorded } }
+
+ func run(
+ executablePath: String,
+ arguments: [String],
+ timeout: Duration
+ ) async throws -> DiskCleanSubprocessResult {
+ lock.withLock { recorded.append(arguments) }
+ XCTAssertEqual(executablePath, DiskCleanGitStatusInspector.executablePath)
+ XCTAssertEqual(timeout, .seconds(2))
+ return try (arguments.contains("status") ? statusResult : logResult).get()
+ }
+}
diff --git a/Plugins/DiskClean/Tests/DiskCleanRemovalPrimitiveTests.swift b/Plugins/DiskClean/Tests/DiskCleanRemovalPrimitiveTests.swift
new file mode 100644
index 00000000..0eef2589
--- /dev/null
+++ b/Plugins/DiskClean/Tests/DiskCleanRemovalPrimitiveTests.swift
@@ -0,0 +1,407 @@
+import Darwin
+import Foundation
+import XCTest
+@testable import MacTools
+@testable import DiskCleanPlugin
+
+/// Behavior contract for the verify-freeze-delete primitive (design §7.3, §7.4).
+///
+/// **All real syscalls and temp directories**: correctness rests on real `renameatx_np` / `fstatat` /
+/// `unlinkat` semantics; a fake filesystem would test nothing. The only injections are trash
+/// (must not touch the user's real Trash) and entry device IDs (mount crossing cannot be fabricated in a temp dir).
+@MainActor
+final class DiskCleanRemovalPrimitiveTests: XCTestCase {
+ private var temporary: DiskCleanTempDirectory!
+ private var storage: DiskCleanTempDirectory!
+ private var journal: DiskCleanStagingJournal!
+ /// Directories made read-only must have permissions restored before teardown or cleanup fails.
+ private var restrictedDirectories: [String] = []
+
+ override func setUpWithError() throws {
+ try super.setUpWithError()
+ temporary = try DiskCleanTempDirectory(name: "diskclean-primitive")
+ storage = try DiskCleanTempDirectory(name: "diskclean-primitive-state")
+ journal = DiskCleanStagingJournal(directory: storage.url)
+ }
+
+ override func tearDown() {
+ for path in restrictedDirectories {
+ chmod(path, 0o755)
+ }
+ restrictedDirectories.removeAll()
+ temporary?.remove()
+ storage?.remove()
+ temporary = nil
+ storage = nil
+ journal = nil
+ super.tearDown()
+ }
+
+ // MARK: - Permanent delete
+
+ func testPermanentModeDeletesDirectoryTreeAndLeavesNoStagedRemnant() throws {
+ try temporary.makeFile("Cache/a.bin", bytes: 10)
+ try temporary.makeFile("Cache/Nested/b.bin", bytes: 20)
+ try temporary.makeDirectory("Cache/Empty")
+ let target = temporary.resolve("Cache").path
+ let plan = try DiskCleanPlanFactory.makePlan(paths: [target])
+
+ let disposition = makePrimitive().remove(plan.items[0], mode: .permanent)
+
+ XCTAssertEqual(disposition, .removed)
+ assertPathDoesNotExist(target)
+ XCTAssertEqual(stagedNames(in: temporary.path), [], "successful delete must leave no staged remnants")
+ XCTAssertTrue(journal.incompleteEntries().isEmpty, "successful disposition must clear the journal")
+ }
+
+ func testPermanentModeDeletesRegularFileWithoutPrewalk() throws {
+ try temporary.makeFile("installer.dmg", bytes: 512)
+ let target = temporary.resolve("installer.dmg").path
+ let plan = try DiskCleanPlanFactory.makePlan(paths: [target])
+
+ let disposition = makePrimitive().remove(plan.items[0], mode: .permanent)
+
+ XCTAssertEqual(disposition, .removed)
+ assertPathDoesNotExist(target)
+ }
+
+ /// Symlink candidates remove the link itself and never follow.
+ func testSymlinkCandidateRemovesLinkItselfAndKeepsTarget() throws {
+ try temporary.makeFile("Outside/precious.bin", bytes: 4_096)
+ try temporary.makeSymlink("Link/toOutside", destination: "../Outside")
+ let target = temporary.resolve("Link/toOutside").path
+ let plan = try DiskCleanPlanFactory.makePlan(paths: [target])
+
+ let disposition = makePrimitive().remove(plan.items[0], mode: .permanent)
+
+ XCTAssertEqual(disposition, .removed)
+ assertPathDoesNotExist(target)
+ assertPathExists(temporary.resolve("Outside/precious.bin").path, "must not follow the link and delete the target")
+ }
+
+ // MARK: - Path swap (core race in §7.3)
+
+ /// After identity is recorded, replace the target with a symlink elsewhere → refuse; link and target stay intact.
+ func testTargetReplacedWithSymlinkAfterPlanIsRefused() throws {
+ try temporary.makeFile("Cache/a.bin", bytes: 10)
+ try temporary.makeFile("Precious/data.bin", bytes: 8_192)
+ let target = temporary.resolve("Cache").path
+ let plan = try DiskCleanPlanFactory.makePlan(paths: [target])
+
+ // After plan minting, before execution: target swapped for a symlink into user data.
+ try FileManager.default.removeItem(atPath: target)
+ try FileManager.default.createSymbolicLink(
+ atPath: target,
+ withDestinationPath: temporary.resolve("Precious").path
+ )
+
+ let disposition = makePrimitive().remove(plan.items[0], mode: .permanent)
+
+ XCTAssertEqual(disposition, .changedSinceScan)
+ assertPathExists(target, "the replacement symlink itself must not be touched")
+ assertPathExists(temporary.resolve("Precious/data.bin").path, "user data behind the link must remain intact")
+ XCTAssertTrue(journal.incompleteEntries().isEmpty, "identity mismatch must not rename")
+ }
+
+ /// After identity is recorded, replace with a same-name directory (different fileID) → refuse; new contents intact.
+ func testTargetReplacedWithSameNameDirectoryIsRefused() throws {
+ try temporary.makeFile("Cache/a.bin", bytes: 10)
+ let target = temporary.resolve("Cache").path
+ let plan = try DiskCleanPlanFactory.makePlan(paths: [target])
+
+ try FileManager.default.removeItem(atPath: target)
+ try temporary.makeFile("Cache/brand-new.bin", bytes: 20)
+
+ let disposition = makePrimitive().remove(plan.items[0], mode: .permanent)
+
+ XCTAssertEqual(disposition, .changedSinceScan)
+ assertPathExists(temporary.resolve("Cache/brand-new.bin").path, "contents of the same-name replacement must stay intact")
+ }
+
+ /// Middle path component swapped for a symlink → `O_NOFOLLOW_ANY` fails when opening the parent; never delete through the link.
+ func testMiddleComponentReplacedWithSymlinkIsRefused() throws {
+ try temporary.makeDirectory("Parent")
+ try temporary.makeFile("Parent/Cache/a.bin", bytes: 10)
+ try temporary.makeFile("Elsewhere/Cache/precious.bin", bytes: 8_192)
+ let target = temporary.resolve("Parent/Cache").path
+ let plan = try DiskCleanPlanFactory.makePlan(paths: [target])
+
+ try FileManager.default.removeItem(atPath: temporary.resolve("Parent").path)
+ try FileManager.default.createSymbolicLink(
+ atPath: temporary.resolve("Parent").path,
+ withDestinationPath: temporary.resolve("Elsewhere").path
+ )
+
+ let disposition = makePrimitive().remove(plan.items[0], mode: .permanent)
+
+ guard case .failed = disposition else {
+ return XCTFail("middle-component symlink must fail, got: \(disposition)")
+ }
+ assertPathExists(temporary.resolve("Elsewhere/Cache/precious.bin").path, "data behind the link must stay intact")
+ }
+
+ /// Directory contents change after scan (root mtime changes) → refuse and force a rescan.
+ func testDirectoryModifiedAfterPlanIsRefused() throws {
+ try temporary.makeFile("Cache/a.bin", bytes: 10)
+ let target = temporary.resolve("Cache").path
+ let plan = try DiskCleanPlanFactory.makePlan(paths: [target])
+
+ try temporary.makeFile("Cache/added-later.bin", bytes: 5)
+
+ let disposition = makePrimitive().remove(plan.items[0], mode: .permanent)
+
+ XCTAssertEqual(disposition, .changedSinceScan)
+ assertPathExists(temporary.resolve("Cache/added-later.bin").path)
+ }
+
+ /// Regular-file size mismatches the plan → refuse (second evidence when mtime is preserved).
+ func testRegularFileWithDifferentSizeIsRefused() throws {
+ try temporary.makeFile("log.txt", bytes: 100)
+ let target = temporary.resolve("log.txt").path
+ // Size mismatches while other identity fields match — only size comparison can catch this.
+ let candidate = DiskCleanPlanFactory.candidate(
+ path: target,
+ identity: try XCTUnwrap(DiskCleanPlanFactory.currentIdentity(ofItemAt: target)),
+ bytes: 999
+ )
+ let plan = try DiskCleanPlanner.makePlan(
+ artifact: DiskCleanPlanFactory.artifact(candidates: [candidate]),
+ selectedIDs: [candidate.id],
+ mode: .permanent,
+ now: DiskCleanPlanFactory.observedAt,
+ catalog: DiskCleanPlanFactory.catalog()
+ )
+
+ let disposition = makePrimitive().remove(plan.items[0], mode: .permanent)
+
+ XCTAssertEqual(disposition, .changedSinceScan)
+ assertPathExists(target)
+ }
+
+ // MARK: - Freeze semantics
+
+ /// After freeze, anything appearing at the original path is unrelated — the object has left that name.
+ func testStagedObjectIsDetachedFromOriginalPath() throws {
+ try temporary.makeFile("Cache/a.bin", bytes: 10)
+ let target = temporary.resolve("Cache").path
+ let plan = try DiskCleanPlanFactory.makePlan(paths: [target], mode: .trash)
+ let recreatedMarker = temporary.resolve("Cache/recreated.bin").path
+
+ // During disposition, another "process" recreates the cache directory at the original path.
+ let trash = FakeDiskCleanTrash(duringTrash: {
+ try? FileManager.default.createDirectory(
+ atPath: target,
+ withIntermediateDirectories: true
+ )
+ FileManager.default.createFile(atPath: recreatedMarker, contents: Data([0x41]))
+ })
+
+ let disposition = makePrimitive(trash: trash).remove(plan.items[0], mode: .trash)
+
+ guard case let .trashed(stagedName) = disposition else {
+ return XCTFail("expected trashed, got: \(disposition)")
+ }
+ XCTAssertEqual(
+ trash.trashedPaths,
+ [DiskCleanRemovalPrimitive.join(temporary.path, stagedName)],
+ "trash must operate on the staged path, not the re-resolvable original path"
+ )
+ assertPathExists(recreatedMarker, "object recreated at the original path is unrelated and must stay intact")
+ }
+
+ func testTrashOperatesOnStagedNameWithProtectedPrefix() throws {
+ try temporary.makeFile("Cache/a.bin", bytes: 10)
+ let plan = try DiskCleanPlanFactory.makePlan(paths: [temporary.resolve("Cache").path], mode: .trash)
+ let trash = FakeDiskCleanTrash()
+
+ let disposition = makePrimitive(trash: trash).remove(plan.items[0], mode: .trash)
+
+ guard case let .trashed(stagedName) = disposition else {
+ return XCTFail("expected trashed, got: \(disposition)")
+ }
+ XCTAssertTrue(stagedName.hasPrefix(DiskCleanRemovalPrimitive.stagedNamePrefix))
+ XCTAssertTrue(journal.incompleteEntries().isEmpty)
+ }
+
+ /// Staged-name collision → retry once with a new uuid and never overwrite the placeholder (RENAME_EXCL).
+ func testStagedNameCollisionRetriesWithNewNameAndNeverOverwrites() throws {
+ try temporary.makeFile("Cache/a.bin", bytes: 10)
+ let collidingName = DiskCleanRemovalPrimitive.stagedNamePrefix + "collision"
+ try temporary.makeFile(collidingName, bytes: 7)
+ let plan = try DiskCleanPlanFactory.makePlan(paths: [temporary.resolve("Cache").path])
+
+ let names = NameSequence(names: [collidingName, DiskCleanRemovalPrimitive.stagedNamePrefix + "fresh"])
+ let disposition = makePrimitive(stagedNameFactory: { names.next() }).remove(plan.items[0], mode: .permanent)
+
+ XCTAssertEqual(disposition, .removed)
+ assertPathExists(
+ temporary.resolve(collidingName).path,
+ "RENAME_EXCL must guarantee the placeholder is not overwritten"
+ )
+ XCTAssertEqual(DiskCleanPlanFactory.currentSize(ofItemAt: temporary.resolve(collidingName).path), 7)
+ }
+
+ /// If the journal cannot be written, never rename — reverse check of the ordering invariant.
+ func testRenameNeverHappensWhenJournalCannotBeWritten() throws {
+ try temporary.makeFile("Cache/a.bin", bytes: 10)
+ let target = temporary.resolve("Cache").path
+ let plan = try DiskCleanPlanFactory.makePlan(paths: [target])
+
+ // Occupy the journal directory path with a regular file so createDirectory and writes fail.
+ let blocked = try DiskCleanTempDirectory(name: "diskclean-blocked-journal")
+ defer { blocked.remove() }
+ try blocked.makeFile("state", bytes: 1)
+ let unwritableJournal = DiskCleanStagingJournal(directory: blocked.resolve("state"))
+
+ let disposition = DiskCleanRemovalPrimitive(journal: unwritableJournal, trash: FakeDiskCleanTrash())
+ .remove(plan.items[0], mode: .permanent)
+
+ guard case .failed = disposition else {
+ return XCTFail("unwritable journal must fail, got: \(disposition)")
+ }
+ assertPathExists(target, "object must be untouched when journal write fails")
+ XCTAssertEqual(stagedNames(in: temporary.path), [])
+ }
+
+ // MARK: - Rollback
+
+ func testTrashFailureRollsBackToOriginalPath() throws {
+ try temporary.makeFile("Cache/a.bin", bytes: 10)
+ let target = temporary.resolve("Cache").path
+ let plan = try DiskCleanPlanFactory.makePlan(paths: [target], mode: .trash)
+
+ let disposition = makePrimitive(trash: FakeDiskCleanTrash(shouldFail: true))
+ .remove(plan.items[0], mode: .trash)
+
+ guard case .failed = disposition else {
+ return XCTFail("expected failed, got: \(disposition)")
+ }
+ assertPathExists(temporary.resolve("Cache/a.bin").path, "original path must be intact after rollback")
+ XCTAssertEqual(stagedNames(in: temporary.path), [])
+ XCTAssertTrue(journal.incompleteEntries().isEmpty, "successful rollback clears the journal")
+ }
+
+ /// On rollback the original path was recreated → never overwrite: keep the staged object; leave the journal entry for reconciliation.
+ func testRollbackBlockedWhenOriginalPathWasRecreated() throws {
+ try temporary.makeFile("Cache/a.bin", bytes: 10)
+ let target = temporary.resolve("Cache").path
+ let plan = try DiskCleanPlanFactory.makePlan(paths: [target], mode: .trash)
+ let recreatedMarker = temporary.resolve("Cache/recreated.bin").path
+
+ let trash = FakeDiskCleanTrash(shouldFail: true, duringTrash: {
+ try? FileManager.default.createDirectory(
+ atPath: target,
+ withIntermediateDirectories: true
+ )
+ FileManager.default.createFile(atPath: recreatedMarker, contents: Data([0x41]))
+ })
+
+ let disposition = makePrimitive(trash: trash).remove(plan.items[0], mode: .trash)
+
+ guard case let .rollbackBlocked(stagedName, _) = disposition else {
+ return XCTFail("expected rollbackBlocked, got: \(disposition)")
+ }
+ assertPathExists(recreatedMarker, "recreated original path must never be overwritten")
+ assertPathExists(
+ DiskCleanRemovalPrimitive.join(temporary.path, stagedName),
+ "staged object must be retained for reconciliation"
+ )
+ XCTAssertEqual(
+ journal.incompleteEntries().map(\.stagedName),
+ [stagedName],
+ "journal entry stays incomplete when rollback is blocked"
+ )
+ }
+
+ /// Prewalk finds mount crossing → delete nothing; rename the whole tree back to the original path.
+ func testPrewalkCrossingMountPointRollsBackWithoutDeleting() throws {
+ try temporary.makeFile("Cache/a.bin", bytes: 10)
+ try temporary.makeFile("Cache/Nested/b.bin", bytes: 20)
+ let target = temporary.resolve("Cache").path
+ let plan = try DiskCleanPlanFactory.makePlan(paths: [target])
+
+ let primitive = makePrimitive(
+ deviceResolver: FakeDiskCleanStagedEntryDeviceResolver(crossedMountEntryNames: ["Nested"])
+ )
+ let disposition = primitive.remove(plan.items[0], mode: .permanent)
+
+ guard case let .failed(reason) = disposition else {
+ return XCTFail("expected failed, got: \(disposition)")
+ }
+ XCTAssertTrue(reason.contains("挂载点"), "failure reason should mention mount crossing, got: \(reason)")
+ assertPathExists(temporary.resolve("Cache/a.bin").path, "prewalk is non-destructive; whole tree must be intact after rollback")
+ assertPathExists(temporary.resolve("Cache/Nested/b.bin").path)
+ XCTAssertEqual(stagedNames(in: temporary.path), [])
+ }
+
+ // MARK: - partiallyDeleted
+
+ /// Mid-delete unwritable subdirectory → honestly report partiallyDeleted; remnants stay under the staged name.
+ ///
+ /// **Deviation from design §7.4**: the journal entry is explicitly cleared rather than retained. Retention would let
+ /// next-launch reconciliation rename the half-deleted broken tree back, and the app would treat it as a healthy cache.
+ func testPartiallyDeletedWhenSubdirectoryIsNotWritable() throws {
+ try temporary.makeFile("Cache/top.bin", bytes: 10)
+ try temporary.makeFile("Cache/Locked/inner.bin", bytes: 20)
+ let target = temporary.resolve("Cache").path
+ let plan = try DiskCleanPlanFactory.makePlan(paths: [target])
+
+ // r-x: readable/enterable (prewalk succeeds) but not writable (unlinkat of children fails).
+ let lockedPath = temporary.resolve("Cache/Locked").path
+ restrictedDirectories.append(lockedPath)
+ XCTAssertEqual(chmod(lockedPath, 0o555), 0)
+
+ let disposition = makePrimitive().remove(plan.items[0], mode: .permanent)
+
+ guard case let .partiallyDeleted(stagedName, _) = disposition else {
+ return XCTFail("expected partiallyDeleted, got: \(disposition)")
+ }
+ assertPathDoesNotExist(target, "object has left the original path and is not pretend-restored")
+ XCTAssertEqual(
+ stagedNames(in: temporary.path),
+ [stagedName],
+ "half-deleted remnants stay under the staged name for audit and clean history"
+ )
+ XCTAssertTrue(
+ journal.incompleteEntries().isEmpty,
+ "partiallyDeleted must clear the journal or next launch renames the broken tree back"
+ )
+ // Restore permissions so teardown can delete the tree.
+ chmod(DiskCleanRemovalPrimitive.join(temporary.path, stagedName) + "/Locked", 0o755)
+ }
+
+ // MARK: - Fixtures
+
+ private func makePrimitive(
+ trash: any DiskCleanTrashing = FakeDiskCleanTrash(),
+ deviceResolver: any DiskCleanStagedEntryDeviceResolving = DiskCleanRealStagedEntryDeviceResolver(),
+ stagedNameFactory: (@Sendable () -> String)? = nil
+ ) -> DiskCleanRemovalPrimitive {
+ if let stagedNameFactory {
+ return DiskCleanRemovalPrimitive(
+ journal: journal,
+ trash: trash,
+ deviceResolver: deviceResolver,
+ stagedNameFactory: stagedNameFactory
+ )
+ }
+ return DiskCleanRemovalPrimitive(journal: journal, trash: trash, deviceResolver: deviceResolver)
+ }
+}
+
+/// Sequential staged-name factory for constructing RENAME_EXCL collisions.
+private final class NameSequence: @unchecked Sendable {
+ private let lock = NSLock()
+ private var names: [String]
+
+ init(names: [String]) {
+ self.names = names
+ }
+
+ func next() -> String {
+ lock.withLock {
+ guard !names.isEmpty else { return DiskCleanRemovalPrimitive.stagedNamePrefix + UUID().uuidString }
+ return names.removeFirst()
+ }
+ }
+}
diff --git a/Plugins/DiskClean/Tests/DiskCleanRootOpenerTests.swift b/Plugins/DiskClean/Tests/DiskCleanRootOpenerTests.swift
new file mode 100644
index 00000000..ca399676
--- /dev/null
+++ b/Plugins/DiskClean/Tests/DiskCleanRootOpenerTests.swift
@@ -0,0 +1,181 @@
+import Darwin
+import XCTest
+@testable import MacTools
+@testable import DiskCleanPlugin
+
+final class DiskCleanRootOpenerTests: XCTestCase {
+ private var temporaryDirectory: DiskCleanTempDirectory!
+ private var opener: DiskCleanRootOpener!
+
+ override func setUpWithError() throws {
+ try super.setUpWithError()
+ temporaryDirectory = try DiskCleanTempDirectory(name: "DiskCleanRootOpenerTests")
+ opener = DiskCleanRootOpener()
+ }
+
+ override func tearDownWithError() throws {
+ temporaryDirectory?.remove()
+ temporaryDirectory = nil
+ opener = nil
+ try super.tearDownWithError()
+ }
+
+ // MARK: - Type branching
+
+ func testOpensDirectoryAndReportsIdentity() throws {
+ let directory = try temporaryDirectory.makeDirectory("Caches")
+
+ let outcome = opener.open(path: directory.path)
+
+ guard case let .directory(fileDescriptor, identity) = outcome else {
+ return XCTFail("directory should take the walker branch, got \(outcome)")
+ }
+ defer { close(fileDescriptor) }
+
+ XCTAssertGreaterThanOrEqual(fileDescriptor, 0)
+ XCTAssertEqual(identity.fileType, .directory)
+ XCTAssertEqual(identity, expectedIdentity(of: directory.path))
+ }
+
+ /// Regular files (installers, logs) take st_size from the same fd and skip the walker.
+ func testResolvesRegularFileDirectlyFromDescriptor() throws {
+ let file = try temporaryDirectory.makeFile("installer.dmg", bytes: 4096)
+
+ let outcome = opener.open(path: file.path)
+
+ guard case let .resolved(bytes, identity) = outcome else {
+ return XCTFail("regular file should resolve size directly, got \(outcome)")
+ }
+ XCTAssertEqual(bytes, 4096)
+ XCTAssertEqual(identity.fileType, .regularFile)
+ XCTAssertEqual(identity, expectedIdentity(of: file.path))
+ }
+
+ /// Symlinks are measured as the **link itself** (size = target string length) and never followed.
+ func testResolvesSymlinkAsLinkItself() throws {
+ try temporaryDirectory.makeFile("target.bin", bytes: 9999)
+ let link = try temporaryDirectory.makeSymlink("link", destination: "target.bin")
+
+ let outcome = opener.open(path: link.path)
+
+ guard case let .resolved(bytes, identity) = outcome else {
+ return XCTFail("symlink should resolve as the link itself, got \(outcome)")
+ }
+ XCTAssertEqual(identity.fileType, .symlink)
+ XCTAssertEqual(bytes, Int64("target.bin".utf8.count), "must not follow to the 9999-byte target")
+ XCTAssertEqual(identity, expectedIdentity(of: link.path))
+ }
+
+ /// A symlink to a directory is also measured as the link; never descend into the target.
+ func testSymlinkToDirectoryIsNotFollowed() throws {
+ try temporaryDirectory.makeFile("RealDir/huge.bin", bytes: 8192)
+ let link = try temporaryDirectory.makeSymlink("DirLink", destination: "RealDir")
+
+ let outcome = opener.open(path: link.path)
+
+ guard case let .resolved(bytes, identity) = outcome else {
+ return XCTFail("directory symlink should resolve as the link itself, got \(outcome)")
+ }
+ XCTAssertEqual(identity.fileType, .symlink)
+ XCTAssertEqual(bytes, Int64("RealDir".utf8.count))
+ }
+
+ // MARK: - O_NOFOLLOW_ANY: reject symlinks at any path component
+
+ /// Intermediate directory swapped for a symlink → must refuse. Defense against attacks that
+ /// replace a middle directory with a symlink into Documents.
+ func testRejectsSymlinkInIntermediateComponent() throws {
+ try temporaryDirectory.makeFile("RealDir/payload.bin", bytes: 128)
+ try temporaryDirectory.makeSymlink("DirLink", destination: "RealDir")
+
+ let outcome = opener.open(path: temporaryDirectory.resolve("DirLink/payload.bin").path)
+
+ XCTAssertEqual(outcome, .failed(reason: .walkError))
+ }
+
+ /// Critical regression: intermediate is a symlink **and** the final component is also a symlink.
+ ///
+ /// Naive `lstat` succeeds and reports "final is symlink", letting the intermediate swap through.
+ /// Parent-directory fd anchoring (open parent with O_NOFOLLOW_ANY first) is what catches it.
+ func testRejectsSymlinkChainWhereFinalComponentIsAlsoSymlink() throws {
+ try temporaryDirectory.makeFile("RealDir/target.bin", bytes: 128)
+ try temporaryDirectory.makeSymlink("RealDir/inner", destination: "target.bin")
+ try temporaryDirectory.makeSymlink("DirLink", destination: "RealDir")
+
+ let victimPath = temporaryDirectory.resolve("DirLink/inner").path
+
+ // Prerequisite: naive lstat is indeed fooled by this layout.
+ var status = stat()
+ XCTAssertEqual(lstat(victimPath, &status), 0)
+ XCTAssertEqual(status.st_mode & S_IFMT, S_IFLNK, "lstat reports it as a legitimate symlink candidate")
+
+ XCTAssertEqual(opener.open(path: victimPath), .failed(reason: .walkError))
+ }
+
+ func testRejectsDeepSymlinkChainInAncestor() throws {
+ try temporaryDirectory.makeFile("a/b/c/payload.bin", bytes: 32)
+ try temporaryDirectory.makeSymlink("a/b/hop", destination: "c")
+
+ let outcome = opener.open(path: temporaryDirectory.resolve("a/b/hop/payload.bin").path)
+
+ XCTAssertEqual(outcome, .failed(reason: .walkError))
+ }
+
+ // MARK: - Error mapping
+
+ func testMissingPathMapsToWalkError() {
+ let outcome = opener.open(path: temporaryDirectory.resolve("does-not-exist").path)
+
+ XCTAssertEqual(outcome, .failed(reason: .walkError))
+ }
+
+ func testUnreadableDirectoryMapsToPermissionDenied() throws {
+ try temporaryDirectory.makeDirectory("Locked")
+ try temporaryDirectory.denyAccess(to: "Locked")
+
+ // root ignores permission bits, making this assertion meaningless.
+ try XCTSkipIf(getuid() == 0, "cannot construct EACCES when running as root")
+
+ let outcome = opener.open(path: temporaryDirectory.resolve("Locked").path)
+
+ XCTAssertEqual(outcome, .failed(reason: .permissionDenied))
+ }
+
+ func testRootPathIsRejectedRatherThanTreatedAsCandidate() {
+ // "/" has no parent and cannot be a candidate; only require no crash and no false cleanable.
+ let outcome = opener.open(path: "/")
+
+ if case let .directory(fileDescriptor, identity) = outcome {
+ close(fileDescriptor)
+ XCTAssertEqual(identity.fileType, .directory)
+ }
+ }
+
+ // MARK: - ParentAnchoredPath
+
+ func testParentAnchoredPathSplitsPaths() throws {
+ let nested = try XCTUnwrap(ParentAnchoredPath(path: "/Users/someone/Library/Caches"))
+ XCTAssertEqual(nested.parentPath, "/Users/someone/Library")
+ XCTAssertEqual(nested.name, "Caches")
+
+ let trailingSlash = try XCTUnwrap(ParentAnchoredPath(path: "/Users/someone/Library/Caches/"))
+ XCTAssertEqual(trailingSlash.parentPath, "/Users/someone/Library")
+ XCTAssertEqual(trailingSlash.name, "Caches")
+
+ let topLevel = try XCTUnwrap(ParentAnchoredPath(path: "/single"))
+ XCTAssertEqual(topLevel.parentPath, "/")
+ XCTAssertEqual(topLevel.name, "single")
+
+ XCTAssertNil(ParentAnchoredPath(path: "/"))
+ XCTAssertNil(ParentAnchoredPath(path: ""))
+ XCTAssertNil(ParentAnchoredPath(path: "/a/.."))
+ }
+
+ // MARK: - Helpers
+
+ private func expectedIdentity(of path: String) -> DiskCleanRootIdentity {
+ var status = stat()
+ XCTAssertEqual(lstat(path, &status), 0, "failed to lstat \(path)")
+ return DiskCleanRootIdentity(stat: status)
+ }
+}
diff --git a/Plugins/DiskClean/Tests/DiskCleanRuleCatalogV2Tests.swift b/Plugins/DiskClean/Tests/DiskCleanRuleCatalogV2Tests.swift
new file mode 100644
index 00000000..59b439ce
--- /dev/null
+++ b/Plugins/DiskClean/Tests/DiskCleanRuleCatalogV2Tests.swift
@@ -0,0 +1,428 @@
+import XCTest
+@testable import MacTools
+@testable import DiskCleanPlugin
+
+/// Rule v1 → v2 mapping snapshot (design §5.4, §11 "rule mapping").
+///
+/// These assertions guard the migration: the v2 catalog may only change ownership and metadata, never silently add/remove globs or drop v1 rule IDs.
+final class DiskCleanRuleCatalogV2Tests: XCTestCase {
+ private let catalog = DiskCleanRuleCatalogV2.current
+
+ /// v1 pseudo-dynamic rules only had static fallback globs; v2 lands them as path targets.
+ /// This is the full set of globs v2 adds relative to the v1 *catalog*; any other addition should fail this test.
+ private let expectedGlobsAbsentFromFirstVersionCatalog: Set = [
+ // v1 `.xcodeDerivedData` fallback
+ "~/Library/Developer/Xcode/DerivedData/*",
+ // two v1 package-manager dynamic rules not covered by other rules
+ "~/.cache/go-build/*",
+ "~/Library/Caches/mise/*",
+ // v1 `.serviceWorkerCache` fallback
+ "~/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/*/*"
+ ]
+
+ // MARK: - legacyRuleID coverage
+
+ func testLegacyRuleIDsCoverFirstVersionRuleIDsExactly() {
+ // Only rule targets: P2 synthetic targets (`purge.*` / `installer.*`) are not v1 migrations;
+ // dedicated scanners discover their candidates, and legacyRuleID equals id only so audit logs have a stable name.
+ let legacyIDs = Set(catalog.ruleTargets.map(\.legacyRuleID))
+ let firstVersionIDs = Set(DiskCleanRuleCatalog.moleFirstVersion.rules.map(\.id))
+
+ XCTAssertEqual(legacyIDs, firstVersionIDs)
+ XCTAssertEqual(firstVersionIDs.count, 43)
+ }
+
+ func testSplitLegacyRulesKeepEveryTargetUnderTheSameLegacyID() {
+ XCTAssertEqual(
+ catalog.targets(legacyRuleID: "cache.user-essentials").map(\.id),
+ ["cache.user-essentials.caches", "cache.user-essentials.logs"]
+ )
+ XCTAssertEqual(
+ catalog.targets(legacyRuleID: "developer.rust-go-docker").map(\.id),
+ ["developer.rust-go", "developer.docker"]
+ )
+ XCTAssertEqual(
+ catalog.targets(legacyRuleID: "browser.chrome").map(\.id),
+ ["browser.chrome", "browser.chrome.service-worker"]
+ )
+ }
+
+ // MARK: - Glob ownership
+
+ func testEveryFirstVersionGlobHasExactlyOneOwningTarget() {
+ var ownersByGlob: [String: [String]] = [:]
+ for target in catalog.targets {
+ for glob in target.pathGlobs {
+ ownersByGlob[glob, default: []].append(target.id)
+ }
+ }
+
+ let duplicated = ownersByGlob.filter { $0.value.count > 1 }
+ XCTAssertTrue(duplicated.isEmpty, "glob owned by multiple targets: \(duplicated)")
+
+ let firstVersionGlobs = Self.firstVersionPathGlobs()
+ let missing = firstVersionGlobs.subtracting(ownersByGlob.keys)
+ XCTAssertTrue(missing.isEmpty, "v1 glob has no v2 owner: \(missing.sorted())")
+
+ let added = Set(ownersByGlob.keys).subtracting(firstVersionGlobs)
+ XCTAssertEqual(added, expectedGlobsAbsentFromFirstVersionCatalog)
+ }
+
+ func testFirstVersionDuplicatedGlobsAreOwnedByTheMoreSpecificCategory() {
+ // In v1, five Codex globs and Figma appeared in two rules; v2 normalizes to the more specific category.
+ XCTAssertEqual(
+ Self.owner(of: "~/Library/Application Support/Codex/Cache/*", in: catalog)?.id,
+ "cache.ai-assistants"
+ )
+ XCTAssertEqual(
+ Self.owner(of: "~/Library/Caches/com.figma.Desktop/*", in: catalog)?.id,
+ "cache.creative-tools"
+ )
+ }
+
+ func testPathTargetsDeclareAtLeastOneGlob() {
+ for target in catalog.ruleTargets where !target.isDynamic {
+ XCTAssertFalse(target.pathGlobs.isEmpty, "path target has no globs: \(target.id)")
+ }
+ }
+
+ func testTargetIDsAreUniqueAndNonEmpty() {
+ let ids = catalog.targets.map(\.id)
+ XCTAssertEqual(Set(ids).count, ids.count)
+ XCTAssertFalse(ids.contains { $0.isEmpty })
+ for target in catalog.targets {
+ XCTAssertEqual(catalog.target(id: target.id)?.id, target.id)
+ }
+ }
+
+ // MARK: - Reserved roots
+
+ func testReservedRootPathsAreNonEmptyNormalizedAbsolutePaths() {
+ let home = "/Users/diskclean-tests"
+ for target in catalog.targets {
+ if target.id.hasPrefix("purge.") {
+ // Only allowed empty class: scan roots are user-configured; the catalog has no fixed prefix.
+ // Reserved roots come from the expansion source at runtime; see
+ // `DiskCleanScanEngineTests.testDeveloperArtifactScanReservesConfiguredRoots`.
+ XCTAssertTrue(target.reservedRootPaths.isEmpty, "developer-artifact target must not hardcode reserved roots: \(target.id)")
+ } else {
+ XCTAssertFalse(target.reservedRootPaths.isEmpty, "target missing reserved roots: \(target.id)")
+ }
+ for path in target.expandedReservedRootPaths(homeDirectory: home) {
+ XCTAssertTrue(path.hasPrefix("/"), "reserved root is not absolute: \(target.id) \(path)")
+ XCTAssertFalse(path.contains { "*?[".contains($0) }, "reserved root contains glob: \(target.id) \(path)")
+ XCTAssertFalse(path.contains("//"), "reserved root not normalized: \(target.id) \(path)")
+ XCTAssertFalse(path.hasSuffix("/"), "reserved root has trailing slash: \(target.id) \(path)")
+ XCTAssertEqual(
+ path,
+ (path as NSString).standardizingPath,
+ "reserved root not normalized: \(target.id) \(path)"
+ )
+ }
+ }
+ }
+
+ /// The real criterion is **no path component may be a symlink** — the sizing root opener uses `O_NOFOLLOW_ANY`
+ /// (see `DiskCleanRootOpener` docs); intermediate symlinks also fail with ELOOP.
+ /// A final-component symlink is a legitimate candidate (measured as the link, not followed).
+ ///
+ /// Only runtime `realpath(3)` can decide that, and catalog globs often point at paths that may not exist on this machine,
+ /// so here we statically block the three well-known macOS symlink prefixes (`/var -> private/var`,
+ /// `/tmp -> private/tmp`, `/etc -> private/etc`). Future system directories must use `/private/...` physical paths.
+ /// Note `resolvingSymlinksInPath()` does not help: it tends to strip `/private` rather than add it.
+ func testNoTargetPathStartsWithSymlinkedSystemPrefix() {
+ let symlinkedRoots = ["/var", "/tmp", "/etc"]
+ for target in catalog.targets {
+ for path in target.pathGlobs + target.reservedRootPaths {
+ for root in symlinkedRoots {
+ XCTAssertNotEqual(path, root, "path must use /private physical path: \(target.id) \(path)")
+ XCTAssertFalse(
+ path.hasPrefix(root + "/"),
+ "path traverses a symlinked directory; rewrite to /private physical path: \(target.id) \(path)"
+ )
+ }
+ }
+ }
+ }
+
+ /// Reserved roots must equal the deduped set of glob fixed-directory prefixes — recompute independently so hand-edited globs cannot drift.
+ func testPathTargetReservedRootsEqualGlobFixedPrefixes() {
+ for target in catalog.ruleTargets where !target.isDynamic {
+ var expected: [String] = []
+ for glob in target.pathGlobs {
+ let prefix = Self.fixedDirectoryPrefix(of: glob)
+ if !expected.contains(prefix) {
+ expected.append(prefix)
+ }
+ }
+ XCTAssertEqual(target.reservedRootPaths, expected, "reserved roots disagree with glob fixed prefixes: \(target.id)")
+ }
+ }
+
+ func testDynamicTargetReservedRootsCoverProviderScopes() {
+ XCTAssertEqual(
+ catalog.target(id: "developer.simulator-unavailable")?.reservedRootPaths,
+ ["~/Library/Developer/CoreSimulator/Devices"]
+ )
+ XCTAssertEqual(
+ catalog.target(id: "developer.jetbrains-toolbox-old-versions")?.reservedRootPaths,
+ ["~/Library/Application Support/JetBrains/Toolbox/apps"]
+ )
+ XCTAssertEqual(
+ catalog.target(id: "browser.old-versions")?.reservedRootPaths.first,
+ "~/Library/Application Support/Google/GoogleUpdater"
+ )
+ }
+
+ // MARK: - Risk and category
+
+ func testDynamicTargetsAreAtLeastMediumRisk() {
+ let dynamicTargets = catalog.targets.filter(\.isDynamic)
+ XCTAssertEqual(dynamicTargets.count, 4)
+ XCTAssertEqual(
+ Set(dynamicTargets.map(\.id)),
+ [
+ "developer.simulator-unavailable",
+ "developer.jetbrains-toolbox-old-versions",
+ "developer.ai-agent-old-versions",
+ "browser.old-versions"
+ ]
+ )
+ for target in dynamicTargets {
+ XCTAssertGreaterThanOrEqual(target.risk, .medium, "dynamic target risk too low: \(target.id)")
+ }
+ }
+
+ func testMappingTableRiskAndCategoryForSplitAndElevatedTargets() {
+ assertTarget("cache.user-essentials.caches", category: .userEssentials, risk: .low)
+ assertTarget("cache.user-essentials.logs", category: .logs, risk: .low)
+ assertTarget("cache.macos-app-state", category: .systemCaches, risk: .medium)
+ assertTarget("cache.virtualization", category: .virtualization, risk: .medium)
+ assertTarget("developer.rust-go", category: .developer, risk: .low)
+ assertTarget("developer.docker", category: .developer, risk: .medium)
+ assertTarget("developer.ai-agent-old-versions", category: .aiTools, risk: .medium)
+ assertTarget("browser.safari", category: .browsers, risk: .low)
+ assertTarget("browser.chrome.service-worker", category: .browsers, risk: .medium)
+ assertTarget("browser.service-worker", category: .browsers, risk: .medium)
+ }
+
+ func testServiceWorkerTargetsAreAllMediumRisk() {
+ let serviceWorkerTargets = catalog.targets.filter { $0.id.contains("service-worker") }
+ XCTAssertEqual(serviceWorkerTargets.count, 6)
+ for target in serviceWorkerTargets {
+ XCTAssertEqual(target.risk, .medium, "Service Worker target should be medium: \(target.id)")
+ }
+ }
+
+ func testCategoryDisplayOrderCoversAllCasesWithNonDecreasingRisk() {
+ XCTAssertEqual(Set(DiskCleanCategoryID.displayOrder), Set(DiskCleanCategoryID.allCases))
+ XCTAssertEqual(DiskCleanCategoryID.displayOrder.count, DiskCleanCategoryID.allCases.count)
+
+ let risks = DiskCleanCategoryID.displayOrder.map(\.risk)
+ XCTAssertEqual(risks, risks.sorted())
+ }
+
+ func testEveryCategoryOwnsAtLeastOneTarget() {
+ for category in DiskCleanCategoryID.allCases {
+ XCTAssertFalse(catalog.targets(in: category).isEmpty, "category has no targets: \(category.rawValue)")
+ }
+ }
+
+ func testTargetsInDisplayOrderAreCategoryThenRiskOrdered() {
+ let ordered = catalog.targetsInDisplayOrder
+ XCTAssertEqual(ordered.count, catalog.targets.count)
+
+ let categoryRank = Dictionary(
+ uniqueKeysWithValues: DiskCleanCategoryID.displayOrder.enumerated().map { ($0.element, $0.offset) }
+ )
+ for (previous, next) in zip(ordered, ordered.dropFirst()) {
+ let previousRank = categoryRank[previous.category] ?? 0
+ let nextRank = categoryRank[next.category] ?? 0
+ XCTAssertLessThanOrEqual(previousRank, nextRank)
+ if previous.category == next.category {
+ XCTAssertLessThanOrEqual(previous.risk, next.risk)
+ }
+ }
+ }
+
+ // MARK: - Locking and FDA
+
+ func testProcessLockedTargetsAlsoDeclareBundleIDs() {
+ for target in catalog.targets where !target.skipWhenProcessIsRunning.isEmpty {
+ XCTAssertFalse(
+ target.lockedByBundleIDs.isEmpty,
+ "declares process names but no bundle IDs: \(target.id)"
+ )
+ }
+ }
+
+ func testFirstVersionProcessLocksArePreserved() {
+ XCTAssertEqual(
+ catalog.target(id: "developer.xcode-derived-data")?.skipWhenProcessIsRunning,
+ ["Xcode"]
+ )
+ XCTAssertEqual(
+ catalog.target(id: "developer.simulator-unavailable")?.skipWhenProcessIsRunning,
+ ["Simulator"]
+ )
+ XCTAssertEqual(
+ catalog.target(id: "browser.chrome.service-worker")?.lockedByBundleIDs,
+ ["com.google.Chrome"]
+ )
+ }
+
+ /// TCC-protected prefix table (design §9).
+ ///
+ /// Declared **independently in the test**; marks are reverse-derived from it: a catalog that sets
+ /// `requiresFullDiskAccess` wrongly, or omits it when required, fails either way. New targets whose
+ /// full glob set lands in protected ranges without the mark are also caught here.
+ ///
+ /// Rationale is in `DiskCleanRuleCatalogV2` docs; briefly: containers use macOS 14+
+ /// `kTCCServiceSystemPolicyAppData` (prompts), Safari caches and Suggestions/Calendars/AddressBook
+ /// are FDA / Calendar / Contacts protected (silent EPERM).
+ private static let tccProtectedPrefixes = [
+ "~/Library/Containers/",
+ "~/Library/Group Containers/",
+ "~/Library/Caches/com.apple.Safari",
+ "~/Library/Safari",
+ "~/Library/Suggestions",
+ "~/Library/Calendars",
+ "~/Library/Application Support/AddressBook"
+ ]
+
+ func testFullDiskAccessMarksAreDerivableFromTCCProtectedPrefixes() {
+ for target in catalog.ruleTargets where !target.pathGlobs.isEmpty {
+ let allProtected = target.pathGlobs.allSatisfy { glob in
+ Self.tccProtectedPrefixes.contains { glob.hasPrefix($0) }
+ }
+ XCTAssertEqual(
+ target.requiresFullDiskAccess,
+ allProtected,
+ allProtected
+ ? "all globs are TCC-protected but not marked: \(target.id)"
+ : "contains non-protected globs yet marked FDA, which would skip cleanable paths too: \(target.id)"
+ )
+ }
+ }
+
+ /// Mark-set snapshot. Prevents silent coverage changes for unauthorized users when a target is split without carrying the mark.
+ func testFullDiskAccessTargetSnapshot() {
+ XCTAssertEqual(
+ catalog.targets.filter(\.requiresFullDiskAccess).map(\.id).sorted(),
+ [
+ "browser.safari",
+ "cache.apple-sandboxed-apps.containers",
+ "cache.macos-app-state.protected",
+ "cache.office-apps.containers",
+ "cache.productivity-media.containers"
+ ]
+ )
+ }
+
+ /// Dynamic and synthetic targets are never marked: the former degrades via their provider, the latter never goes through rule expansion;
+ /// a mark would only make `fdaRestricted` report a target id the user cannot map to content.
+ func testDynamicAndExternalTargetsNeverRequireFullDiskAccess() {
+ for target in catalog.targets where target.isDynamic || target.isExternallyDiscovered {
+ XCTAssertFalse(target.requiresFullDiskAccess, "must not mark FDA: \(target.id)")
+ }
+ }
+
+ /// Split-out FDA targets must keep the parent category and risk — the only reason to split is TCC,
+ /// not to change what the user sees.
+ func testFullDiskAccessSplitsKeepParentCategoryAndRisk() {
+ for target in catalog.targets where target.requiresFullDiskAccess {
+ let siblings = catalog.targets(legacyRuleID: target.legacyRuleID)
+ for sibling in siblings where sibling.id != target.id {
+ XCTAssertEqual(sibling.category, target.category, "split changed category: \(target.id)")
+ XCTAssertEqual(sibling.risk, target.risk, "split changed risk: \(target.id)")
+ }
+ }
+ }
+
+ // MARK: - P2 synthetic targets
+
+ func testExternalTargetsCoverEveryPurgeAndInstallerKind() {
+ XCTAssertEqual(
+ catalog.targets(in: .developerArtifacts).map(\.id),
+ DiskCleanPurgeKind.allCases.map(\.targetID)
+ )
+ XCTAssertEqual(
+ catalog.targets(in: .installers).map(\.id),
+ DiskCleanInstallerKind.allCases.map(\.targetID)
+ )
+ for target in catalog.targets(in: .developerArtifacts) + catalog.targets(in: .installers) {
+ XCTAssertTrue(target.isExternallyDiscovered, "P2 target should be external: \(target.id)")
+ XCTAssertTrue(target.pathGlobs.isEmpty, "P2 target must not have globs: \(target.id)")
+ // Fallback risk must be >= medium: if the expansion source omits an override, default to not selected.
+ XCTAssertGreaterThanOrEqual(target.risk, .medium, "P2 fallback risk too low: \(target.id)")
+ }
+ }
+
+ /// Synthetic targets must actually exist in the catalog — `DiskCleanPlanner.makePlan` looks up by targetID,
+ /// throws `unknownTarget` if missing, and P2 candidates become permanently uncleansable dead items.
+ func testEveryExternalTargetIDResolvesInCatalog() {
+ for kind in DiskCleanPurgeKind.allCases {
+ XCTAssertNotNil(catalog.target(id: kind.targetID), "missing target: \(kind.targetID)")
+ }
+ for kind in DiskCleanInstallerKind.allCases {
+ XCTAssertNotNil(catalog.target(id: kind.targetID), "missing target: \(kind.targetID)")
+ }
+ }
+
+ func testRuleTargetsExcludeExternalTargets() {
+ XCTAssertFalse(catalog.ruleTargets.contains { $0.isExternallyDiscovered })
+ XCTAssertEqual(
+ catalog.ruleTargets.count + DiskCleanPurgeKind.allCases.count + DiskCleanInstallerKind.allCases.count,
+ catalog.targets.count
+ )
+ }
+
+ // MARK: - Helpers
+
+ private func assertTarget(
+ _ id: String,
+ category: DiskCleanCategoryID,
+ risk: DiskCleanRisk,
+ file: StaticString = #filePath,
+ line: UInt = #line
+ ) {
+ guard let target = catalog.target(id: id) else {
+ return XCTFail("missing target: \(id)", file: file, line: line)
+ }
+ XCTAssertEqual(target.category, category, "category mismatch: \(id)", file: file, line: line)
+ XCTAssertEqual(target.risk, risk, "risk mismatch: \(id)", file: file, line: line)
+ }
+
+ private static func owner(of glob: String, in catalog: DiskCleanRuleCatalogV2) -> DiskCleanRuleTarget? {
+ catalog.targets.first { $0.pathGlobs.contains(glob) }
+ }
+
+ private static func firstVersionPathGlobs() -> Set {
+ var globs: Set = []
+ for rule in DiskCleanRuleCatalog.moleFirstVersion.rules {
+ for target in rule.targets {
+ if case let .path(glob) = target {
+ globs.insert(glob)
+ }
+ }
+ }
+ return globs
+ }
+
+ /// Last complete path component before the first glob metacharacter. Without metacharacters, the path itself.
+ private static func fixedDirectoryPrefix(of glob: String) -> String {
+ guard let metaIndex = glob.firstIndex(where: { "*?[".contains($0) }) else {
+ return glob
+ }
+ let head = glob[glob.startIndex.. Error? in
+ do {
+ for try await _ in engine.scan(choices: [.cache], forceRefresh: false) {
+ try await Task.sleep(nanoseconds: 50_000_000)
+ }
+ return nil
+ } catch {
+ return error
+ }
+ }
+ try? await Task.sleep(nanoseconds: 20_000_000)
+ task.cancel()
+
+ let error = await task.value
+ XCTAssertTrue(error is CancellationError)
+ }
+
+ // MARK: - Scan scope (panel equivalence)
+
+ func testScopeFiltersTargetsByLegacyRuleIDPrefix() async throws {
+ let fileSystem = FakeDiskCleanFileSystem()
+ fileSystem.setItems([.testDirectory("/cache/item")], forPattern: "/cache/*")
+ fileSystem.setItems([.testDirectory("/developer/item")], forPattern: "/developer/*")
+ fileSystem.setItems([.testDirectory("/browser/item")], forPattern: "/browser/*")
+ let engine = makeEngine(
+ fileSystem: fileSystem,
+ targets: [
+ .test(id: "cache.x", legacyRuleID: "cache.x", globs: ["/cache/*"]),
+ // Category is developer but legacy prefix is browser — panel membership must follow the legacy prefix;
+ // this is the kind of target where "select by category" changes scan coverage.
+ .test(
+ id: "browser.service-worker.editors",
+ legacyRuleID: "browser.service-worker",
+ category: .developer,
+ globs: ["/browser/*"]
+ ),
+ .test(id: "developer.y", legacyRuleID: "developer.y", category: .developer, globs: ["/developer/*"])
+ ]
+ )
+
+ let summary = try await finish(engine, choices: [.browser])
+
+ XCTAssertEqual(summary.artifact.candidates.map(\.path), ["/browser/item"])
+ }
+
+ // MARK: - limitations
+
+ func testReportsFullDiskAccessRestrictionWithReservedRootsInArtifact() async throws {
+ let engine = makeEngine(
+ fileSystem: FakeDiskCleanFileSystem(),
+ fullDiskAccess: FakeDiskCleanFullDiskAccess(hasFullDiskAccess: false),
+ targets: [
+ .test(
+ id: "cache.system",
+ globs: ["/private/var/log/*"],
+ reservedRootPaths: ["/private/var/log"],
+ requiresFullDiskAccess: true
+ )
+ ]
+ )
+
+ let summary = try await finish(engine)
+
+ XCTAssertEqual(summary.limitations, [.fdaRestricted(skippedTargetIDs: ["cache.system"])])
+ XCTAssertEqual(
+ summary.artifact.reservedRootPaths,
+ ["/private/var/log"],
+ "reserved roots of skipped targets must enter the artifact: ancestors of unscanned subtrees must never be deleted"
+ )
+ }
+
+ func testReportsDynamicRuleFailureAndKeepsScanningOtherTargets() async throws {
+ let fileSystem = FakeDiskCleanFileSystem()
+ fileSystem.setItems([.testDirectory("/cache/item")], forPattern: "/cache/*")
+ let engine = makeEngine(
+ fileSystem: fileSystem,
+ targets: [
+ .test(
+ id: "cache.dynamic",
+ provider: FakeFailingDynamicRuleProvider(),
+ reservedRootPaths: ["/dynamic/root"]
+ ),
+ .test(id: "cache.static", globs: ["/cache/*"])
+ ]
+ )
+
+ let summary = try await finish(engine)
+
+ XCTAssertEqual(
+ summary.limitations,
+ [.dynamicRuleFailed(targetID: "cache.dynamic", reason: "provider exploded")]
+ )
+ XCTAssertEqual(summary.artifact.reservedRootPaths, ["/dynamic/root"])
+ XCTAssertEqual(summary.artifact.candidates.map(\.path), ["/cache/item"], "one rule failure must not block other rules")
+ }
+
+ func testReportsPathExpansionFailureSeparatelyFromDynamicFailure() async throws {
+ let fileSystem = FakeDiskCleanFileSystem()
+ fileSystem.setError(FakeDiskCleanExpansionError(message: "glob blew up"), forPattern: "/cache/*")
+ let engine = makeEngine(
+ fileSystem: fileSystem,
+ targets: [.test(id: "cache.static", globs: ["/cache/*"], reservedRootPaths: ["/cache"])]
+ )
+
+ let summary = try await finish(engine)
+
+ XCTAssertEqual(
+ summary.limitations,
+ [.targetExpansionFailed(targetID: "cache.static", reason: "glob blew up")]
+ )
+ XCTAssertEqual(summary.artifact.reservedRootPaths, ["/cache"])
+ }
+
+ func testReportsVolumeSkippedFromSizeResults() async throws {
+ let fileSystem = FakeDiskCleanFileSystem()
+ fileSystem.setItems([.testDirectory("/cache/item")], forPattern: "/cache/*")
+ let executor = FakeDiskCleanSizingExecutor()
+ executor.setResult(.testPartial(reasons: [.unsupportedVolume]), forPath: "/cache/item")
+ let engine = makeEngine(
+ fileSystem: fileSystem,
+ sizingExecutor: executor,
+ targets: [.test(id: "cache.a", globs: ["/cache/*"])]
+ )
+
+ let summary = try await finish(engine)
+
+ XCTAssertEqual(summary.limitations, [.volumeSkipped(path: "/cache/item")])
+ }
+
+ func testDerivesCircuitBreakerAndAbandonedThreadsFromPoolState() async throws {
+ let fileSystem = FakeDiskCleanFileSystem()
+ fileSystem.setItems([.testDirectory("/cache/item")], forPattern: "/cache/*")
+ let executor = FakeDiskCleanSizingExecutor()
+ executor.setPoolState(isCircuitBroken: true, abandonedThreads: 3)
+ let engine = makeEngine(
+ fileSystem: fileSystem,
+ sizingExecutor: executor,
+ targets: [.test(id: "cache.a", globs: ["/cache/*"])]
+ )
+
+ let summary = try await finish(engine)
+
+ XCTAssertTrue(summary.limitations.contains(.walkerCircuitBroken))
+ XCTAssertTrue(summary.limitations.contains(.threadsAbandoned(count: 3)))
+ }
+
+ func testCapsVolumeSkippedReportsSoLimitationsStayBounded() async throws {
+ let fileSystem = FakeDiskCleanFileSystem()
+ let items = (0..<30).map { DiskCleanFileItem.testDirectory("/cache/item\($0)") }
+ fileSystem.setItems(items, forPattern: "/cache/*")
+ let executor = FakeDiskCleanSizingExecutor()
+ executor.setDefaultResult(.testPartial(reasons: [.unsupportedVolume]))
+ var configuration = DiskCleanScanEngineConfiguration()
+ configuration.maximumVolumeSkippedReports = 4
+ let engine = makeEngine(
+ fileSystem: fileSystem,
+ sizingExecutor: executor,
+ configuration: configuration,
+ targets: [.test(id: "cache.a", globs: ["/cache/*"])]
+ )
+
+ let summary = try await finish(engine)
+
+ XCTAssertEqual(summary.limitations.count, 4)
+ }
+
+ // MARK: - Lock detection
+
+ func testLockedTargetProducesInUseCandidatesThatAreNotCleanable() async throws {
+ let fileSystem = FakeDiskCleanFileSystem()
+ fileSystem.setItems([.testDirectory("/cache/chrome")], forPattern: "/cache/*")
+ let engine = makeEngine(
+ fileSystem: fileSystem,
+ runningAppLock: FakeDiskCleanRunningAppLock(
+ snapshot: DiskCleanRunningAppSnapshot(runningBundleIDs: ["com.google.chrome"])
+ ),
+ targets: [
+ .test(id: "cache.a", globs: ["/cache/*"], lockedByBundleIDs: ["com.google.Chrome"])
+ ]
+ )
+
+ let summary = try await finish(engine)
+ let candidate = try XCTUnwrap(summary.artifact.candidates.first)
+
+ XCTAssertEqual(candidate.safety, .inUse(processName: "com.google.Chrome"))
+ XCTAssertFalse(candidate.isCleanable)
+ }
+
+ func testQueriesRunningProcessNamesOfScopedTargetsOnlyOnce() async throws {
+ final class RecordingLock: DiskCleanRunningAppSnapshotting, @unchecked Sendable {
+ private let storage = NSLock()
+ private var requests: [[String]] = []
+ var recordedRequests: [[String]] { storage.withLock { requests } }
+
+ func makeSnapshot(processNames: [String]) async -> DiskCleanRunningAppSnapshot {
+ storage.withLock { requests.append(processNames) }
+ return DiskCleanRunningAppSnapshot()
+ }
+ }
+
+ let fileSystem = FakeDiskCleanFileSystem()
+ fileSystem.setItems([.testDirectory("/cache/a")], forPattern: "/cache/*")
+ let lock = RecordingLock()
+ let engine = makeEngine(
+ fileSystem: fileSystem,
+ runningAppLock: lock,
+ targets: [
+ .test(id: "cache.a", globs: ["/cache/*"], skipWhenProcessIsRunning: ["Docker", "Xcode"]),
+ .test(id: "cache.b", globs: ["/other/*"], skipWhenProcessIsRunning: ["Docker"])
+ ]
+ )
+
+ _ = try await finish(engine)
+
+ XCTAssertEqual(
+ lock.recordedRequests,
+ [["Docker", "Xcode"]],
+ "one snapshot queries all process names, replacing v1 per-rule pgrep"
+ )
+ }
+
+ // MARK: - Cache wiring
+
+ func testSizeCacheHitSkipsSizerAndPreservesObservedAt() async throws {
+ let path = "/cache/item"
+ let identity = DiskCleanRootIdentity.test(devid: 7, fileID: 9)
+ let observedAt = Date(timeIntervalSince1970: 1_000)
+ let cache = DiskCleanSizeCache()
+ cache.store(
+ path: path,
+ result: .testComplete(bytes: 4_096, identity: identity, observedAt: observedAt),
+ now: observedAt
+ )
+ let sizer = FakeDiskCleanSizer()
+ let fileSystem = FakeDiskCleanFileSystem()
+ fileSystem.setItems([.testDirectory(path)], forPattern: "/cache/*")
+ let readAt = observedAt.addingTimeInterval(10)
+ let engine = makeEngine(
+ fileSystem: fileSystem,
+ sizingExecutor: DirectDiskCleanSizingExecutor(now: { readAt }),
+ sizer: sizer,
+ sizeCache: cache,
+ identityProbe: FakeDiskCleanRootIdentityProbe(identitiesByPath: [path: identity]),
+ targets: [.test(id: "cache.a", globs: ["/cache/*"])],
+ now: { readAt }
+ )
+
+ let summary = try await finish(engine)
+
+ XCTAssertTrue(sizer.calledPaths.isEmpty, "cache hit must not re-walk the directory")
+ XCTAssertEqual(summary.artifact.candidates.first?.sizeResult?.estimatedBytes, 4_096)
+ XCTAssertEqual(
+ summary.artifact.candidates.first?.sizeResult?.observedAt,
+ observedAt,
+ "observedAt must carry the cache entry original observation time or the expiry gate is bypassed"
+ )
+ }
+
+ func testForceRefreshBypassesSizeCache() async throws {
+ let path = "/cache/item"
+ let identity = DiskCleanRootIdentity.test()
+ let cache = DiskCleanSizeCache()
+ cache.store(path: path, result: .testComplete(bytes: 4_096, identity: identity), now: Date())
+ let sizer = FakeDiskCleanSizer()
+ sizer.setResult(.testComplete(bytes: 8_192, identity: identity), forPath: path)
+ let fileSystem = FakeDiskCleanFileSystem()
+ fileSystem.setItems([.testDirectory(path)], forPattern: "/cache/*")
+ let engine = makeEngine(
+ fileSystem: fileSystem,
+ sizingExecutor: DirectDiskCleanSizingExecutor(),
+ sizer: sizer,
+ sizeCache: cache,
+ identityProbe: FakeDiskCleanRootIdentityProbe(identitiesByPath: [path: identity]),
+ targets: [.test(id: "cache.a", globs: ["/cache/*"])]
+ )
+
+ let summary = try await finish(engine, forceRefresh: true)
+
+ XCTAssertEqual(sizer.calledPaths, [path])
+ XCTAssertEqual(summary.artifact.candidates.first?.sizeResult?.estimatedBytes, 8_192)
+ }
+
+ // MARK: - P2 sections join the unified pipeline (design §10)
+
+ /// Core assertion: candidates from dedicated scanners and rule candidates share **the same pipeline** —
+ /// same sizing, same completeness, same artifact entry, so both can be minted into plans by `makePlan`.
+ /// Any delete path that bypasses this pipeline makes this test meaningless.
+ func testDeveloperArtifactCandidatesFlowThroughSizingIntoTheArtifact() async throws {
+ let target = DiskCleanRuleTarget.testExternal(id: DiskCleanPurgeKind.nodeModules.targetID)
+ let executor = FakeDiskCleanSizingExecutor()
+ executor.setResult(.testComplete(bytes: 4_096), forPath: "/code/app/node_modules")
+
+ let engine = makeEngine(
+ fileSystem: FakeDiskCleanFileSystem(),
+ sizingExecutor: executor,
+ developerArtifactExpansion: FakeDiskCleanExternalExpansion(
+ hits: [
+ DiskCleanTargetHit(
+ target: target,
+ item: .testDirectory("/code/app/node_modules"),
+ specificity: 0,
+ facts: DiskCleanCandidateFacts(
+ risk: .low,
+ notes: [.developerProject(path: "/code/app", marker: "package.json")]
+ )
+ )
+ ],
+ reservedRootPaths: ["/code"]
+ ),
+ targets: [target]
+ )
+
+ let summary = try await finish(engine, scope: .developerArtifacts(roots: ["/code"]))
+ let candidate = try XCTUnwrap(summary.artifact.candidates.first)
+
+ XCTAssertEqual(executor.requestedPaths, ["/code/app/node_modules"], "P2 candidates must go through unified sizing")
+ XCTAssertEqual(candidate.path, "/code/app/node_modules")
+ XCTAssertEqual(candidate.targetID, DiskCleanPurgeKind.nodeModules.targetID)
+ XCTAssertEqual(candidate.category, .developerArtifacts)
+ XCTAssertEqual(candidate.estimatedBytes, 4_096)
+ XCTAssertTrue(candidate.isCleanable)
+ XCTAssertEqual(candidate.notes, [.developerProject(path: "/code/app", marker: "package.json")])
+ }
+
+ /// Expansion-source risk overrides the target fallback; when omitted, keep the target (fail-safe to not default-selected).
+ func testExpansionFactsOverrideTargetRiskPerCandidate() async throws {
+ let target = DiskCleanRuleTarget.testExternal(id: DiskCleanPurgeKind.nodeModules.targetID, risk: .medium)
+ let engine = makeEngine(
+ fileSystem: FakeDiskCleanFileSystem(),
+ developerArtifactExpansion: FakeDiskCleanExternalExpansion(
+ hits: [
+ DiskCleanTargetHit(
+ target: target,
+ item: .testDirectory("/code/clean/node_modules"),
+ specificity: 0,
+ facts: DiskCleanCandidateFacts(risk: .low)
+ ),
+ DiskCleanTargetHit(
+ target: target,
+ item: .testDirectory("/code/dirty/node_modules"),
+ specificity: 0,
+ facts: DiskCleanCandidateFacts(
+ notes: [.repositoryHasChanges(repositoryPath: "/code/dirty", reason: .uncommittedChanges)]
+ )
+ )
+ ],
+ reservedRootPaths: ["/code"]
+ ),
+ targets: [target]
+ )
+
+ let summary = try await finish(engine, scope: .developerArtifacts(roots: ["/code"]))
+ let risksByPath = Dictionary(
+ summary.artifact.candidates.map { ($0.path, $0.risk) },
+ uniquingKeysWith: { first, _ in first }
+ )
+
+ XCTAssertEqual(risksByPath["/code/clean/node_modules"], .low)
+ XCTAssertEqual(risksByPath["/code/dirty/node_modules"], .medium)
+ XCTAssertEqual(
+ DiskCleanSelectionModel().projection(for: summary.artifact.candidates).selectedIDs.count,
+ 1,
+ "only the low-risk item is default-selected"
+ )
+ }
+
+ func testDeveloperArtifactScanReservesConfiguredRoots() async throws {
+ let target = DiskCleanRuleTarget.testExternal(id: DiskCleanPurgeKind.nodeModules.targetID)
+ let engine = makeEngine(
+ fileSystem: FakeDiskCleanFileSystem(),
+ developerArtifactExpansion: FakeDiskCleanExternalExpansion(
+ hits: [
+ DiskCleanTargetHit(
+ target: target,
+ item: .testDirectory("/code/app/node_modules"),
+ specificity: 0
+ )
+ ],
+ reservedRootPaths: ["/code", "/work"]
+ ),
+ targets: [target]
+ )
+
+ let summary = try await finish(engine, scope: .developerArtifacts(roots: ["/code", "/work"]))
+
+ XCTAssertEqual(summary.artifact.reservedRootPaths, ["/code", "/work"])
+ XCTAssertEqual(summary.artifact.scope, .developerArtifacts(roots: ["/code", "/work"]))
+ }
+
+ /// Reserved scan roots extend Planner ancestor assertions to P2: candidates **inside** a root remain deletable,
+ /// while any path that would make the root a descendant (e.g. the root's parent) is refused.
+ func testPlannerAcceptsCandidatesInsideReservedRootsButRejectsTheirAncestors() async throws {
+ let target = DiskCleanRuleTarget.testExternal(id: DiskCleanPurgeKind.nodeModules.targetID)
+ let engine = makeEngine(
+ fileSystem: FakeDiskCleanFileSystem(),
+ developerArtifactExpansion: FakeDiskCleanExternalExpansion(
+ hits: [
+ DiskCleanTargetHit(
+ target: target,
+ item: .testDirectory("/code/app/node_modules"),
+ specificity: 0,
+ facts: DiskCleanCandidateFacts(risk: .low)
+ )
+ ],
+ reservedRootPaths: ["/code"]
+ ),
+ targets: [target]
+ )
+
+ let artifact = try await finish(engine, scope: .developerArtifacts(roots: ["/code"])).artifact
+ let candidate = try XCTUnwrap(artifact.candidates.first)
+
+ let plan = try await MainActor.run {
+ try DiskCleanPlanner.makePlan(
+ artifact: artifact,
+ selectedIDs: [candidate.id],
+ mode: .trash,
+ now: candidate.observedAt ?? Date(),
+ catalog: DiskCleanRuleCatalogV2(targets: [target])
+ )
+ }
+ XCTAssertEqual(plan.items.map(\.path), ["/code/app/node_modules"])
+ XCTAssertEqual(plan.reservedPrefixes, ["/code"])
+
+ // Under the same evidence, "delete the scan root parent" must be refused by ancestor assertion.
+ XCTAssertThrowsError(
+ try DiskCleanPlanner.assertNoAncestorViolation(
+ plannedPaths: ["/"],
+ exclusionPaths: [],
+ reservedPrefixes: plan.reservedPrefixes
+ )
+ ) { error in
+ XCTAssertEqual(
+ error as? DiskCleanPlanError,
+ .ancestorViolation(plannedPath: "/", protectedPath: "/code")
+ )
+ }
+ }
+
+ func testInstallerScopeUsesInstallerExpansionOnly() async throws {
+ let installerTarget = DiskCleanRuleTarget.testExternal(
+ id: DiskCleanInstallerKind.diskImage.targetID,
+ category: .installers,
+ reservedRootPaths: ["/downloads"]
+ )
+ let purgeTarget = DiskCleanRuleTarget.testExternal(id: DiskCleanPurgeKind.nodeModules.targetID)
+ let engine = makeEngine(
+ fileSystem: FakeDiskCleanFileSystem(),
+ developerArtifactExpansion: FakeDiskCleanExternalExpansion(
+ hits: [
+ DiskCleanTargetHit(target: purgeTarget, item: .testDirectory("/code/x/node_modules"), specificity: 0)
+ ]
+ ),
+ installerExpansion: FakeDiskCleanExternalExpansion(
+ hits: [
+ DiskCleanTargetHit(target: installerTarget, item: .testFile("/downloads/Tool.dmg"), specificity: 0)
+ ],
+ reservedRootPaths: ["/downloads"]
+ ),
+ targets: [installerTarget, purgeTarget]
+ )
+
+ let summary = try await finish(engine, scope: .installers)
+
+ XCTAssertEqual(summary.artifact.candidates.map(\.path), ["/downloads/Tool.dmg"])
+ XCTAssertEqual(summary.artifact.candidates.first?.category, .installers)
+ }
+
+ /// Ordinary three-group scans **never** piggyback P2: developer artifacts walk user project trees and installers trigger
+ /// the `~/Downloads` TCC prompt; both must be started explicitly in their own sections.
+ func testRuleScanNeverInvokesExternalExpansionOrExternalTargets() async throws {
+ let fileSystem = FakeDiskCleanFileSystem()
+ fileSystem.setItems([.testDirectory("/cache/item")], forPattern: "/cache/*")
+ let engine = makeEngine(
+ fileSystem: fileSystem,
+ developerArtifactExpansion: FakeDiskCleanExternalExpansion(
+ hits: [
+ DiskCleanTargetHit(
+ target: .testExternal(id: DiskCleanPurgeKind.nodeModules.targetID),
+ item: .testDirectory("/code/app/node_modules"),
+ specificity: 0
+ )
+ ],
+ reservedRootPaths: ["/code"]
+ ),
+ targets: [
+ .test(id: "cache.a", globs: ["/cache/*"]),
+ .testExternal(id: DiskCleanPurgeKind.nodeModules.targetID),
+ .testExternal(id: DiskCleanInstallerKind.diskImage.targetID, category: .installers)
+ ]
+ )
+
+ let summary = try await finish(engine, choices: Set(DiskCleanChoice.allCases))
+
+ XCTAssertEqual(summary.artifact.candidates.map(\.path), ["/cache/item"])
+ XCTAssertFalse(summary.artifact.reservedRootPaths.contains("/code"))
+ }
+
+ /// Unreadable scan roots must be reported honestly: when TCC denies `~/Downloads` it may hold tens of GB;
+ /// reporting "nothing to clean" would mislead the user.
+ func testExternalExpansionLimitationsReachTheSummary() async throws {
+ let engine = makeEngine(
+ fileSystem: FakeDiskCleanFileSystem(),
+ installerExpansion: FakeDiskCleanExternalExpansion(
+ reservedRootPaths: ["/downloads"],
+ limitations: [.scanRootUnreadable(path: "/downloads", reason: .permissionDenied)]
+ ),
+ targets: [
+ .testExternal(
+ id: DiskCleanInstallerKind.diskImage.targetID,
+ category: .installers,
+ reservedRootPaths: ["/downloads"]
+ )
+ ]
+ )
+
+ let summary = try await finish(engine, scope: .installers)
+
+ XCTAssertEqual(
+ summary.limitations,
+ [.scanRootUnreadable(path: "/downloads", reason: .permissionDenied)]
+ )
+ XCTAssertTrue(summary.artifact.candidates.isEmpty)
+ XCTAssertEqual(summary.artifact.reservedRootPaths, ["/downloads"])
+ }
+
+ // MARK: - Fixtures
+
+ private func makeEngine(
+ fileSystem: any DiskCleanFileSystemProviding,
+ sizingExecutor: any DiskCleanSizingExecuting = FakeDiskCleanSizingExecutor(),
+ sizer: any DiskCleanDirectorySizing = FakeDiskCleanSizer(),
+ sizeCache: DiskCleanSizeCache = DiskCleanSizeCache(),
+ identityProbe: any DiskCleanRootIdentityProbing = FakeDiskCleanRootIdentityProbe(identitiesByPath: [:]),
+ runningAppLock: any DiskCleanRunningAppSnapshotting = FakeDiskCleanRunningAppLock(),
+ fullDiskAccess: any DiskCleanFullDiskAccessProbing = FakeDiskCleanFullDiskAccess(hasFullDiskAccess: true),
+ developerArtifactExpansion: any DiskCleanExternalExpanding = FakeDiskCleanExternalExpansion(),
+ installerExpansion: any DiskCleanExternalExpanding = FakeDiskCleanExternalExpansion(),
+ configuration: DiskCleanScanEngineConfiguration = DiskCleanScanEngineConfiguration(),
+ targets: [DiskCleanRuleTarget],
+ now: @escaping @Sendable () -> Date = { Date() }
+ ) -> DiskCleanScanEngine {
+ DiskCleanScanEngine(
+ catalog: DiskCleanRuleCatalogV2(targets: targets),
+ fileSystem: fileSystem,
+ safetyPolicy: DiskCleanSafetyPolicy(
+ homeDirectory: home,
+ whitelistStore: DiskCleanWhitelistStore(homeDirectory: home, includeDefaults: false)
+ ),
+ sizer: sizer,
+ sizingExecutor: sizingExecutor,
+ sizeCache: sizeCache,
+ identityProbe: identityProbe,
+ runningAppLock: runningAppLock,
+ fullDiskAccess: fullDiskAccess,
+ developerArtifactExpansion: developerArtifactExpansion,
+ installerExpansion: installerExpansion,
+ configuration: configuration,
+ now: now
+ )
+ }
+
+ private func collect(
+ _ engine: DiskCleanScanEngine,
+ choices: Set = Set(DiskCleanChoice.allCases),
+ forceRefresh: Bool = false
+ ) async throws -> [DiskCleanScanEvent] {
+ try await collect(engine, scope: .rules(choices: choices), forceRefresh: forceRefresh)
+ }
+
+ private func collect(
+ _ engine: DiskCleanScanEngine,
+ scope: DiskCleanScanScope,
+ forceRefresh: Bool = false
+ ) async throws -> [DiskCleanScanEvent] {
+ var events: [DiskCleanScanEvent] = []
+ for try await event in engine.scan(scope: scope, forceRefresh: forceRefresh) {
+ events.append(event)
+ }
+ return events
+ }
+
+ private func finish(
+ _ engine: DiskCleanScanEngine,
+ choices: Set = Set(DiskCleanChoice.allCases),
+ forceRefresh: Bool = false
+ ) async throws -> DiskCleanScanSummary {
+ try await finish(engine, scope: .rules(choices: choices), forceRefresh: forceRefresh)
+ }
+
+ private func finish(
+ _ engine: DiskCleanScanEngine,
+ scope: DiskCleanScanScope,
+ forceRefresh: Bool = false
+ ) async throws -> DiskCleanScanSummary {
+ let events = try await collect(engine, scope: scope, forceRefresh: forceRefresh)
+ return try XCTUnwrap(events.compactMap(\.summary).last)
+ }
+}
+
+// MARK: - Event projection
+
+extension DiskCleanScanEvent {
+ var isCandidateFound: Bool {
+ candidateFound != nil
+ }
+
+ var candidateFound: DiskCleanCandidate? {
+ guard case let .candidateFound(candidate) = self else { return nil }
+ return candidate
+ }
+
+ var isCandidateSized: Bool {
+ guard case .candidateSized = self else { return false }
+ return true
+ }
+
+ var finishedCategory: DiskCleanCategoryID? {
+ guard case let .categoryFinished(category) = self else { return nil }
+ return category
+ }
+
+ var summary: DiskCleanScanSummary? {
+ guard case let .finished(summary) = self else { return nil }
+ return summary
+ }
+
+ var logMessage: DiskCleanScanLogMessage? {
+ guard case let .log(message) = self else { return nil }
+ return message
+ }
+}
diff --git a/Plugins/DiskClean/Tests/DiskCleanScanTestSupport.swift b/Plugins/DiskClean/Tests/DiskCleanScanTestSupport.swift
new file mode 100644
index 00000000..7ce69092
--- /dev/null
+++ b/Plugins/DiskClean/Tests/DiskCleanScanTestSupport.swift
@@ -0,0 +1,603 @@
+import Foundation
+import os
+import XCTest
+import MacToolsPluginKit
+@testable import MacTools
+@testable import DiskCleanPlugin
+
+// MARK: - Result and identity factories
+
+extension DiskCleanRootIdentity {
+ static func test(
+ devid: UInt64 = 1,
+ fileID: UInt64 = 2,
+ mtime: Date = Date(timeIntervalSince1970: 1_000),
+ fileType: DiskCleanRootIdentity.FileType = .directory
+ ) -> DiskCleanRootIdentity {
+ DiskCleanRootIdentity(devid: devid, fileID: fileID, mtime: mtime, fileType: fileType)
+ }
+}
+
+extension DiskCleanSizeResult {
+ static func testComplete(
+ bytes: Int64 = 100,
+ fileCount: Int = 1,
+ identity: DiskCleanRootIdentity = .test(),
+ observedAt: Date = Date(timeIntervalSince1970: 10_000)
+ ) -> DiskCleanSizeResult {
+ DiskCleanSizeResult(
+ estimatedBytes: bytes,
+ fileCount: fileCount,
+ completeness: .complete,
+ rootIdentity: identity,
+ observedAt: observedAt
+ )
+ }
+
+ static func testPartial(
+ reasons: Set,
+ bytes: Int64 = 0,
+ identity: DiskCleanRootIdentity? = nil,
+ observedAt: Date = Date(timeIntervalSince1970: 10_000)
+ ) -> DiskCleanSizeResult {
+ DiskCleanSizeResult(
+ estimatedBytes: bytes,
+ fileCount: 0,
+ completeness: .partial(reasons: reasons),
+ rootIdentity: identity,
+ observedAt: observedAt
+ )
+ }
+}
+
+extension DiskCleanFileItem {
+ static func testDirectory(_ path: String) -> DiskCleanFileItem {
+ DiskCleanFileItem(path: path, isDirectory: true, isSymlink: false, resolvedSymlinkTarget: nil)
+ }
+
+ static func testFile(_ path: String) -> DiskCleanFileItem {
+ DiskCleanFileItem(path: path, isDirectory: false, isSymlink: false, resolvedSymlinkTarget: nil)
+ }
+}
+
+extension DiskCleanRuleTarget {
+ /// P2 synthetic target. `external` is mutually exclusive with glob/provider, so a dedicated factory rather than another parameter.
+ static func testExternal(
+ id: String,
+ category: DiskCleanCategoryID = .developerArtifacts,
+ risk: DiskCleanRisk = .medium,
+ reservedRootPaths: [String] = []
+ ) -> DiskCleanRuleTarget {
+ DiskCleanRuleTarget(
+ id: id,
+ legacyRuleID: id,
+ category: category,
+ risk: risk,
+ kind: .external,
+ reservedRootPaths: reservedRootPaths
+ )
+ }
+
+ static func test(
+ id: String,
+ legacyRuleID: String? = nil,
+ category: DiskCleanCategoryID = .appCaches,
+ risk: DiskCleanRisk = .low,
+ globs: [String] = [],
+ provider: (any DiskCleanDynamicRuleProviding)? = nil,
+ reservedRootPaths: [String] = ["/reserved"],
+ requiresFullDiskAccess: Bool = false,
+ lockedByBundleIDs: [String] = [],
+ skipWhenProcessIsRunning: [String] = []
+ ) -> DiskCleanRuleTarget {
+ DiskCleanRuleTarget(
+ id: id,
+ legacyRuleID: legacyRuleID ?? id,
+ category: category,
+ risk: risk,
+ kind: provider.map { DiskCleanRuleTarget.Kind.dynamic(provider: $0) } ?? .path(globs: globs),
+ reservedRootPaths: reservedRootPaths,
+ requiresFullDiskAccess: requiresFullDiskAccess,
+ lockedByBundleIDs: lockedByBundleIDs,
+ skipWhenProcessIsRunning: skipWhenProcessIsRunning
+ )
+ }
+}
+
+// MARK: - Programmable filesystem
+
+/// Pure in-memory filesystem. Engine expansion only needs "glob → hits" and "path → direct children" tables.
+final class FakeDiskCleanFileSystem: DiskCleanFileSystemProviding, @unchecked Sendable {
+ private let lock = NSLock()
+ private var itemsByPattern: [String: [DiskCleanFileItem]] = [:]
+ private var childrenByPath: [String: [DiskCleanFileItem]] = [:]
+ private var patternErrors: [String: Error] = [:]
+ private var unlistablePaths: Set = []
+
+ func setItems(_ items: [DiskCleanFileItem], forPattern pattern: String) {
+ lock.withLock { itemsByPattern[pattern] = items }
+ }
+
+ func setChildren(_ children: [DiskCleanFileItem], of path: String) {
+ lock.withLock { childrenByPath[path] = children }
+ }
+
+ func setError(_ error: Error, forPattern pattern: String) {
+ lock.withLock { patternErrors[pattern] = error }
+ }
+
+ func markUnlistable(_ path: String) {
+ lock.withLock { _ = unlistablePaths.insert(path) }
+ }
+
+ func expandPathPattern(_ pattern: String) throws -> [DiskCleanFileItem] {
+ let (items, error) = lock.withLock { (itemsByPattern[pattern] ?? [], patternErrors[pattern]) }
+ if let error { throw error }
+ return items
+ }
+
+ func itemInfo(at path: String) throws -> DiskCleanFileItem? {
+ lock.withLock { itemsByPattern.values.flatMap { $0 }.first { $0.path == path } }
+ }
+
+ func directChildren(of path: String) throws -> [DiskCleanFileItem] {
+ let (children, isUnlistable) = lock.withLock {
+ (childrenByPath[path] ?? [], unlistablePaths.contains(path))
+ }
+ if isUnlistable { throw FakeFileSystemError.unlistable(path) }
+ return children
+ }
+
+ func removeItem(at path: String) throws {}
+
+ func deduplicatedParentChildPaths(_ paths: [String]) -> [String] {
+ paths
+ }
+
+ enum FakeFileSystemError: Error, Equatable {
+ case unlistable(String)
+ }
+}
+
+struct FakeDiskCleanExpansionError: LocalizedError, Equatable {
+ let message: String
+
+ var errorDescription: String? { message }
+}
+
+struct FakeFailingDynamicRuleProvider: DiskCleanDynamicRuleProviding {
+ let error: Error
+
+ init(error: Error = FakeDiskCleanExpansionError(message: "provider exploded")) {
+ self.error = error
+ }
+
+ func expand() async throws -> [DiskCleanFileItem] {
+ throw error
+ }
+}
+
+struct FakeStaticDynamicRuleProvider: DiskCleanDynamicRuleProviding {
+ let items: [DiskCleanFileItem]
+
+ func expand() async throws -> [DiskCleanFileItem] {
+ items
+ }
+}
+
+// MARK: - Sizing execution seam
+
+/// Programmable sizing executor: records concurrency peak, request order, and deadlines; can inject delay and circuit-break state.
+///
+/// No real threads, so concurrency limits test the **engine TaskGroup sliding window**, not WorkerPool thread count.
+final class FakeDiskCleanSizingExecutor: DiskCleanSizingExecuting, @unchecked Sendable {
+ private struct State {
+ var resultsByPath: [String: DiskCleanSizeResult] = [:]
+ var defaultResult: DiskCleanSizeResult = .testComplete()
+ var requestedPaths: [String] = []
+ var deadlines: [Date] = []
+ var active = 0
+ var peakConcurrency = 0
+ var isCircuitBroken = false
+ var abandonedThreads = 0
+ }
+
+ private let lock = NSLock()
+ private var state = State()
+ /// Artificial per-call delay so concurrent tasks actually overlap.
+ let delay: Duration?
+
+ init(delay: Duration? = nil) {
+ self.delay = delay
+ }
+
+ func setResult(_ result: DiskCleanSizeResult, forPath path: String) {
+ lock.withLock { state.resultsByPath[path] = result }
+ }
+
+ func setDefaultResult(_ result: DiskCleanSizeResult) {
+ lock.withLock { state.defaultResult = result }
+ }
+
+ func setPoolState(isCircuitBroken: Bool, abandonedThreads: Int) {
+ lock.withLock {
+ state.isCircuitBroken = isCircuitBroken
+ state.abandonedThreads = abandonedThreads
+ }
+ }
+
+ var requestedPaths: [String] { lock.withLock { state.requestedPaths } }
+ var deadlines: [Date] { lock.withLock { state.deadlines } }
+ var peakConcurrency: Int { lock.withLock { state.peakConcurrency } }
+ var isCircuitBroken: Bool { lock.withLock { state.isCircuitBroken } }
+ var abandonedThreads: Int { lock.withLock { state.abandonedThreads } }
+
+ func size(
+ ofItemAt path: String,
+ using sizer: any DiskCleanDirectorySizing,
+ deadline: Date
+ ) async -> DiskCleanSizeResult {
+ lock.withLock {
+ state.requestedPaths.append(path)
+ state.deadlines.append(deadline)
+ state.active += 1
+ state.peakConcurrency = max(state.peakConcurrency, state.active)
+ }
+ if let delay {
+ try? await Task.sleep(for: delay)
+ }
+ return lock.withLock {
+ state.active -= 1
+ return state.resultsByPath[path] ?? state.defaultResult
+ }
+ }
+}
+
+/// Runs the sizer on the caller thread, not a pool. Used to verify engine and cache-decorator wiring.
+///
+/// `now` is injected separately: the real sizing-context clock belongs to WorkerPool (the engine cannot pass its own),
+/// so TTL tests must supply time in the same coordinate system as the cache write.
+struct DirectDiskCleanSizingExecutor: DiskCleanSizingExecuting {
+ let now: @Sendable () -> Date
+
+ init(now: @escaping @Sendable () -> Date = { Date() }) {
+ self.now = now
+ }
+
+ var isCircuitBroken: Bool { false }
+ var abandonedThreads: Int { 0 }
+
+ func size(
+ ofItemAt path: String,
+ using sizer: any DiskCleanDirectorySizing,
+ deadline: Date
+ ) async -> DiskCleanSizeResult {
+ sizer.size(
+ ofItemAt: path,
+ context: DiskCleanSizingContext(deadline: deadline, now: now)
+ )
+ }
+}
+
+/// Path-keyed sizer that returns preset results and records paths actually invoked (cache hits should not call it).
+final class FakeDiskCleanSizer: DiskCleanDirectorySizing, @unchecked Sendable {
+ private let lock = NSLock()
+ private var resultsByPath: [String: DiskCleanSizeResult] = [:]
+ private var invokedPaths: [String] = []
+ /// Blocking duration. Verifies the real timeout path "item deadline expires → partial([.timedOut])".
+ let blockingDuration: TimeInterval
+
+ init(blockingDuration: TimeInterval = 0) {
+ self.blockingDuration = blockingDuration
+ }
+
+ func setResult(_ result: DiskCleanSizeResult, forPath path: String) {
+ lock.withLock { resultsByPath[path] = result }
+ }
+
+ var calledPaths: [String] { lock.withLock { invokedPaths } }
+
+ func size(ofItemAt path: String, context: DiskCleanSizingContext) -> DiskCleanSizeResult {
+ lock.withLock { invokedPaths.append(path) }
+ if blockingDuration > 0 {
+ Thread.sleep(forTimeInterval: blockingDuration)
+ }
+ return lock.withLock { resultsByPath[path] } ?? .testComplete()
+ }
+}
+
+struct FakeDiskCleanRootIdentityProbe: DiskCleanRootIdentityProbing {
+ let identitiesByPath: [String: DiskCleanRootIdentity]
+
+ func identity(ofItemAt path: String) -> DiskCleanRootIdentity? {
+ identitiesByPath[path]
+ }
+}
+
+// MARK: - Running-app snapshot
+
+struct FakeDiskCleanRunningAppLock: DiskCleanRunningAppSnapshotting {
+ let snapshot: DiskCleanRunningAppSnapshot
+
+ init(snapshot: DiskCleanRunningAppSnapshot = DiskCleanRunningAppSnapshot()) {
+ self.snapshot = snapshot
+ }
+
+ func makeSnapshot(processNames: [String]) async -> DiskCleanRunningAppSnapshot {
+ snapshot
+ }
+}
+
+struct FakeDiskCleanFullDiskAccess: DiskCleanFullDiskAccessProbing {
+ let hasFullDiskAccess: Bool
+}
+
+/// Programmable file-readability probe. Authorization-matrix tests never touch real TCC-protected files.
+struct FakeDiskCleanFileReadability: DiskCleanFileReadabilityProbing {
+ /// Set of openable paths. Anything outside is "cannot open", covering both denied and missing files —
+ /// the probe itself does not distinguish (neither proves FDA).
+ let openablePaths: Set
+ /// Records probe order to assert "stop after the first success".
+ private let probed = OSAllocatedUnfairLock<[String]>(initialState: [])
+
+ init(openablePaths: Set = []) {
+ self.openablePaths = openablePaths
+ }
+
+ var probedPaths: [String] { probed.withLock { $0 } }
+
+ func canOpenForReading(atPath path: String) -> Bool {
+ probed.withLock { $0.append(path) }
+ return openablePaths.contains(path)
+ }
+}
+
+// MARK: - P2 expansion seam
+
+/// Programmable non-rule expansion source. Lets P2 candidates run the full pipeline without touching the filesystem.
+struct FakeDiskCleanExternalExpansion: DiskCleanExternalExpanding {
+ var hits: [DiskCleanTargetHit] = []
+ var reservedRootPaths: [String] = []
+ var limitations: [DiskCleanScanLimitation] = []
+ var logMessages: [DiskCleanScanLogMessage] = []
+
+ func expand(
+ scope: DiskCleanScanScope,
+ catalog: DiskCleanRuleCatalogV2,
+ localization: PluginLocalization
+ ) async -> DiskCleanExternalExpansion {
+ var expansion = DiskCleanExternalExpansion()
+ expansion.hits = hits
+ expansion.reservedRootPaths = reservedRootPaths
+ expansion.limitations = limitations
+ expansion.logMessages = logMessages
+ return expansion
+ }
+}
+
+// MARK: - Subprocess seam
+
+/// Programmable subprocess: records call args and returns output or throws on demand.
+final class FakeDiskCleanSubprocessRunner: DiskCleanSubprocessRunning, @unchecked Sendable {
+ struct Invocation: Equatable {
+ let executablePath: String
+ let arguments: [String]
+ }
+
+ private let lock = NSLock()
+ private var recordedInvocations: [Invocation] = []
+ private let result: Result
+
+ init(exitCode: Int32 = 0, standardOutput: String = "") {
+ self.result = .success(
+ DiskCleanSubprocessResult(
+ exitCode: exitCode,
+ standardOutput: Data(standardOutput.utf8)
+ )
+ )
+ }
+
+ init(error: Error) {
+ self.result = .failure(error)
+ }
+
+ var invocations: [Invocation] { lock.withLock { recordedInvocations } }
+
+ func run(
+ executablePath: String,
+ arguments: [String],
+ timeout: Duration
+ ) async throws -> DiskCleanSubprocessResult {
+ lock.withLock {
+ recordedInvocations.append(Invocation(executablePath: executablePath, arguments: arguments))
+ }
+ return try result.get()
+ }
+}
+
+// MARK: - Test clock
+
+/// Manually advanced clock. Expiry gates are time-driven; tests must drive the transition without waiting 300s.
+final class TestDiskCleanClock: DiskCleanClock, @unchecked Sendable {
+ private struct Waiter {
+ let deadline: Date
+ let continuation: CheckedContinuation
+ }
+
+ private let lock = NSLock()
+ private var current: Date
+ private var waiters: [Waiter] = []
+
+ init(now: Date = Date(timeIntervalSince1970: 10_000)) {
+ self.current = now
+ }
+
+ var now: Date { lock.withLock { current } }
+
+ func sleep(until deadline: Date) async throws {
+ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in
+ let shouldResumeImmediately = lock.withLock { () -> Bool in
+ guard current < deadline else { return true }
+ waiters.append(Waiter(deadline: deadline, continuation: continuation))
+ return false
+ }
+ if shouldResumeImmediately {
+ continuation.resume()
+ }
+ }
+ }
+
+ func advance(by interval: TimeInterval) {
+ advance(to: now.addingTimeInterval(interval))
+ }
+
+ func advance(to date: Date) {
+ let due = lock.withLock { () -> [Waiter] in
+ current = date
+ let due = waiters.filter { $0.deadline <= date }
+ waiters.removeAll { $0.deadline <= date }
+ return due
+ }
+ for waiter in due {
+ waiter.continuation.resume()
+ }
+ }
+}
+
+// MARK: - Controlled engine
+
+/// Test-driven event stream for operationID generations, throttled publish, and expiry clocks —
+/// behaviors that depend on still being unfinished at a precise moment.
+final class ControlledDiskCleanScanEngine: DiskCleanScanning, @unchecked Sendable {
+ private let lock = NSLock()
+ private var continuation: AsyncThrowingStream.Continuation?
+ private var scanCalls: [(scope: DiskCleanScanScope, forceRefresh: Bool)] = []
+ private var isTerminated = false
+
+ var scanCallCount: Int { lock.withLock { scanCalls.count } }
+ var lastForceRefresh: Bool? { lock.withLock { scanCalls.last?.forceRefresh } }
+ var lastScope: DiskCleanScanScope? { lock.withLock { scanCalls.last?.scope } }
+ var lastChoices: Set? { lock.withLock { scanCalls.last?.scope.choices } }
+ var didTerminate: Bool { lock.withLock { isTerminated } }
+
+ func scan(
+ scope: DiskCleanScanScope,
+ forceRefresh: Bool
+ ) -> AsyncThrowingStream {
+ lock.withLock {
+ scanCalls.append((scope: scope, forceRefresh: forceRefresh))
+ isTerminated = false
+ }
+ return AsyncThrowingStream { continuation in
+ lock.withLock { self.continuation = continuation }
+ continuation.onTermination = { [weak self] _ in
+ self?.lock.withLock { self?.isTerminated = true }
+ }
+ }
+ }
+
+ func emit(_ event: DiskCleanScanEvent) {
+ lock.withLock { continuation }?.yield(event)
+ }
+
+ func finish() {
+ lock.withLock { continuation }?.finish()
+ }
+
+ func fail(with error: Error) {
+ lock.withLock { continuation }?.finish(throwing: error)
+ }
+}
+
+/// Call-recording executor fake. The executor only accepts `ValidatedPlan`, so the fake records received plans.
+final class FakeDiskCleanExecutor: DiskCleanExecuting, @unchecked Sendable {
+ private let lock = NSLock()
+ private var plans: [DiskCleanValidatedPlan] = []
+ private var failure: Error?
+
+ init(failure: Error? = nil) {
+ self.failure = failure
+ }
+
+ var callCount: Int { lock.withLock { plans.count } }
+ var lastPlan: DiskCleanValidatedPlan? { lock.withLock { plans.last } }
+ var lastSelectedIDs: Set? {
+ lock.withLock { plans.last.map { Set($0.items.map(\.candidateID)) } }
+ }
+ var lastMode: DiskCleanRemovalMode? { lock.withLock { plans.last?.mode } }
+
+ func execute(plan: DiskCleanValidatedPlan) async throws -> DiskCleanExecutionResult {
+ let failure = lock.withLock { () -> Error? in
+ plans.append(plan)
+ return self.failure
+ }
+ if let failure { throw failure }
+ return DiskCleanExecutionResult(
+ itemResults: plan.items.map {
+ DiskCleanExecutionItemResult(
+ candidateID: $0.candidateID,
+ path: $0.path,
+ outcome: plan.mode == .trash
+ ? .trashed(reclaimedBytes: $0.estimatedBytes, stagedName: ".mactools-staged-test")
+ : .removed(reclaimedBytes: $0.estimatedBytes)
+ )
+ },
+ mode: plan.mode
+ )
+ }
+}
+
+/// In-memory removal-mode store. Real implementation writes `UserDefaults`; tests never touch user prefs.
+final class InMemoryDiskCleanRemovalModeStore: DiskCleanRemovalModeStoring, @unchecked Sendable {
+ private let lock = NSLock()
+ private var mode: DiskCleanRemovalMode
+
+ init(mode: DiskCleanRemovalMode = .trash) {
+ self.mode = mode
+ }
+
+ var savedMode: DiskCleanRemovalMode { lock.withLock { mode } }
+
+ func load() -> DiskCleanRemovalMode { lock.withLock { mode } }
+
+ func save(_ mode: DiskCleanRemovalMode) {
+ lock.withLock { self.mode = mode }
+ }
+}
+
+/// In-memory purge-root store. Same rule: never touch real user folders in `UserDefaults.standard`.
+final class EphemeralPurgeRootsPersistence: DiskCleanPurgeRootsPersisting, @unchecked Sendable {
+ private let lock = NSLock()
+ private var roots: [String]
+
+ init(roots: [String] = []) {
+ self.roots = roots
+ }
+
+ func loadRoots() -> [String] { lock.withLock { roots } }
+
+ func saveRoots(_ roots: [String]) {
+ lock.withLock { self.roots = roots }
+ }
+}
+
+// MARK: - Async assertion helpers
+
+/// Poll until a condition holds. Async state transitions (throttled publish, expiry tasks) cannot be asserted with fixed sleeps.
+@MainActor
+func waitUntil(
+ timeout: TimeInterval = 3,
+ file: StaticString = #filePath,
+ line: UInt = #line,
+ _ description: String,
+ _ condition: () -> Bool
+) async {
+ let deadline = Date().addingTimeInterval(timeout)
+ while Date() < deadline {
+ if condition() {
+ return
+ }
+ try? await Task.sleep(nanoseconds: 5_000_000)
+ }
+ XCTFail("wait timed out: \(description)", file: file, line: line)
+}
diff --git a/Plugins/DiskClean/Tests/DiskCleanScannerTests.swift b/Plugins/DiskClean/Tests/DiskCleanScannerTests.swift
deleted file mode 100644
index cf5c82ce..00000000
--- a/Plugins/DiskClean/Tests/DiskCleanScannerTests.swift
+++ /dev/null
@@ -1,290 +0,0 @@
-import XCTest
-@testable import MacTools
-@testable import DiskCleanPlugin
-
-final class DiskCleanScannerTests: XCTestCase {
- private let home = "/Users/tester"
-
- func testScanReturnsOnlySelectedChoicesAndNeverDeletesDuringDryRun() async throws {
- let cachePath = "\(home)/Library/Caches/App"
- let developerPath = "\(home)/Library/Caches/DeveloperTool"
- let fileSystem = FakeDiskCleanFileSystem(
- expansions: [
- "\(home)/Library/Caches/*": [item(cachePath), item(developerPath)],
- "\(home)/Library/Developer/*": [item(developerPath)]
- ],
- sizes: [
- cachePath: 10,
- developerPath: 20
- ]
- )
- let catalog = DiskCleanRuleCatalog(rules: [
- DiskCleanRule(
- id: "cache-rule",
- choice: .cache,
- title: "Cache",
- risk: .low,
- targets: [.path("\(home)/Library/Caches/*")]
- ),
- DiskCleanRule(
- id: "developer-rule",
- choice: .developer,
- title: "Developer",
- risk: .medium,
- targets: [.path("\(home)/Library/Developer/*")]
- )
- ])
- let scanner = makeScanner(catalog: catalog, fileSystem: fileSystem)
-
- let result = try await scanner.scan(choices: [.cache])
-
- XCTAssertEqual(result.choices, [.cache])
- XCTAssertEqual(result.candidates.map(\.choice), [.cache, .cache])
- XCTAssertEqual(Set(result.candidates.map(\.path)), [cachePath, developerPath])
- XCTAssertTrue(fileSystem.removedPaths.isEmpty)
- }
-
- func testScanPreservesWhitelistedProtectedAndInUseCandidatesAsNonCleanable() async throws {
- let allowedPath = "\(home)/Library/Caches/Regular"
- let whitelistedPath = "\(home)/Library/Caches/KeepMe/data"
- let protectedPath = "\(home)/Library/Application Support/Google/Chrome/Default/Cookies"
- let runningPath = "\(home)/Library/Caches/Running"
- let fileSystem = FakeDiskCleanFileSystem(
- expansions: [
- "\(home)/Library/Caches/Regular": [item(allowedPath)],
- "\(home)/Library/Caches/KeepMe/data": [item(whitelistedPath)],
- "\(home)/Library/Application Support/Google/Chrome/Default/Cookies": [item(protectedPath)],
- "\(home)/Library/Caches/Running": [item(runningPath)]
- ],
- sizes: [
- allowedPath: 10,
- whitelistedPath: 20,
- protectedPath: 30,
- runningPath: 40
- ]
- )
- let whitelist = DiskCleanWhitelistStore(
- homeDirectory: home,
- includeDefaults: false,
- customRules: ["\(home)/Library/Caches/KeepMe*"]
- )
- let catalog = DiskCleanRuleCatalog(rules: [
- DiskCleanRule(
- id: "allowed",
- choice: .cache,
- title: "Allowed",
- risk: .low,
- targets: [.path("\(home)/Library/Caches/Regular")]
- ),
- DiskCleanRule(
- id: "whitelisted",
- choice: .cache,
- title: "Whitelisted",
- risk: .low,
- targets: [.path("\(home)/Library/Caches/KeepMe/data")]
- ),
- DiskCleanRule(
- id: "protected",
- choice: .cache,
- title: "Protected",
- risk: .high,
- targets: [.path("\(home)/Library/Application Support/Google/Chrome/Default/Cookies")]
- ),
- DiskCleanRule(
- id: "running",
- choice: .cache,
- title: "Running",
- risk: .medium,
- targets: [.path("\(home)/Library/Caches/Running")],
- skipWhenProcessIsRunning: ["Google Chrome"]
- )
- ])
- let scanner = makeScanner(
- catalog: catalog,
- fileSystem: fileSystem,
- whitelistStore: whitelist,
- processInspector: FakeDiskCleanProcessInspector(runningProcessNames: ["Google Chrome"])
- )
-
- let result = try await scanner.scan(choices: [.cache])
-
- XCTAssertEqual(result.cleanableCandidates.map(\.path), [allowedPath])
- XCTAssertEqual(result.cleanableSizeBytes, 10)
- XCTAssertEqual(result.protectedCount, 2)
- XCTAssertEqual(status(for: whitelistedPath, in: result), .whitelisted(rule: "\(home)/Library/Caches/KeepMe*"))
- XCTAssertEqual(status(for: protectedPath, in: result), .protected(reason: "browser profile data is protected"))
- XCTAssertEqual(status(for: runningPath, in: result), .inUse(processName: "Google Chrome"))
- }
-
- func testScanDeduplicatesParentChildCandidatesBeforeSizingTotals() async throws {
- let parent = "\(home)/Library/Caches/App"
- let child = "\(home)/Library/Caches/App/Nested"
- let sibling = "\(home)/Library/Caches/Other"
- let fileSystem = FakeDiskCleanFileSystem(
- expansions: [
- "\(home)/Library/Caches/App": [item(parent)],
- "\(home)/Library/Caches/App/Nested": [item(child)],
- "\(home)/Library/Caches/Other": [item(sibling)]
- ],
- sizes: [
- parent: 100,
- child: 75,
- sibling: 25
- ]
- )
- let catalog = DiskCleanRuleCatalog(rules: [
- DiskCleanRule(
- id: "dedupe",
- choice: .cache,
- title: "Dedupe",
- risk: .low,
- targets: [
- .path("\(home)/Library/Caches/App"),
- .path("\(home)/Library/Caches/App/Nested"),
- .path("\(home)/Library/Caches/Other")
- ]
- )
- ])
- let scanner = makeScanner(catalog: catalog, fileSystem: fileSystem)
-
- let result = try await scanner.scan(choices: [.cache])
-
- XCTAssertEqual(result.candidates.map(\.path), [parent, sibling])
- XCTAssertEqual(result.cleanableSizeBytes, 125)
- }
-
- func testScanEmitsProgressMessagesForRulesCandidatesAndCompletion() async throws {
- let cachePath = "\(home)/Library/Caches/App"
- let fileSystem = FakeDiskCleanFileSystem(
- expansions: ["\(home)/Library/Caches/App": [item(cachePath)]],
- sizes: [cachePath: 10]
- )
- let catalog = DiskCleanRuleCatalog(rules: [
- DiskCleanRule(
- id: "cache",
- choice: .cache,
- title: "Cache",
- risk: .low,
- targets: [.path("\(home)/Library/Caches/App")]
- )
- ])
- let scanner = makeScanner(catalog: catalog, fileSystem: fileSystem)
- let recorder = DiskCleanProgressRecorder()
-
- _ = try await scanner.scan(choices: [.cache]) { message in
- await recorder.record(message)
- }
-
- let messages = await recorder.messages()
- let texts = messages.map(\.text)
-
- XCTAssertTrue(texts.contains("扫描范围:缓存清理"))
- XCTAssertTrue(texts.contains("展开规则:Cache"))
- XCTAssertTrue(texts.contains("匹配 1 项:Cache"))
- XCTAssertTrue(texts.contains("可清理:\(cachePath)"))
- XCTAssertEqual(texts.last, "扫描完成:1 项,1 项可清理")
- }
-
- private func makeScanner(
- catalog: DiskCleanRuleCatalog,
- fileSystem: FakeDiskCleanFileSystem,
- whitelistStore: DiskCleanWhitelistStore? = nil,
- processInspector: DiskCleanProcessInspecting = FakeDiskCleanProcessInspector()
- ) -> DiskCleanScanner {
- DiskCleanScanner(
- ruleCatalog: catalog,
- fileSystem: fileSystem,
- safetyPolicy: DiskCleanSafetyPolicy(
- homeDirectory: home,
- whitelistStore: whitelistStore ?? DiskCleanWhitelistStore(homeDirectory: home, includeDefaults: false)
- ),
- processInspector: processInspector,
- now: { Date(timeIntervalSince1970: 0) }
- )
- }
-
- private func status(for path: String, in result: DiskCleanScanResult) -> DiskCleanSafetyStatus? {
- result.candidates.first { $0.path == path }?.safety
- }
-
- private func item(_ path: String, isDirectory: Bool = true) -> DiskCleanFileItem {
- DiskCleanFileItem(
- path: path,
- isDirectory: isDirectory,
- isSymlink: false,
- resolvedSymlinkTarget: nil
- )
- }
-}
-
-private final class FakeDiskCleanFileSystem: DiskCleanFileSystemProviding, @unchecked Sendable {
- let expansions: [String: [DiskCleanFileItem]]
- let sizes: [String: Int64]
-
- private(set) var removedPaths: [String] = []
-
- init(
- expansions: [String: [DiskCleanFileItem]],
- sizes: [String: Int64]
- ) {
- self.expansions = expansions
- self.sizes = sizes
- }
-
- func expandPathPattern(_ pattern: String) throws -> [DiskCleanFileItem] {
- expansions[pattern] ?? []
- }
-
- func itemInfo(at path: String) throws -> DiskCleanFileItem? {
- expansions.values
- .flatMap { $0 }
- .first { $0.path == path }
- }
-
- func sizeOfItem(at path: String) throws -> Int64 {
- sizes[path] ?? 0
- }
-
- func removeItem(at path: String) throws {
- removedPaths.append(path)
- }
-
- func deduplicatedParentChildPaths(_ paths: [String]) -> [String] {
- let uniqueSortedPaths = Array(Set(paths)).sorted { lhs, rhs in
- if lhs.count == rhs.count {
- return lhs < rhs
- }
- return lhs.count < rhs.count
- }
-
- var kept: [String] = []
- for path in uniqueSortedPaths {
- if kept.contains(where: { path == $0 || path.hasPrefix($0 + "/") }) {
- continue
- }
- kept.append(path)
- }
-
- return kept.sorted()
- }
-}
-
-private struct FakeDiskCleanProcessInspector: DiskCleanProcessInspecting {
- var runningProcessNames: Set = []
-
- func runningProcessName(from names: [String]) -> String? {
- names.first { runningProcessNames.contains($0) }
- }
-}
-
-private actor DiskCleanProgressRecorder {
- private var recordedMessages: [DiskCleanScanLogMessage] = []
-
- func record(_ message: DiskCleanScanLogMessage) {
- recordedMessages.append(message)
- }
-
- func messages() -> [DiskCleanScanLogMessage] {
- recordedMessages
- }
-}
diff --git a/Plugins/DiskClean/Tests/DiskCleanSectionGuidanceTests.swift b/Plugins/DiskClean/Tests/DiskCleanSectionGuidanceTests.swift
new file mode 100644
index 00000000..993d61bb
--- /dev/null
+++ b/Plugins/DiskClean/Tests/DiskCleanSectionGuidanceTests.swift
@@ -0,0 +1,166 @@
+import XCTest
+@testable import MacTools
+@testable import DiskCleanPlugin
+
+/// Empty-state resolution for P2 sections (design §10).
+///
+/// These states must stay distinct: they ask the user for completely different next steps — add folders, start a scan,
+/// grant access, roots are gone, or truly empty. Collapsing into one "no content" leaves the user guessing.
+@MainActor
+final class DiskCleanSectionGuidanceTests: XCTestCase {
+ // MARK: - Needs-roots guidance
+
+ func testDeveloperArtifactsWithoutRootsAsksForFolders() {
+ let snapshot = makeSnapshot(scope: .developerArtifacts(roots: []), scanResult: nil)
+
+ XCTAssertEqual(DiskCleanSectionGuidance.resolve(snapshot), .needsRoots)
+ }
+
+ /// Needs-roots outranks not-scanned: showing "scan" with a disabled button is the most confusing combination.
+ func testNeedsRootsOutranksNotScanned() {
+ let snapshot = makeSnapshot(scope: .developerArtifacts(roots: []), scanResult: nil)
+
+ XCTAssertFalse(snapshot.canScan, "scan entry must be disabled without roots")
+ XCTAssertEqual(DiskCleanSectionGuidance.resolve(snapshot), .needsRoots)
+ }
+
+ func testDeveloperArtifactsWithRootsFallsBackToNotScanned() {
+ let snapshot = makeSnapshot(scope: .developerArtifacts(roots: ["/code"]), scanResult: nil)
+
+ XCTAssertTrue(snapshot.canScan)
+ XCTAssertEqual(DiskCleanSectionGuidance.resolve(snapshot), .notScanned)
+ }
+
+ func testInstallersNeverAskForRoots() {
+ let snapshot = makeSnapshot(scope: .installers, scanResult: nil)
+
+ XCTAssertEqual(DiskCleanSectionGuidance.resolve(snapshot), .notScanned)
+ XCTAssertTrue(snapshot.canScan, "installer section has fixed scope and is always scannable")
+ }
+
+ // MARK: - Authorization guidance
+
+ /// `denied` must stay distinct from "scanned with no candidates": `~/Downloads` may hold tens of GB of installers;
+ /// showing "nothing to clean" would mislead the user.
+ func testDeniedScanRootShowsAuthorizationGuidanceInsteadOfEmptyState() {
+ let snapshot = makeSnapshot(
+ scope: .installers,
+ scanResult: makeResult(
+ scope: .installers,
+ candidates: [],
+ limitations: [.scanRootUnreadable(path: "/Users/x/Downloads", reason: .permissionDenied)]
+ )
+ )
+
+ XCTAssertEqual(
+ DiskCleanSectionGuidance.resolve(snapshot),
+ .accessDenied(path: "/Users/x/Downloads")
+ )
+ }
+
+ func testUnreadableRootShowsItsOwnGuidance() {
+ let snapshot = makeSnapshot(
+ scope: .developerArtifacts(roots: ["/code"]),
+ scanResult: makeResult(
+ scope: .developerArtifacts(roots: ["/code"]),
+ candidates: [],
+ limitations: [.scanRootUnreadable(path: "/code", reason: .walkError)]
+ )
+ )
+
+ XCTAssertEqual(DiskCleanSectionGuidance.resolve(snapshot), .rootsUnreadable(paths: ["/code"]))
+ }
+
+ /// When both denied and invalid roots exist, prefer authorization — the one the user can fix themselves.
+ func testPermissionDeniedOutranksOtherUnreadableReasons() {
+ let snapshot = makeSnapshot(
+ scope: .developerArtifacts(roots: ["/a", "/b"]),
+ scanResult: makeResult(
+ scope: .developerArtifacts(roots: ["/a", "/b"]),
+ candidates: [],
+ limitations: [
+ .scanRootUnreadable(path: "/a", reason: .walkError),
+ .scanRootUnreadable(path: "/b", reason: .permissionDenied)
+ ]
+ )
+ )
+
+ XCTAssertEqual(DiskCleanSectionGuidance.resolve(snapshot), .accessDenied(path: "/b"))
+ }
+
+ // MARK: - Candidates keep the stage
+
+ /// One root denied, another yields candidates: show the list; root problems go to the limited banner.
+ func testCandidatesTakePrecedenceOverRootProblems() {
+ let scope = DiskCleanScanScope.developerArtifacts(roots: ["/a", "/b"])
+ let snapshot = makeSnapshot(
+ scope: scope,
+ scanResult: makeResult(
+ scope: scope,
+ candidates: [candidate(path: "/a/app/node_modules")],
+ limitations: [.scanRootUnreadable(path: "/b", reason: .permissionDenied)]
+ )
+ )
+
+ XCTAssertEqual(DiskCleanSectionGuidance.resolve(snapshot), .candidates)
+ }
+
+ func testScannedWithNothingFoundIsAnHonestEmptyState() {
+ let snapshot = makeSnapshot(
+ scope: .installers,
+ scanResult: makeResult(scope: .installers, candidates: [], limitations: [])
+ )
+
+ XCTAssertEqual(DiskCleanSectionGuidance.resolve(snapshot), .empty)
+ }
+
+ // MARK: - Helpers
+
+ private func makeSnapshot(
+ scope: DiskCleanScanScope,
+ scanResult: DiskCleanScanResult?
+ ) -> DiskCleanControllerSnapshot {
+ DiskCleanControllerSnapshot(
+ phase: scanResult == nil ? .idle : .scanned,
+ scope: scope,
+ scanResult: scanResult,
+ executionResult: nil,
+ isResultStale: false,
+ errorMessage: nil
+ )
+ }
+
+ private func makeResult(
+ scope: DiskCleanScanScope,
+ candidates: [DiskCleanCandidate],
+ limitations: [DiskCleanScanLimitation]
+ ) -> DiskCleanScanResult {
+ DiskCleanScanResult(
+ scope: scope,
+ candidates: candidates,
+ scannedAt: Date(timeIntervalSince1970: 10_000),
+ limitations: limitations,
+ artifact: DiskCleanScanArtifact(
+ scope: scope,
+ candidates: candidates,
+ reservedRootPaths: [],
+ limitations: limitations,
+ startedAt: Date(timeIntervalSince1970: 9_000),
+ finishedAt: Date(timeIntervalSince1970: 10_000)
+ )
+ )
+ }
+
+ private func candidate(path: String) -> DiskCleanCandidate {
+ DiskCleanCandidate(
+ id: DiskCleanCandidate.makeID(targetID: DiskCleanPurgeKind.nodeModules.targetID, path: path),
+ targetID: DiskCleanPurgeKind.nodeModules.targetID,
+ legacyRuleID: DiskCleanPurgeKind.nodeModules.targetID,
+ category: .developerArtifacts,
+ path: path,
+ risk: .low,
+ safety: .allowed,
+ sizeResult: .testComplete()
+ )
+ }
+}
diff --git a/Plugins/DiskClean/Tests/DiskCleanSelectionModelTests.swift b/Plugins/DiskClean/Tests/DiskCleanSelectionModelTests.swift
new file mode 100644
index 00000000..2ff561b6
--- /dev/null
+++ b/Plugins/DiskClean/Tests/DiskCleanSelectionModelTests.swift
@@ -0,0 +1,287 @@
+import Foundation
+import XCTest
+@testable import MacTools
+@testable import DiskCleanPlugin
+
+/// Selection-model semantic matrix (design §8.1).
+///
+/// Verifies the combination of "user action × candidate facts" with no UI:
+/// detail view and menu bar are just two renderings of these conclusions.
+final class DiskCleanSelectionModelTests: XCTestCase {
+ private let observedAt = Date(timeIntervalSince1970: 10_000)
+
+ // MARK: - Selectability
+
+ func testUnselectableCandidatesCoverEverySixReasons() {
+ let cases: [(String, DiskCleanCandidate)] = [
+ ("in use", makeCandidate(id: "inUse", safety: .inUse(processName: "Safari"))),
+ ("whitelisted", makeCandidate(id: "whitelisted", safety: .whitelisted(rule: "rule"))),
+ ("protected", makeCandidate(id: "protected", safety: .protected(reason: "credentials"))),
+ ("not complete", makeCandidate(id: "partial", completeness: .partial(reasons: [.timedOut]))),
+ ("unsized", makeCandidate(id: "unsized", sized: false)),
+ ("crossed mount point", makeCandidate(id: "mount", completeness: .partial(reasons: [.crossedMountPoint])))
+ ]
+
+ for (reason, candidate) in cases {
+ XCTAssertFalse(
+ DiskCleanSelectionModel.isSelectable(candidate),
+ "candidate must be unselectable for: \(reason)"
+ )
+ XCTAssertFalse(
+ DiskCleanSelectionModel.isSelectedByDefault(candidate),
+ "candidate must not be default-selected for: \(reason)"
+ )
+ }
+ }
+
+ func testTogglingUnselectableCandidateIsRejectedAndLeavesNoTrace() {
+ var model = DiskCleanSelectionModel()
+ let locked = makeCandidate(id: "locked", safety: .inUse(processName: "Safari"))
+
+ XCTAssertFalse(model.setCandidate(locked, isSelected: true), "toggle must be rejected, not merely UI-disabled")
+ XCTAssertFalse(model.isSelected(locked))
+
+ // Rejection leaves no record: if the item later becomes selectable, default policy applies rather than a rejected click.
+ let unlocked = makeCandidate(id: "locked", risk: .medium)
+ XCTAssertFalse(model.isSelected(unlocked))
+ }
+
+ // MARK: - Default policy
+
+ func testDefaultSelectionTakesLowRiskOnly() {
+ let model = DiskCleanSelectionModel()
+
+ XCTAssertTrue(model.isSelected(makeCandidate(id: "low", risk: .low)))
+ XCTAssertFalse(model.isSelected(makeCandidate(id: "medium", risk: .medium)))
+ XCTAssertFalse(model.isSelected(makeCandidate(id: "high", risk: .high)))
+ }
+
+ /// Dynamic-rule product targets always have risk >= medium (design §5.5), so default policy excludes them
+ /// without the selection model re-detecting "is this a dynamic rule".
+ func testDynamicRuleProductsAreNotSelectedByDefault() {
+ let model = DiskCleanSelectionModel()
+ let dynamicTargets = DiskCleanRuleCatalogV2.current.targets.filter(\.isDynamic)
+
+ XCTAssertFalse(dynamicTargets.isEmpty, "catalog should have dynamic targets or this assertion is vacuous")
+ for target in dynamicTargets {
+ XCTAssertGreaterThanOrEqual(target.risk, .medium)
+ let candidate = makeCandidate(id: target.id, category: target.category, risk: target.risk)
+ XCTAssertFalse(model.isSelected(candidate), "\(target.id) must not be default-selected")
+ }
+ }
+
+ // MARK: - Per-item overrides
+
+ func testExplicitCandidateSelectionOverridesDefaultInBothDirections() {
+ var model = DiskCleanSelectionModel()
+ let low = makeCandidate(id: "low", risk: .low)
+ let medium = makeCandidate(id: "medium", risk: .medium)
+
+ XCTAssertTrue(model.setCandidate(low, isSelected: false))
+ XCTAssertTrue(model.setCandidate(medium, isSelected: true))
+
+ XCTAssertFalse(model.isSelected(low))
+ XCTAssertTrue(model.isSelected(medium), "user may explicitly select medium; default simply does not")
+ }
+
+ // MARK: - Category three-state
+
+ func testCategorySelectAllTakesLowRiskOnlyAndClearsPerItemOverrides() {
+ var model = DiskCleanSelectionModel()
+ let low = makeCandidate(id: "low", risk: .low)
+ let medium = makeCandidate(id: "medium", risk: .medium)
+ model.setCandidate(low, isSelected: false)
+ model.setCandidate(medium, isSelected: true)
+
+ model.setCategory(.appCaches, isSelected: true)
+
+ XCTAssertTrue(model.isSelected(low), "category select-all clears per-item deselects in that category")
+ XCTAssertFalse(model.isSelected(medium), "\"select all\" means all low-risk items; medium is not included")
+ XCTAssertEqual(model.explicitOperation(for: .appCaches), .selectAllLowRisk)
+ }
+
+ func testCategoryDeselectAllClearsEverythingInThatCategoryOnly() {
+ var model = DiskCleanSelectionModel()
+ let cacheItem = makeCandidate(id: "cache", category: .appCaches)
+ let logItem = makeCandidate(id: "log", category: .logs)
+
+ model.setCategory(.appCaches, isSelected: false)
+
+ XCTAssertFalse(model.isSelected(cacheItem))
+ XCTAssertTrue(model.isSelected(logItem), "category operations only affect that category")
+ }
+
+ func testCategoryStateReportsThreeStatesAndUnavailable() {
+ var model = DiskCleanSelectionModel()
+ let low = makeCandidate(id: "low", risk: .low)
+ let medium = makeCandidate(id: "medium", risk: .medium)
+ let locked = makeCandidate(id: "locked", category: .logs, safety: .inUse(processName: "App"))
+
+ XCTAssertEqual(
+ model.projection(for: [low, medium, locked]).state(of: .appCaches),
+ .partiallySelected
+ )
+ XCTAssertEqual(
+ model.projection(for: [low, medium, locked]).state(of: .logs),
+ .unavailable,
+ "all-unselectable category must be distinct from \"nothing scanned in this category\""
+ )
+ XCTAssertEqual(
+ model.projection(for: [low]).state(of: .appCaches),
+ .allSelected
+ )
+
+ model.setCategory(.appCaches, isSelected: false)
+ XCTAssertEqual(
+ model.projection(for: [low, medium]).state(of: .appCaches),
+ .noneSelected
+ )
+ XCTAssertEqual(
+ model.projection(for: []).state(of: .browsers),
+ .unavailable,
+ "categories absent from this scan are unavailable"
+ )
+ }
+
+ // MARK: - Streaming arrivals × explicit override matrix
+
+ func testNewCandidateFollowsDefaultPolicyWhenCategoryWasNeverTouched() {
+ let model = DiskCleanSelectionModel()
+
+ XCTAssertTrue(model.isSelected(makeCandidate(id: "newLow", risk: .low)))
+ XCTAssertFalse(model.isSelected(makeCandidate(id: "newMedium", risk: .medium)))
+ }
+
+ func testNewCandidateStaysUnselectedAfterExplicitDeselectAll() {
+ var model = DiskCleanSelectionModel()
+ model.setCategory(.appCaches, isSelected: false)
+
+ XCTAssertFalse(
+ model.isSelected(makeCandidate(id: "newLow", risk: .low)),
+ "explicit deselect-all is a standing intent, not a one-shot action"
+ )
+ XCTAssertTrue(
+ model.isSelected(makeCandidate(id: "otherCategory", category: .logs)),
+ "other categories are unaffected"
+ )
+ }
+
+ func testNewLowRiskCandidateJoinsAfterExplicitSelectAll() {
+ var model = DiskCleanSelectionModel()
+ model.setCategory(.appCaches, isSelected: true)
+
+ XCTAssertTrue(model.isSelected(makeCandidate(id: "newLow", risk: .low)))
+ XCTAssertFalse(
+ model.isSelected(makeCandidate(id: "newMedium", risk: .medium)),
+ "medium is never pulled in by select-all, including newly arrived items"
+ )
+ }
+
+ func testPerItemOverrideSurvivesNewCandidatesArriving() {
+ var model = DiskCleanSelectionModel()
+ let existing = makeCandidate(id: "existing", risk: .low)
+ model.setCandidate(existing, isSelected: false)
+
+ let projection = model.projection(for: [existing, makeCandidate(id: "new", risk: .low)])
+
+ XCTAssertEqual(projection.selectedIDs, ["new"])
+ }
+
+ func testDeselectAllRemainsInEffectAfterUserReselectsOneItem() {
+ var model = DiskCleanSelectionModel()
+ model.setCategory(.appCaches, isSelected: false)
+ let picked = makeCandidate(id: "picked", risk: .low)
+ model.setCandidate(picked, isSelected: true)
+
+ XCTAssertTrue(model.isSelected(picked))
+ XCTAssertFalse(
+ model.isSelected(makeCandidate(id: "new", risk: .low)),
+ "reselecting one item does not revoke category-level deselect-all"
+ )
+ }
+
+ /// Candidate appears unsized first, then size completes (design §4.1, §4.2): default policy must apply the moment it becomes selectable.
+ func testCandidateEntersSelectionOnceSizingCompletes() {
+ let model = DiskCleanSelectionModel()
+ let unsized = makeCandidate(id: "a", sized: false)
+
+ XCTAssertFalse(model.isSelected(unsized))
+ XCTAssertTrue(model.isSelected(unsized.applying(.testComplete(bytes: 10, observedAt: observedAt))))
+ }
+
+ /// Reverse: a default-selected candidate re-sized as partial must leave the selection immediately.
+ func testCandidateLeavesSelectionWhenItBecomesIncomplete() {
+ let model = DiskCleanSelectionModel()
+ let sized = makeCandidate(id: "a")
+
+ XCTAssertTrue(model.isSelected(sized))
+ XCTAssertFalse(
+ model.isSelected(sized.applying(.testPartial(reasons: [.timedOut], observedAt: observedAt)))
+ )
+ }
+
+ // MARK: - Derived values and reset
+
+ func testProjectionDerivesCountAndBytesFromSelectedItemsOnly() {
+ var model = DiskCleanSelectionModel()
+ let candidates = [
+ makeCandidate(id: "a", bytes: 1_000),
+ makeCandidate(id: "b", bytes: 2_000),
+ makeCandidate(id: "c", risk: .medium, bytes: 4_000),
+ makeCandidate(id: "d", bytes: 8_000, safety: .whitelisted(rule: "rule"))
+ ]
+ model.setCandidate(candidates[2], isSelected: true)
+
+ let projection = model.projection(for: candidates)
+
+ XCTAssertEqual(projection.selectedIDs, ["a", "b", "c"])
+ XCTAssertEqual(projection.selectedCount, 3)
+ XCTAssertEqual(projection.selectedEstimatedBytes, 7_000)
+ XCTAssertEqual(projection.selectableIDs, ["a", "b", "c"])
+ XCTAssertFalse(projection.isSelectable("d"))
+ }
+
+ func testResetReturnsToDefaultPolicy() {
+ var model = DiskCleanSelectionModel()
+ let low = makeCandidate(id: "low", risk: .low)
+ model.setCandidate(low, isSelected: false)
+ model.setCategory(.logs, isSelected: false)
+
+ model.reset()
+
+ XCTAssertTrue(model.isSelected(low))
+ XCTAssertNil(model.explicitOperation(for: .logs))
+ }
+
+ // MARK: - Fixtures
+
+ private func makeCandidate(
+ id: String,
+ category: DiskCleanCategoryID = .appCaches,
+ risk: DiskCleanRisk = .low,
+ bytes: Int64 = 1_024,
+ safety: DiskCleanSafetyStatus = .allowed,
+ completeness: DiskCleanScanCompleteness = .complete,
+ sized: Bool = true
+ ) -> DiskCleanCandidate {
+ let sizeResult: DiskCleanSizeResult? = sized
+ ? DiskCleanSizeResult(
+ estimatedBytes: bytes,
+ fileCount: 1,
+ completeness: completeness,
+ rootIdentity: completeness.isComplete ? .test() : nil,
+ observedAt: observedAt
+ )
+ : nil
+ return DiskCleanCandidate(
+ id: id,
+ targetID: "test.target",
+ legacyRuleID: "test.target",
+ category: category,
+ path: "/cache/\(id)",
+ risk: risk,
+ safety: safety,
+ sizeResult: sizeResult
+ )
+ }
+}
diff --git a/Plugins/DiskClean/Tests/DiskCleanSizeCacheTests.swift b/Plugins/DiskClean/Tests/DiskCleanSizeCacheTests.swift
new file mode 100644
index 00000000..78f17fcd
--- /dev/null
+++ b/Plugins/DiskClean/Tests/DiskCleanSizeCacheTests.swift
@@ -0,0 +1,327 @@
+import Darwin
+import Foundation
+import XCTest
+@testable import MacTools
+@testable import DiskCleanPlugin
+
+final class DiskCleanSizeCacheTests: XCTestCase {
+ private let path = "/cache/item"
+
+ // MARK: - Hit conditions
+
+ func testHitRequiresFullIdentityTriple() {
+ let cache = DiskCleanSizeCache()
+ let stored = DiskCleanRootIdentity.test(devid: 1, fileID: 2, mtime: Date(timeIntervalSince1970: 500))
+ cache.store(path: path, result: .testComplete(identity: stored), now: Date())
+
+ XCTAssertNotNil(cache.result(forPath: path, identity: stored, now: Date()))
+ }
+
+ /// Directory deleted and recreated with mtime preserved: mtime-only comparison would reuse the old result; fileID must participate.
+ func testDirectoryReplacedWithSameMtimeDoesNotHit() {
+ let cache = DiskCleanSizeCache()
+ let mtime = Date(timeIntervalSince1970: 500)
+ cache.store(
+ path: path,
+ result: .testComplete(identity: .test(devid: 1, fileID: 2, mtime: mtime)),
+ now: Date()
+ )
+
+ let replaced = DiskCleanRootIdentity.test(devid: 1, fileID: 99, mtime: mtime)
+
+ XCTAssertNil(cache.result(forPath: path, identity: replaced, now: Date()))
+ }
+
+ func testDifferentDeviceDoesNotHit() {
+ let cache = DiskCleanSizeCache()
+ cache.store(path: path, result: .testComplete(identity: .test(devid: 1)), now: Date())
+
+ XCTAssertNil(cache.result(forPath: path, identity: .test(devid: 2), now: Date()))
+ }
+
+ func testDifferentMtimeDoesNotHit() {
+ let cache = DiskCleanSizeCache()
+ cache.store(
+ path: path,
+ result: .testComplete(identity: .test(mtime: Date(timeIntervalSince1970: 500))),
+ now: Date()
+ )
+
+ XCTAssertNil(
+ cache.result(
+ forPath: path,
+ identity: .test(mtime: Date(timeIntervalSince1970: 501)),
+ now: Date()
+ )
+ )
+ }
+
+ func testMissDropsStaleEntry() {
+ let cache = DiskCleanSizeCache()
+ cache.store(path: path, result: .testComplete(identity: .test(fileID: 2)), now: Date())
+
+ _ = cache.result(forPath: path, identity: .test(fileID: 3), now: Date())
+
+ XCTAssertEqual(cache.count, 0, "identity-mismatched entries are worthless; hit check clears them")
+ }
+
+ // MARK: - TTL and capacity
+
+ func testEntryExpiresAfterTimeToLive() {
+ let cache = DiskCleanSizeCache(timeToLive: 240)
+ let storedAt = Date(timeIntervalSince1970: 10_000)
+ let identity = DiskCleanRootIdentity.test()
+ cache.store(path: path, result: .testComplete(identity: identity), now: storedAt)
+
+ XCTAssertNotNil(
+ cache.result(forPath: path, identity: identity, now: storedAt.addingTimeInterval(239))
+ )
+ XCTAssertNil(
+ cache.result(forPath: path, identity: identity, now: storedAt.addingTimeInterval(240))
+ )
+ }
+
+ func testTimeToLiveIsStrictlyShorterThanFreshnessWindow() {
+ XCTAssertLessThan(
+ DiskCleanSizeCache.timeToLive,
+ DiskCleanScanFreshness.window,
+ "TTL must be shorter than the expiry window or 'expire → rescan → hit old cache → still expired' becomes a loop"
+ )
+ }
+
+ func testEvictsOldestEntryBeyondCapacity() {
+ let cache = DiskCleanSizeCache(capacity: 2)
+ let identity = DiskCleanRootIdentity.test()
+ let now = Date()
+ cache.store(path: "/a", result: .testComplete(identity: identity), now: now)
+ cache.store(path: "/b", result: .testComplete(identity: identity), now: now)
+ cache.store(path: "/c", result: .testComplete(identity: identity), now: now)
+
+ XCTAssertEqual(cache.count, 2)
+ XCTAssertNil(cache.result(forPath: "/a", identity: identity, now: now))
+ XCTAssertNotNil(cache.result(forPath: "/c", identity: identity, now: now))
+ }
+
+ // MARK: - Cache complete only
+
+ func testPartialResultIsNeverStored() {
+ let cache = DiskCleanSizeCache()
+ cache.store(
+ path: path,
+ result: .testPartial(reasons: [.timedOut], identity: .test()),
+ now: Date()
+ )
+
+ XCTAssertEqual(cache.count, 0, "caching partial permanently freezes a degradation")
+ }
+
+ func testResultWithoutRootIdentityIsNeverStored() {
+ let cache = DiskCleanSizeCache()
+ cache.store(
+ path: path,
+ result: DiskCleanSizeResult(
+ estimatedBytes: 10,
+ fileCount: 1,
+ completeness: .complete,
+ rootIdentity: nil,
+ observedAt: Date()
+ ),
+ now: Date()
+ )
+
+ XCTAssertEqual(cache.count, 0)
+ }
+
+ // MARK: - Decorator behavior
+
+ func testCachingSizerReturnsCachedResultWithOriginalObservedAt() {
+ let identity = DiskCleanRootIdentity.test()
+ let observedAt = Date(timeIntervalSince1970: 1_000)
+ let cache = DiskCleanSizeCache()
+ cache.store(
+ path: path,
+ result: .testComplete(bytes: 777, identity: identity, observedAt: observedAt),
+ now: observedAt
+ )
+ let base = FakeDiskCleanSizer()
+ let sizer = DiskCleanCachingSizer(
+ base: base,
+ fallback: nil,
+ cache: cache,
+ identityProbe: FakeDiskCleanRootIdentityProbe(identitiesByPath: [path: identity])
+ )
+
+ let result = sizer.size(
+ ofItemAt: path,
+ context: DiskCleanSizingContext(
+ deadline: observedAt.addingTimeInterval(60),
+ now: { observedAt.addingTimeInterval(30) }
+ )
+ )
+
+ XCTAssertEqual(result.estimatedBytes, 777)
+ XCTAssertEqual(result.observedAt, observedAt)
+ XCTAssertTrue(base.calledPaths.isEmpty)
+ }
+
+ func testCachingSizerForceRefreshBypassesReadButStillWrites() {
+ let identity = DiskCleanRootIdentity.test()
+ let cache = DiskCleanSizeCache()
+ cache.store(path: path, result: .testComplete(bytes: 1, identity: identity), now: Date())
+ let base = FakeDiskCleanSizer()
+ base.setResult(.testComplete(bytes: 42, identity: identity), forPath: path)
+ let sizer = DiskCleanCachingSizer(
+ base: base,
+ fallback: nil,
+ cache: cache,
+ identityProbe: FakeDiskCleanRootIdentityProbe(identitiesByPath: [path: identity]),
+ forceRefresh: true
+ )
+
+ let result = sizer.size(ofItemAt: path, context: .test())
+
+ XCTAssertEqual(result.estimatedBytes, 42)
+ XCTAssertEqual(base.calledPaths, [path])
+ XCTAssertEqual(
+ cache.result(forPath: path, identity: identity, now: Date())?.estimatedBytes,
+ 42,
+ "bypass reads but still writes so the next ordinary scan can hit the new value"
+ )
+ }
+
+ func testCachingSizerFallsBackToBaseWhenIdentityProbeFails() {
+ let cache = DiskCleanSizeCache()
+ cache.store(path: path, result: .testComplete(bytes: 1, identity: .test()), now: Date())
+ let base = FakeDiskCleanSizer()
+ base.setResult(.testPartial(reasons: [.walkError]), forPath: path)
+ let sizer = DiskCleanCachingSizer(
+ base: base,
+ fallback: nil,
+ cache: cache,
+ identityProbe: FakeDiskCleanRootIdentityProbe(identitiesByPath: [:])
+ )
+
+ let result = sizer.size(ofItemAt: path, context: .test())
+
+ XCTAssertEqual(result.completeness, .partial(reasons: [.walkError]))
+ XCTAssertEqual(base.calledPaths, [path], "when identity cannot be probed, run the real sizer for an accurate degradation reason")
+ }
+
+ /// getattrlistbulk-style pure walkError should retry through SlowWalker (or any injected fallback).
+ func testCachingSizerFallsBackOnPureWalkError() {
+ let identity = DiskCleanRootIdentity.test()
+ let cache = DiskCleanSizeCache()
+ let base = FakeDiskCleanSizer()
+ base.setResult(.testPartial(reasons: [.walkError]), forPath: path)
+ let fallback = FakeDiskCleanSizer()
+ fallback.setResult(.testComplete(bytes: 99, identity: identity), forPath: path)
+ let sizer = DiskCleanCachingSizer(
+ base: base,
+ fallback: fallback,
+ cache: cache,
+ identityProbe: FakeDiskCleanRootIdentityProbe(identitiesByPath: [path: identity]),
+ forceRefresh: true
+ )
+
+ let result = sizer.size(ofItemAt: path, context: .test())
+
+ XCTAssertEqual(result.estimatedBytes, 99)
+ XCTAssertEqual(base.calledPaths, [path])
+ XCTAssertEqual(fallback.calledPaths, [path])
+ }
+
+ func testCachingSizerDoesNotFallbackOnPermissionDenied() {
+ let cache = DiskCleanSizeCache()
+ let base = FakeDiskCleanSizer()
+ base.setResult(.testPartial(reasons: [.permissionDenied]), forPath: path)
+ let fallback = FakeDiskCleanSizer()
+ fallback.setResult(.testComplete(bytes: 1), forPath: path)
+ let sizer = DiskCleanCachingSizer(
+ base: base,
+ fallback: fallback,
+ cache: cache,
+ identityProbe: FakeDiskCleanRootIdentityProbe(identitiesByPath: [:]),
+ forceRefresh: true
+ )
+
+ let result = sizer.size(ofItemAt: path, context: .test())
+
+ XCTAssertEqual(result.completeness, .partial(reasons: [.permissionDenied]))
+ XCTAssertTrue(fallback.calledPaths.isEmpty)
+ }
+}
+
+// MARK: - Real identity probe
+
+final class DiskCleanRootIdentityProbeTests: XCTestCase {
+ private var temporary: DiskCleanTempDirectory!
+ private let probe = DiskCleanRootIdentityProbe()
+
+ override func setUpWithError() throws {
+ temporary = try DiskCleanTempDirectory(name: "identity-probe")
+ }
+
+ override func tearDown() {
+ temporary?.remove()
+ temporary = nil
+ }
+
+ func testReportsDirectoryIdentity() throws {
+ let directory = try temporary.makeDirectory("Cache")
+
+ let identity = try XCTUnwrap(probe.identity(ofItemAt: directory.path))
+
+ XCTAssertEqual(identity.fileType, .directory)
+ XCTAssertGreaterThan(identity.fileID, 0)
+ }
+
+ /// Symlinks must report as the link itself and never follow — otherwise the cache would endorse the target identity for the link.
+ func testReportsSymlinkItselfWithoutFollowing() throws {
+ try temporary.makeDirectory("Real")
+ let link = try temporary.makeSymlink("Link", destination: "Real")
+
+ let identity = try XCTUnwrap(probe.identity(ofItemAt: link.path))
+
+ XCTAssertEqual(identity.fileType, .symlink)
+ }
+
+ /// Intermediate symlink → refuse (the `O_NOFOLLOW_ANY` guarantee).
+ func testRejectsSymlinkInParentChain() throws {
+ try temporary.makeDirectory("Real/Inner")
+ try temporary.makeSymlink("Alias", destination: "Real")
+
+ XCTAssertNil(probe.identity(ofItemAt: temporary.resolve("Alias/Inner").path))
+ }
+
+ func testMissingPathHasNoIdentity() {
+ XCTAssertNil(probe.identity(ofItemAt: temporary.resolve("nope").path))
+ }
+
+ /// Real-FS "replace directory while preserving mtime": cache must miss.
+ func testReplacedDirectoryWithPreservedMtimeInvalidatesCache() throws {
+ // Write both with the same fixed mtime so comparisons are not affected by utimes microsecond truncation.
+ let pinnedMtime = Date(timeIntervalSince1970: 1_600_000_000)
+ let directory = try temporary.makeDirectory("Cache")
+ try temporary.makeFile("Cache/a.bin", bytes: 10)
+ try FileManager.default.setAttributes([.modificationDate: pinnedMtime], ofItemAtPath: directory.path)
+ let originalIdentity = try XCTUnwrap(probe.identity(ofItemAt: directory.path))
+ let cache = DiskCleanSizeCache()
+ cache.store(
+ path: directory.path,
+ result: .testComplete(bytes: 10, identity: originalIdentity),
+ now: Date()
+ )
+
+ try FileManager.default.removeItem(at: directory)
+ try temporary.makeDirectory("Cache")
+ try FileManager.default.setAttributes([.modificationDate: pinnedMtime], ofItemAtPath: directory.path)
+ let replacedIdentity = try XCTUnwrap(probe.identity(ofItemAt: directory.path))
+
+ XCTAssertEqual(replacedIdentity.mtime, originalIdentity.mtime, "mtime was preserved")
+ XCTAssertNotEqual(replacedIdentity.fileID, originalIdentity.fileID)
+ XCTAssertNil(
+ cache.result(forPath: directory.path, identity: replacedIdentity, now: Date()),
+ "same mtime different inode must be treated as different objects"
+ )
+ }
+}
diff --git a/Plugins/DiskClean/Tests/DiskCleanSizingTestSupport.swift b/Plugins/DiskClean/Tests/DiskCleanSizingTestSupport.swift
new file mode 100644
index 00000000..a700ace0
--- /dev/null
+++ b/Plugins/DiskClean/Tests/DiskCleanSizingTestSupport.swift
@@ -0,0 +1,335 @@
+import Darwin
+import Foundation
+import XCTest
+@testable import MacTools
+@testable import DiskCleanPlugin
+
+/// Temp-directory fixture for sizing-subsystem filesystem tests.
+///
+/// **Must use physical paths**: `FileManager.temporaryDirectory` lives under `/var/folders/...`, and `/var`
+/// itself is a symlink to `private/var`, so `O_NOFOLLOW_ANY` rejects such paths with ELOOP.
+/// `resolvingSymlinksInPath()` does not fix this (it does not expand `/var`); only `realpath(3)` does.
+///
+/// Everything is under an isolated subdirectory of `FileManager.temporaryDirectory`, deleted wholesale on teardown,
+/// and never touches real user directories (repo hard requirement).
+final class DiskCleanTempDirectory {
+ let url: URL
+ /// Directories set to 000 must have permissions restored before teardown or cleanup fails.
+ private var restrictedDirectories: [URL] = []
+
+ var path: String { url.path }
+
+ init(name: String) throws {
+ let created = FileManager.default.temporaryDirectory
+ .appendingPathComponent("\(name)-\(UUID().uuidString)", isDirectory: true)
+ try FileManager.default.createDirectory(at: created, withIntermediateDirectories: true)
+ self.url = URL(fileURLWithPath: Self.physicalPath(of: created.path), isDirectory: true)
+ }
+
+ func remove() {
+ for directory in restrictedDirectories {
+ chmod(directory.path, 0o755)
+ }
+ restrictedDirectories.removeAll()
+ try? FileManager.default.removeItem(at: url)
+ }
+
+ static func physicalPath(of path: String) -> String {
+ var buffer = [CChar](repeating: 0, count: Int(PATH_MAX))
+ guard realpath(path, &buffer) != nil else { return path }
+ let bytes = buffer.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) }
+ return String(decoding: bytes, as: UTF8.self)
+ }
+
+ // MARK: - Layout construction
+
+ func resolve(_ relativePath: String) -> URL {
+ url.appendingPathComponent(relativePath)
+ }
+
+ @discardableResult
+ func makeDirectory(_ relativePath: String) throws -> URL {
+ let target = resolve(relativePath)
+ try FileManager.default.createDirectory(at: target, withIntermediateDirectories: true)
+ return target
+ }
+
+ /// Write a file of `bytes` bytes and return its URL. Logical size equals `bytes` exactly.
+ @discardableResult
+ func makeFile(_ relativePath: String, bytes: Int) throws -> URL {
+ let target = resolve(relativePath)
+ try FileManager.default.createDirectory(
+ at: target.deletingLastPathComponent(),
+ withIntermediateDirectories: true
+ )
+ try Data(repeating: 0x41, count: bytes).write(to: target)
+ return target
+ }
+
+ @discardableResult
+ func makeSymlink(_ relativePath: String, destination: String) throws -> URL {
+ let target = resolve(relativePath)
+ try FileManager.default.createDirectory(
+ at: target.deletingLastPathComponent(),
+ withIntermediateDirectories: true
+ )
+ try FileManager.default.createSymbolicLink(atPath: target.path, withDestinationPath: destination)
+ return target
+ }
+
+ @discardableResult
+ func makeHardLink(_ relativePath: String, to existingRelativePath: String) throws -> URL {
+ let target = resolve(relativePath)
+ try FileManager.default.createDirectory(
+ at: target.deletingLastPathComponent(),
+ withIntermediateDirectories: true
+ )
+ try FileManager.default.linkItem(at: resolve(existingRelativePath), to: target)
+ return target
+ }
+
+ /// Set directory mode to 000 to create an EPERM subtree and register it for teardown restore.
+ func denyAccess(to relativePath: String) throws {
+ let target = resolve(relativePath)
+ restrictedDirectories.append(target)
+ guard chmod(target.path, 0o000) == 0 else {
+ throw DiskCleanTestError.chmodFailed(path: target.path, code: errno)
+ }
+ }
+}
+
+enum DiskCleanTestError: Error {
+ case chmodFailed(path: String, code: Int32)
+}
+
+/// Known directory-tree fixture: cross-directory hard links, symlinks, and deep nesting; both walkers share identical expectations.
+enum DiskCleanKnownTree {
+ static let plainFileBytes = 100
+ static let nestedFileBytes = 250
+ static let deepFileBytes = 50
+ static let hardLinkedFileBytes = 400
+ static let symlinkDestination = "../a.bin"
+ static let deepDirectoryDepth = 40
+
+ /// Symlink logical size is the target string length.
+ static var symlinkBytes: Int { symlinkDestination.utf8.count }
+
+ /// Hard links counted once: `original.bin` / `HardLinks/copy.bin` / `OtherDir/cross.bin`
+ /// share the same (devid, fileID).
+ static var expectedBytes: Int64 {
+ Int64(plainFileBytes + nestedFileBytes + deepFileBytes + symlinkBytes + hardLinkedFileBytes)
+ }
+
+ /// a.bin / Nested/b.bin / deep deep.bin / Links/toA / hard-link group (count 1). Directories not counted.
+ static let expectedFileCount = 5
+
+ @discardableResult
+ static func build(in temporary: DiskCleanTempDirectory, at relativeRoot: String = "Tree") throws -> String {
+ try temporary.makeFile("\(relativeRoot)/a.bin", bytes: plainFileBytes)
+ try temporary.makeFile("\(relativeRoot)/Nested/b.bin", bytes: nestedFileBytes)
+
+ var deepPath = "\(relativeRoot)/Nested/deep"
+ for level in 0.. Bool = { false },
+ admitDevice: @escaping @Sendable (UInt64) -> Bool = { _ in true }
+ ) -> DiskCleanSizingContext {
+ DiskCleanSizingContext(
+ deadline: deadline,
+ isCancelled: isCancelled,
+ admitDevice: admitDevice
+ )
+ }
+}
diff --git a/Plugins/DiskClean/Tests/DiskCleanSlowWalkerTests.swift b/Plugins/DiskClean/Tests/DiskCleanSlowWalkerTests.swift
new file mode 100644
index 00000000..21eff75c
--- /dev/null
+++ b/Plugins/DiskClean/Tests/DiskCleanSlowWalkerTests.swift
@@ -0,0 +1,142 @@
+import Darwin
+import XCTest
+@testable import MacTools
+@testable import DiskCleanPlugin
+
+final class DiskCleanSlowWalkerTests: XCTestCase {
+ private var temporaryDirectory: DiskCleanTempDirectory!
+ private var walker: DiskCleanSlowWalker!
+
+ override func setUpWithError() throws {
+ try super.setUpWithError()
+ temporaryDirectory = try DiskCleanTempDirectory(name: "DiskCleanSlowWalkerTests")
+ walker = DiskCleanSlowWalker()
+ }
+
+ override func tearDownWithError() throws {
+ temporaryDirectory?.remove()
+ temporaryDirectory = nil
+ walker = nil
+ try super.tearDownWithError()
+ }
+
+ // MARK: - Behavior contract (shared asserts with FastWalker)
+
+ 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: - SlowWalker-specific
+
+ /// readdir returns "." and ".." which must be filtered — otherwise counts duplicate and recursion never ends.
+ func testSkipsDotEntries() throws {
+ try temporaryDirectory.makeFile("Root/only.bin", bytes: 64)
+
+ let result = walker.size(ofItemAt: temporaryDirectory.resolve("Root").path, context: .test())
+
+ XCTAssertEqual(result.completeness, .complete)
+ XCTAssertEqual(result.fileCount, 1, "\".\" and \"..\" must not be counted")
+ XCTAssertEqual(result.estimatedBytes, 64)
+ }
+
+ 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.estimatedBytes, 512)
+ XCTAssertEqual(result.fileCount, 1)
+ }
+
+ /// Tiny batchSize forces multi-batch reads to verify no misses or duplicates at batch boundaries.
+ func testSingleEntryBatchesStillProduceSameTotal() throws {
+ let root = try DiskCleanKnownTree.build(in: temporaryDirectory)
+
+ let result = DiskCleanSlowWalker(batchSize: 1).size(ofItemAt: root, context: .test())
+
+ XCTAssertEqual(result.completeness, .complete)
+ XCTAssertEqual(result.estimatedBytes, DiskCleanKnownTree.expectedBytes)
+ XCTAssertEqual(result.fileCount, DiskCleanKnownTree.expectedFileCount)
+ }
+
+ // MARK: - Cross-check with FastWalker
+
+ /// Both walkers must produce identical results on the same tree — the core guarantee that fallback does not lower semantics.
+ func testAgreesWithFastWalkerOnKnownTree() throws {
+ let root = try DiskCleanKnownTree.build(in: temporaryDirectory)
+
+ let slow = walker.size(ofItemAt: root, context: .test())
+ let fast = DiskCleanFastWalker().size(ofItemAt: root, context: .test())
+
+ XCTAssertEqual(slow.estimatedBytes, fast.estimatedBytes)
+ XCTAssertEqual(slow.fileCount, fast.fileCount)
+ XCTAssertEqual(slow.completeness, fast.completeness)
+ XCTAssertEqual(slow.rootIdentity, fast.rootIdentity)
+ }
+
+ /// Degradation scenarios must also match: under an EPERM subtree both share the same byte count and completeness reasons.
+ func testAgreesWithFastWalkerOnPermissionDeniedTree() throws {
+ try XCTSkipIf(getuid() == 0, "cannot construct EACCES when running as root")
+ try temporaryDirectory.makeFile("Root/readable.bin", bytes: 70)
+ try temporaryDirectory.makeFile("Root/Locked/secret.bin", bytes: 900)
+ try temporaryDirectory.denyAccess(to: "Root/Locked")
+ let root = temporaryDirectory.resolve("Root").path
+
+ let slow = walker.size(ofItemAt: root, context: .test())
+ let fast = DiskCleanFastWalker().size(ofItemAt: root, context: .test())
+
+ XCTAssertEqual(slow.completeness, fast.completeness)
+ XCTAssertEqual(slow.completeness, .partial(reasons: [.permissionDenied]))
+ XCTAssertEqual(slow.estimatedBytes, fast.estimatedBytes)
+ }
+
+ func testAgreesWithFastWalkerOnSymlinkAndFileRoots() throws {
+ try temporaryDirectory.makeFile("target.bin", bytes: 1234)
+ try temporaryDirectory.makeSymlink("alias", destination: "target.bin")
+
+ for relativePath in ["target.bin", "alias"] {
+ let path = temporaryDirectory.resolve(relativePath).path
+ let slow = walker.size(ofItemAt: path, context: .test())
+ let fast = DiskCleanFastWalker().size(ofItemAt: path, context: .test())
+
+ XCTAssertEqual(slow.estimatedBytes, fast.estimatedBytes, relativePath)
+ XCTAssertEqual(slow.fileCount, fast.fileCount, relativePath)
+ XCTAssertEqual(slow.completeness, fast.completeness, relativePath)
+ XCTAssertEqual(slow.rootIdentity, fast.rootIdentity, relativePath)
+ }
+ }
+}
diff --git a/Plugins/DiskClean/Tests/DiskCleanStagingJournalTests.swift b/Plugins/DiskClean/Tests/DiskCleanStagingJournalTests.swift
new file mode 100644
index 00000000..f59c98be
--- /dev/null
+++ b/Plugins/DiskClean/Tests/DiskCleanStagingJournalTests.swift
@@ -0,0 +1,184 @@
+import Foundation
+import XCTest
+@testable import MacTools
+@testable import DiskCleanPlugin
+
+/// Staging journal and crash-point matrix (design §7.6).
+final class DiskCleanStagingJournalTests: XCTestCase {
+ private var storage: DiskCleanTempDirectory!
+ private var journal: DiskCleanStagingJournal!
+
+ override func setUpWithError() throws {
+ try super.setUpWithError()
+ storage = try DiskCleanTempDirectory(name: "diskclean-journal")
+ journal = DiskCleanStagingJournal(directory: storage.url)
+ }
+
+ override func tearDown() {
+ storage?.remove()
+ storage = nil
+ journal = nil
+ super.tearDown()
+ }
+
+ func testInitDoesNotTouchFileSystem() throws {
+ let untouched = storage.resolve("never-created")
+
+ _ = DiskCleanStagingJournal(directory: untouched)
+
+ XCTAssertFalse(
+ FileManager.default.fileExists(atPath: untouched.path),
+ "constructing a journal must not create directories: a default-constructed plugin would create state dirs in real user paths"
+ )
+ }
+
+ /// Crash point 1: begin is durable, rename not yet done or done without a completion record → entry incomplete.
+ func testEntryWithoutCompletionIsIncomplete() throws {
+ let entry = makeEntry(id: "one")
+
+ try journal.begin(entry)
+
+ XCTAssertEqual(journal.incompleteEntries(), [entry])
+ }
+
+ /// Crash point 2: completion record is durable → entry is cleared; reconciliation will not touch it.
+ func testCompletedEntryIsNotIncomplete() throws {
+ let entry = makeEntry(id: "one")
+ try journal.begin(entry)
+
+ journal.complete(entryID: entry.id, status: "removed")
+
+ XCTAssertTrue(journal.incompleteEntries().isEmpty)
+ }
+
+ /// Live cleanup transactions must not be offered to reconciliation between begin and complete/release.
+ func testActiveEntriesAreHiddenFromReconciliationUntilCompleteOrRelease() throws {
+ let entry = makeEntry(id: "live")
+ try journal.begin(entry)
+
+ XCTAssertEqual(journal.incompleteEntries(), [entry])
+ XCTAssertTrue(
+ journal.incompleteEntriesForReconciliation().isEmpty,
+ "live begin must not be reconciled or compacted away"
+ )
+
+ journal.compact()
+ XCTAssertEqual(journal.incompleteEntries(), [entry], "compact must keep active begins")
+
+ // rollbackBlocked path: transaction ended but entry stays incomplete for recovery.
+ journal.releaseActive(entryID: entry.id)
+ XCTAssertEqual(journal.incompleteEntriesForReconciliation(), [entry])
+
+ journal.complete(entryID: entry.id, status: "removed")
+ XCTAssertTrue(journal.incompleteEntries().isEmpty)
+ XCTAssertTrue(journal.incompleteEntriesForReconciliation().isEmpty)
+ }
+
+ /// A reopened journal (new process) has no active marks and can reconcile unfinished entries.
+ func testReopenedJournalSeesIncompleteEntriesForReconciliation() throws {
+ let entry = makeEntry(id: "crash")
+ try journal.begin(entry)
+
+ let reopened = DiskCleanStagingJournal(directory: storage.url)
+ XCTAssertEqual(reopened.incompleteEntriesForReconciliation(), [entry])
+ }
+
+ func testOnlyUncompletedEntriesRemainAndAreSortedByTimestamp() throws {
+ let first = makeEntry(id: "first", timestamp: Date(timeIntervalSince1970: 100))
+ let second = makeEntry(id: "second", timestamp: Date(timeIntervalSince1970: 300))
+ let third = makeEntry(id: "third", timestamp: Date(timeIntervalSince1970: 200))
+ try journal.begin(first)
+ try journal.begin(second)
+ try journal.begin(third)
+
+ journal.complete(entryID: second.id, status: "trashed")
+
+ XCTAssertEqual(journal.incompleteEntries().map(\.id), ["first", "third"])
+ }
+
+ /// On crash the last line may be half-written. Half lines must be skipped without affecting other entries.
+ func testTruncatedTrailingLineIsTolerated() throws {
+ let entry = makeEntry(id: "one")
+ try journal.begin(entry)
+ let fileURL = storage.resolve(DiskCleanStagingJournal.fileName)
+ let handle = try FileHandle(forWritingTo: fileURL)
+ try handle.seekToEnd()
+ try handle.write(contentsOf: Data(#"{"kind":"begin","entry":{"id":"tr"#.utf8))
+ try handle.close()
+
+ XCTAssertEqual(
+ journal.incompleteEntries(),
+ [entry],
+ "a half line may only discard itself. Write-order invariant: incomplete begin = rename not yet done = no orphans"
+ )
+ }
+
+ func testCorruptedMiddleLineIsSkipped() throws {
+ let first = makeEntry(id: "first", timestamp: Date(timeIntervalSince1970: 100))
+ let second = makeEntry(id: "second", timestamp: Date(timeIntervalSince1970: 200))
+ try journal.begin(first)
+ let fileURL = storage.resolve(DiskCleanStagingJournal.fileName)
+ let handle = try FileHandle(forWritingTo: fileURL)
+ try handle.seekToEnd()
+ try handle.write(contentsOf: Data("not json at all\n".utf8))
+ try handle.close()
+ try journal.begin(second)
+
+ XCTAssertEqual(journal.incompleteEntries().map(\.id), ["first", "second"])
+ }
+
+ func testCompactKeepsOnlyIncompleteEntries() throws {
+ let done = makeEntry(id: "done")
+ let pending = makeEntry(id: "pending")
+ try journal.begin(done)
+ try journal.begin(pending)
+ journal.complete(entryID: done.id, status: "removed")
+
+ journal.compact()
+
+ XCTAssertEqual(journal.incompleteEntries(), [pending])
+ let contents = try String(contentsOf: storage.resolve(DiskCleanStagingJournal.fileName), encoding: .utf8)
+ XCTAssertFalse(contents.contains("done"), "completed entries must not keep space after compaction")
+ XCTAssertEqual(contents.split(separator: "\n").count, 1)
+ }
+
+ func testCompactOnEmptyJournalLeavesNothingIncomplete() throws {
+ let entry = makeEntry(id: "one")
+ try journal.begin(entry)
+ journal.complete(entryID: entry.id, status: "removed")
+
+ journal.compact()
+
+ XCTAssertTrue(journal.incompleteEntries().isEmpty)
+ }
+
+ func testIncompleteEntriesOnMissingFileIsEmpty() {
+ let fresh = DiskCleanStagingJournal(directory: storage.resolve("absent"))
+
+ XCTAssertTrue(fresh.incompleteEntries().isEmpty)
+ }
+
+ func testBeginThrowsWhenDirectoryPathIsOccupiedByFile() throws {
+ try storage.makeFile("occupied", bytes: 1)
+ let blocked = DiskCleanStagingJournal(directory: storage.resolve("occupied"))
+
+ XCTAssertThrowsError(
+ try blocked.begin(makeEntry(id: "one")),
+ "journal write failure must throw so the caller aborts the rename"
+ )
+ }
+
+ private func makeEntry(
+ id: String,
+ timestamp: Date = Date(timeIntervalSince1970: 1_000)
+ ) -> DiskCleanStagingJournal.Entry {
+ DiskCleanStagingJournal.Entry(
+ id: id,
+ timestamp: timestamp,
+ parentPath: "/cache",
+ originalName: "app",
+ stagedName: ".mactools-staged-\(id)",
+ mode: DiskCleanRemovalMode.permanent.rawValue
+ )
+ }
+}
diff --git a/Plugins/DiskClean/Tests/DiskCleanStagingReconcilerTests.swift b/Plugins/DiskClean/Tests/DiskCleanStagingReconcilerTests.swift
new file mode 100644
index 00000000..6300415c
--- /dev/null
+++ b/Plugins/DiskClean/Tests/DiskCleanStagingReconcilerTests.swift
@@ -0,0 +1,156 @@
+import Darwin
+import Foundation
+import XCTest
+@testable import MacTools
+@testable import DiskCleanPlugin
+
+/// Startup reconciliation (design §7.6). Real temp directories + real renames.
+final class DiskCleanStagingReconcilerTests: XCTestCase {
+ private var temporary: DiskCleanTempDirectory!
+ private var storage: DiskCleanTempDirectory!
+ private var journal: DiskCleanStagingJournal!
+ private var auditLog: DiskCleanAuditLog!
+
+ override func setUpWithError() throws {
+ try super.setUpWithError()
+ temporary = try DiskCleanTempDirectory(name: "diskclean-reconcile")
+ storage = try DiskCleanTempDirectory(name: "diskclean-reconcile-state")
+ journal = DiskCleanStagingJournal(directory: storage.url)
+ auditLog = DiskCleanAuditLog(directory: storage.url)
+ }
+
+ override func tearDown() {
+ temporary?.remove()
+ storage?.remove()
+ temporary = nil
+ storage = nil
+ journal = nil
+ auditLog = nil
+ super.tearDown()
+ }
+
+ /// Crash after rename, before completion record → orphan staged object renamed back.
+ func testOrphanStagedObjectIsRolledBackToOriginalPath() throws {
+ let stagedName = DiskCleanRemovalPrimitive.stagedNamePrefix + "orphan"
+ try temporary.makeFile("\(stagedName)/a.bin", bytes: 10)
+ try journal.begin(entry(stagedName: stagedName, originalName: "Cache"))
+ // New process: reopen journal so in-process active marks are gone.
+ journal = DiskCleanStagingJournal(directory: storage.url)
+
+ let outcomes = DiskCleanStagingReconciler().reconcile(journal: journal, auditLog: auditLog)
+
+ XCTAssertEqual(outcomes, [.rolledBack(originalPath: temporary.resolve("Cache").path)])
+ assertExists(temporary.resolve("Cache/a.bin").path)
+ assertMissing(temporary.resolve(stagedName).path)
+ XCTAssertTrue(journal.incompleteEntries().isEmpty, "successful restore clears the journal")
+ }
+
+ /// Crash before rename → no staged object; clear the entry with no permanent backlog.
+ func testEntryWithoutStagedObjectIsClosedOut() throws {
+ try journal.begin(entry(stagedName: DiskCleanRemovalPrimitive.stagedNamePrefix + "never", originalName: "Cache"))
+ journal = DiskCleanStagingJournal(directory: storage.url)
+
+ let outcomes = DiskCleanStagingReconciler().reconcile(journal: journal, auditLog: auditLog)
+
+ XCTAssertEqual(outcomes, [.absent(stagedName: DiskCleanRemovalPrimitive.stagedNamePrefix + "never")])
+ XCTAssertTrue(journal.incompleteEntries().isEmpty)
+ }
+
+ func testEntryWithMissingParentDirectoryIsClosedOut() throws {
+ let stagedName = DiskCleanRemovalPrimitive.stagedNamePrefix + "gone"
+ try journal.begin(
+ DiskCleanStagingJournal.Entry(
+ id: "gone",
+ timestamp: Date(timeIntervalSince1970: 1_000),
+ parentPath: temporary.resolve("no-such-parent").path,
+ originalName: "Cache",
+ stagedName: stagedName,
+ mode: DiskCleanRemovalMode.permanent.rawValue
+ )
+ )
+ journal = DiskCleanStagingJournal(directory: storage.url)
+
+ let outcomes = DiskCleanStagingReconciler().reconcile(journal: journal, auditLog: auditLog)
+
+ XCTAssertEqual(outcomes, [.absent(stagedName: stagedName)])
+ XCTAssertTrue(journal.incompleteEntries().isEmpty)
+ }
+
+ /// Original path was recreated → never overwrite: keep the staged object and leave the entry incomplete for continued prompting.
+ func testRollbackIsBlockedWhenOriginalPathWasRecreated() throws {
+ let stagedName = DiskCleanRemovalPrimitive.stagedNamePrefix + "blocked"
+ try temporary.makeFile("\(stagedName)/a.bin", bytes: 10)
+ try temporary.makeFile("Cache/rebuilt.bin", bytes: 20)
+ try journal.begin(entry(stagedName: stagedName, originalName: "Cache"))
+ journal = DiskCleanStagingJournal(directory: storage.url)
+
+ let outcomes = DiskCleanStagingReconciler().reconcile(journal: journal, auditLog: auditLog)
+
+ XCTAssertEqual(
+ outcomes,
+ [.blocked(stagedName: stagedName, originalPath: temporary.resolve("Cache").path)]
+ )
+ assertExists(temporary.resolve("Cache/rebuilt.bin").path, "recreated original path must stay intact")
+ assertExists(temporary.resolve("\(stagedName)/a.bin").path, "staged object must be retained")
+ XCTAssertEqual(
+ journal.incompleteEntries().map(\.stagedName),
+ [stagedName],
+ "unresolved entries must remain so the next launch keeps prompting"
+ )
+ }
+
+ func testAuditRecordsEveryOutcome() throws {
+ let rolledBack = DiskCleanRemovalPrimitive.stagedNamePrefix + "rolled"
+ let blocked = DiskCleanRemovalPrimitive.stagedNamePrefix + "blocked"
+ try temporary.makeFile("\(rolledBack)/a.bin", bytes: 10)
+ try temporary.makeFile("\(blocked)/a.bin", bytes: 10)
+ try temporary.makeDirectory("Occupied")
+ try journal.begin(
+ entry(id: "one", stagedName: rolledBack, originalName: "Restored", timestamp: Date(timeIntervalSince1970: 1))
+ )
+ try journal.begin(
+ entry(id: "two", stagedName: blocked, originalName: "Occupied", timestamp: Date(timeIntervalSince1970: 2))
+ )
+ journal = DiskCleanStagingJournal(directory: storage.url)
+
+ _ = DiskCleanStagingReconciler().reconcile(journal: journal, auditLog: auditLog)
+
+ let statuses = Set(auditLog.recentRecords(limit: 10).map(\.status))
+ XCTAssertEqual(statuses, ["reconciledRolledBack", "rollbackBlocked"])
+ }
+
+ func testReconcileWithEmptyJournalDoesNothing() {
+ let outcomes = DiskCleanStagingReconciler().reconcile(journal: journal, auditLog: auditLog)
+
+ XCTAssertTrue(outcomes.isEmpty)
+ XCTAssertTrue(auditLog.recentRecords(limit: 10).isEmpty)
+ }
+
+ // MARK: - Fixtures
+
+ private func entry(
+ id: String = "one",
+ stagedName: String,
+ originalName: String,
+ timestamp: Date = Date(timeIntervalSince1970: 1_000)
+ ) -> DiskCleanStagingJournal.Entry {
+ DiskCleanStagingJournal.Entry(
+ id: id,
+ timestamp: timestamp,
+ parentPath: temporary.path,
+ originalName: originalName,
+ stagedName: stagedName,
+ mode: DiskCleanRemovalMode.permanent.rawValue
+ )
+ }
+
+ private func assertExists(_ path: String, _ message: String = "", file: StaticString = #filePath, line: UInt = #line) {
+ var status = stat()
+ XCTAssertEqual(lstat(path, &status), 0, message, file: file, line: line)
+ }
+
+ private func assertMissing(_ path: String, _ message: String = "", file: StaticString = #filePath, line: UInt = #line) {
+ var status = stat()
+ XCTAssertNotEqual(lstat(path, &status), 0, message, file: file, line: line)
+ }
+}
diff --git a/Plugins/DiskClean/Tests/DiskCleanWorkerPoolTests.swift b/Plugins/DiskClean/Tests/DiskCleanWorkerPoolTests.swift
new file mode 100644
index 00000000..0995afd9
--- /dev/null
+++ b/Plugins/DiskClean/Tests/DiskCleanWorkerPoolTests.swift
@@ -0,0 +1,448 @@
+import Darwin
+import XCTest
+@testable import MacTools
+@testable import DiskCleanPlugin
+
+final class DiskCleanWorkerPoolTests: XCTestCase {
+ /// All pools under test are registered here and torn down together so idle threads do not pile up.
+ private var pools: [DiskCleanWorkerPool] = []
+ /// Releases deliberately stuck jobs; teardown must signal all of them or threads stay forever.
+ private var releaseSemaphores: [DispatchSemaphore] = []
+
+ override func tearDown() {
+ for semaphore in releaseSemaphores {
+ // Extra signals are harmless; ensure stuck jobs can finish and exit.
+ semaphore.signal()
+ semaphore.signal()
+ }
+ releaseSemaphores.removeAll()
+ for pool in pools {
+ pool.shutDown()
+ }
+ pools.removeAll()
+ super.tearDown()
+ }
+
+ // MARK: - Exactly-once gate: two-way race
+
+ /// Timeout and job completion race both ways: no matter how many concurrent callers, `resolve` has one winner
+ /// and resume happens once. Multiple rounds raise the chance of hitting the race.
+ func testResumeGateResumesExactlyOnceUnderConcurrentRace() {
+ for round in 0..<200 {
+ let resumeCount = AtomicCounter()
+ let winnerCount = AtomicCounter()
+ let gate = DiskCleanResumeGate { _ in resumeCount.increment() }
+
+ let contenderCount = 8
+ let startGate = DispatchSemaphore(value: 0)
+ let group = DispatchGroup()
+ for contender in 0..()
+ let gate = DiskCleanResumeGate { delivered.value = $0 }
+
+ XCTAssertTrue(gate.resolve(with: 1))
+ XCTAssertFalse(gate.resolve(with: 2))
+ XCTAssertFalse(gate.claim())
+ XCTAssertEqual(delivered.value, 1)
+ }
+
+ // MARK: - Happy path
+
+ func testReturnsJobResult() async {
+ let pool = makePool()
+
+ let result = await pool.perform(deadline: Date().addingTimeInterval(5)) { _ in
+ DiskCleanSizeResult(
+ estimatedBytes: 4321,
+ fileCount: 7,
+ completeness: .complete,
+ rootIdentity: nil,
+ observedAt: Date()
+ )
+ }
+
+ XCTAssertEqual(result.estimatedBytes, 4321)
+ XCTAssertEqual(result.fileCount, 7)
+ XCTAssertEqual(result.completeness, .complete)
+ }
+
+ func testRunsManyJobsWithoutExceedingThreadBudget() async {
+ let pool = makePool(maxThreadCount: 3)
+ let concurrency = ConcurrencyProbe()
+
+ await withTaskGroup(of: Void.self) { group in
+ for _ in 0..<12 {
+ group.addTask {
+ _ = await pool.perform(deadline: Date().addingTimeInterval(10)) { _ in
+ concurrency.enter()
+ Thread.sleep(forTimeInterval: 0.02)
+ concurrency.leave()
+ return Self.completeResult
+ }
+ }
+ }
+ }
+
+ XCTAssertLessThanOrEqual(concurrency.peak, 3, "resident thread cap is 3; concurrency must not exceed it")
+ XCTAssertEqual(concurrency.totalRuns, 12)
+ }
+
+ func testSizeConvenienceForwardsContextToSizer() async {
+ let pool = makePool()
+ let sizer = RecordingSizer()
+ let deadline = Date().addingTimeInterval(3)
+
+ let result = await pool.size(ofItemAt: "/some/path", using: sizer, deadline: deadline)
+
+ XCTAssertEqual(sizer.observedPath, "/some/path")
+ XCTAssertEqual(sizer.observedDeadline, deadline)
+ XCTAssertEqual(result.completeness, .complete)
+ }
+
+ // MARK: - Timeout, abandon, and device blacklist
+
+ /// Job stuck in an uninterruptible call → timeout wins, thread is abandoned, its device is blacklisted.
+ func testTimeoutAbandonsThreadAndBlocklistsReportedDevice() async {
+ let pool = makePool(maxThreadCount: 3, abandonBudget: 3)
+ let hanging = makeHangingJob(reportingDevice: 42)
+
+ let result = await pool.perform(deadline: Date().addingTimeInterval(0.15), job: hanging.job)
+
+ XCTAssertEqual(result.completeness, .partial(reasons: [.timedOut]))
+ XCTAssertEqual(pool.abandonedThreads, 1, "stuck thread must count against abandon budget")
+ XCTAssertFalse(pool.isCircuitBroken, "budget 3 with 1 used must not circuit-break yet")
+
+ // Blacklist hit: later jobs reporting the same device are refused and must abandon immediately.
+ let admitted = AtomicBox()
+ _ = await pool.perform(deadline: Date().addingTimeInterval(5)) { context in
+ admitted.value = context.admitDevice(42)
+ return Self.completeResult
+ }
+ XCTAssertEqual(admitted.value, false, "device of an abandoned thread must be blacklisted")
+
+ // Other devices are unaffected.
+ let otherAdmitted = AtomicBox()
+ _ = await pool.perform(deadline: Date().addingTimeInterval(5)) { context in
+ otherAdmitted.value = context.admitDevice(7)
+ return Self.completeResult
+ }
+ XCTAssertEqual(otherAdmitted.value, true, "healthy devices must not be collateralized")
+ }
+
+ /// When a stuck job finally returns, its result must be dropped in place (gate already taken by timeout)
+ /// and must never resume the continuation twice (that would crash).
+ func testLateJobResultIsDiscardedWithoutDoubleResume() async {
+ let pool = makePool(maxThreadCount: 3, abandonBudget: 3)
+ let hanging = makeHangingJob()
+
+ let result = await pool.perform(deadline: Date().addingTimeInterval(0.15), job: hanging.job)
+ XCTAssertEqual(result.completeness, .partial(reasons: [.timedOut]))
+
+ // Release the stuck job so it finishes the "own and discard result" path.
+ hanging.release.signal()
+ let finished = await waitUntil { hanging.didFinish.value == true }
+ XCTAssertTrue(finished, "stuck job should finish and exit on its own")
+
+ // Pool remains usable (abandoned threads are replaced).
+ let next = await pool.perform(deadline: Date().addingTimeInterval(5)) { _ in Self.completeResult }
+ XCTAssertEqual(next.completeness, .complete)
+ }
+
+ /// Jobs that time out while still queued stuck no thread → must not consume abandon budget.
+ func testTimeoutOfQueuedJobDoesNotConsumeAbandonBudget() async {
+ let pool = makePool(maxThreadCount: 1, abandonBudget: 3)
+ let occupying = makeHangingJob()
+
+ // Occupy the only thread with a long-deadline blocking job.
+ let occupyingTask = Task { await pool.perform(deadline: Date().addingTimeInterval(60), job: occupying.job) }
+ let started = await waitUntil { occupying.didStart.value == true }
+ XCTAssertTrue(started, "placeholder job failed to start")
+
+ // This one can only queue and will expire while queued.
+ let didRun = AtomicBox()
+ let queued = await pool.perform(deadline: Date().addingTimeInterval(0.15)) { _ in
+ didRun.value = true
+ return Self.completeResult
+ }
+
+ XCTAssertEqual(queued.completeness, .partial(reasons: [.timedOut]))
+ XCTAssertNil(didRun.value, "queued job must not run")
+ XCTAssertEqual(pool.abandonedThreads, 0, "no stuck thread means no budget use")
+ XCTAssertFalse(pool.isCircuitBroken)
+
+ occupying.release.signal()
+ _ = await occupyingTask.value
+ }
+
+ // MARK: - Circuit break (fail closed)
+
+ /// Budget exhausted → circuit break → process runs no more sizing: later jobs **do not run at all**
+ /// and return partial([.unsupportedVolume]) immediately.
+ func testBudgetExhaustionBreaksCircuitAndFailsClosed() async {
+ let pool = makePool(maxThreadCount: 3, abandonBudget: 2)
+
+ let first = makeHangingJob(reportingDevice: 1)
+ let firstResult = await pool.perform(deadline: Date().addingTimeInterval(0.15), job: first.job)
+ XCTAssertEqual(firstResult.completeness, .partial(reasons: [.timedOut]))
+ XCTAssertEqual(pool.abandonedThreads, 1)
+ XCTAssertFalse(pool.isCircuitBroken)
+
+ let second = makeHangingJob(reportingDevice: 2)
+ let secondResult = await pool.perform(deadline: Date().addingTimeInterval(0.15), job: second.job)
+ XCTAssertEqual(secondResult.completeness, .partial(reasons: [.timedOut]))
+ XCTAssertEqual(pool.abandonedThreads, 2)
+ XCTAssertTrue(pool.isCircuitBroken, "exhausted budget must circuit-break")
+
+ // fail closed: no fallback sizer, no retry, refuse immediately.
+ let didRun = AtomicBox()
+ let afterBreak = await pool.perform(deadline: Date().addingTimeInterval(5)) { _ in
+ didRun.value = true
+ return Self.completeResult
+ }
+
+ XCTAssertEqual(afterBreak.completeness, .partial(reasons: [.unsupportedVolume]))
+ XCTAssertNil(didRun.value, "must not run any sizing job after circuit break")
+ XCTAssertNil(afterBreak.rootIdentity)
+
+ // Circuit break does not auto-recover.
+ let stillBroken = await pool.perform(deadline: Date().addingTimeInterval(5)) { _ in Self.completeResult }
+ XCTAssertEqual(stillBroken.completeness, .partial(reasons: [.unsupportedVolume]))
+ XCTAssertTrue(pool.isCircuitBroken)
+ }
+
+ /// After circuit break, jobs already queued are also fail-closed.
+ func testCircuitBreakDrainsQueuedJobs() async {
+ let pool = makePool(maxThreadCount: 1, abandonBudget: 1)
+ let occupying = makeHangingJob(reportingDevice: 5)
+
+ let occupyingTask = Task { await pool.perform(deadline: Date().addingTimeInterval(0.3), job: occupying.job) }
+ let started = await waitUntil { occupying.didStart.value == true }
+ XCTAssertTrue(started)
+
+ let didRun = AtomicBox()
+ let queuedTask = Task {
+ await pool.perform(deadline: Date().addingTimeInterval(60)) { _ in
+ didRun.value = true
+ return Self.completeResult
+ }
+ }
+
+ let occupyingResult = await occupyingTask.value
+ XCTAssertEqual(occupyingResult.completeness, .partial(reasons: [.timedOut]))
+ XCTAssertTrue(pool.isCircuitBroken)
+
+ let queuedResult = await queuedTask.value
+ XCTAssertEqual(queuedResult.completeness, .partial(reasons: [.unsupportedVolume]))
+ XCTAssertNil(didRun.value, "queued jobs during circuit break must also be fail-closed")
+ }
+
+ // MARK: - Cancellation
+
+ /// Task cancellation cannot interrupt a blocking syscall, but must reach the job via `isCancelled`
+ /// so it exits at the next checkpoint.
+ func testCancellationReachesRunningJob() async {
+ let pool = makePool()
+ let didStart = AtomicBox()
+ let sawCancellation = AtomicBox()
+
+ let task = Task {
+ await pool.perform(deadline: Date().addingTimeInterval(60)) { context in
+ didStart.value = true
+ while !context.isCancelled() {
+ Thread.sleep(forTimeInterval: 0.01)
+ }
+ sawCancellation.value = true
+ return Self.completeResult
+ }
+ }
+
+ let started = await waitUntil { didStart.value == true }
+ XCTAssertTrue(started)
+
+ task.cancel()
+ let result = await task.value
+
+ XCTAssertEqual(sawCancellation.value, true, "job must observe cancellation")
+ XCTAssertEqual(result.completeness, .complete, "job-returned result should win the gate")
+ }
+
+ // MARK: - Helpers
+
+ private static var completeResult: DiskCleanSizeResult {
+ DiskCleanSizeResult(
+ estimatedBytes: 1,
+ fileCount: 1,
+ completeness: .complete,
+ rootIdentity: nil,
+ observedAt: Date()
+ )
+ }
+
+ private func makePool(maxThreadCount: Int = 3, abandonBudget: Int = 3) -> DiskCleanWorkerPool {
+ let pool = DiskCleanWorkerPool(maxThreadCount: maxThreadCount, abandonBudget: abandonBudget)
+ pools.append(pool)
+ return pool
+ }
+
+ /// Build a permanently stuck job simulating a walker blocked in an uninterruptible syscall.
+ private func makeHangingJob(reportingDevice device: UInt64? = nil) -> HangingJob {
+ let release = DispatchSemaphore(value: 0)
+ releaseSemaphores.append(release)
+ let didStart = AtomicBox()
+ let didFinish = AtomicBox()
+
+ let job: DiskCleanSizingJob = { context in
+ if let device {
+ _ = context.admitDevice(device)
+ }
+ didStart.value = true
+ release.wait()
+ didFinish.value = true
+ return DiskCleanWorkerPoolTests.completeResult
+ }
+ return HangingJob(job: job, release: release, didStart: didStart, didFinish: didFinish)
+ }
+
+ /// Async poll wait so async tests avoid blocking cooperative threads.
+ private func waitUntil(
+ timeout: TimeInterval = 5,
+ _ condition: @escaping @Sendable () -> Bool
+ ) async -> Bool {
+ let deadline = Date().addingTimeInterval(timeout)
+ while Date() < deadline {
+ if condition() { return true }
+ try? await Task.sleep(nanoseconds: 5_000_000)
+ }
+ return condition()
+ }
+
+ private struct HangingJob {
+ let job: DiskCleanSizingJob
+ let release: DispatchSemaphore
+ let didStart: AtomicBox
+ let didFinish: AtomicBox
+ }
+}
+
+// MARK: - Test concurrency primitives
+
+private final class AtomicCounter: @unchecked Sendable {
+ private let lock = NSLock()
+ private var count = 0
+
+ func increment() {
+ lock.lock()
+ count += 1
+ lock.unlock()
+ }
+
+ var value: Int {
+ lock.lock()
+ defer { lock.unlock() }
+ return count
+ }
+}
+
+private final class AtomicBox: @unchecked Sendable {
+ private let lock = NSLock()
+ private var storage: Value?
+
+ var value: Value? {
+ get {
+ lock.lock()
+ defer { lock.unlock() }
+ return storage
+ }
+ set {
+ lock.lock()
+ storage = newValue
+ lock.unlock()
+ }
+ }
+}
+
+/// Records concurrency peak.
+private final class ConcurrencyProbe: @unchecked Sendable {
+ private let lock = NSLock()
+ private var current = 0
+ private var highWaterMark = 0
+ private var runs = 0
+
+ func enter() {
+ lock.lock()
+ current += 1
+ runs += 1
+ highWaterMark = max(highWaterMark, current)
+ lock.unlock()
+ }
+
+ func leave() {
+ lock.lock()
+ current -= 1
+ lock.unlock()
+ }
+
+ var peak: Int {
+ lock.lock()
+ defer { lock.unlock() }
+ return highWaterMark
+ }
+
+ var totalRuns: Int {
+ lock.lock()
+ defer { lock.unlock() }
+ return runs
+ }
+}
+
+private final class RecordingSizer: DiskCleanDirectorySizing, @unchecked Sendable {
+ private let lock = NSLock()
+ private var path: String?
+ private var deadline: Date?
+
+ var observedPath: String? {
+ lock.lock()
+ defer { lock.unlock() }
+ return path
+ }
+
+ var observedDeadline: Date? {
+ lock.lock()
+ defer { lock.unlock() }
+ return deadline
+ }
+
+ func size(ofItemAt path: String, context: DiskCleanSizingContext) -> DiskCleanSizeResult {
+ lock.lock()
+ self.path = path
+ self.deadline = context.deadline
+ lock.unlock()
+ return DiskCleanSizeResult(
+ estimatedBytes: 0,
+ fileCount: 0,
+ completeness: .complete,
+ rootIdentity: nil,
+ observedAt: Date()
+ )
+ }
+}
diff --git a/Plugins/DiskClean/plugin.json b/Plugins/DiskClean/plugin.json
index 080e8bc8..8d7b5f78 100644
--- a/Plugins/DiskClean/plugin.json
+++ b/Plugins/DiskClean/plugin.json
@@ -1,54 +1,54 @@
{
"id": "disk-clean",
"displayName": "磁盘清理",
- "summary": "缓存、开发者缓存和浏览器缓存清理",
+ "summary": "扫描系统缓存、开发产物与残留安装包,默认移到废纸篓,执行前校验路径安全",
"localizedMetadata": {
"ar": {
"displayName": "تنظيف القرص",
- "summary": "تنظيف ذاكرة التخزين المؤقت، وذاكرة التخزين المؤقت للمطورين، وذاكرة التخزين المؤقت للمتصفح."
+ "summary": "يمسح ذاكرات التخزين المؤقت للنظام ومخرجات التطوير وبرامج التثبيت المتبقية؛ ينقل افتراضيًا إلى سلة المهملات مع فحوصات أمان المسار قبل الحذف."
},
"de": {
"displayName": "Datenträgerbereinigung",
- "summary": "Caches, Entwickler-Caches und Browser-Caches bereinigen."
+ "summary": "System-Caches, Entwicklerartefakte und übrig gebliebene Installer scannen; standardmäßig in den Papierkorb, mit Pfadsicherheitsprüfung vor dem Löschen."
},
"en": {
"displayName": "Disk Cleanup",
- "summary": "Clean caches, developer caches, and browser caches."
+ "summary": "Scan system caches, developer artifacts, and leftover installers; move to Trash by default with path safety checks before deletion"
},
"es": {
"displayName": "Limpieza de disco",
- "summary": "Limpiar cachés, cachés de desarrolladores y cachés de navegador."
+ "summary": "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": {
"displayName": "Nettoyage du disque",
- "summary": "Nettoyer les caches, les caches des développeurs et les caches du navigateur."
+ "summary": "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": {
"displayName": "ディスククリーンアップ",
- "summary": "キャッシュ、開発者キャッシュ、ブラウザ キャッシュをクリーンアップします。"
+ "summary": "システムキャッシュ、開発成果物、残留インストーラをスキャン。既定でゴミ箱へ移動し、削除前にパス安全性を確認します。"
},
"ko": {
"displayName": "디스크 정리",
- "summary": "캐시, 개발자 캐시 및 브라우저 캐시를 정리합니다."
+ "summary": "시스템 캐시, 개발 산출물, 잔여 설치 패키지를 스캔합니다. 기본적으로 휴지통으로 옮기며 삭제 전 경로 안전성을 검사합니다."
},
"pt": {
"displayName": "Limpeza de disco",
- "summary": "Limpe caches, caches de desenvolvedores e caches de navegador."
+ "summary": "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": {
"displayName": "Очистка диска",
- "summary": "Очистка кеша, кеша разработчика и кеша браузера."
+ "summary": "Сканирует системные кэши, артефакты разработки и оставшиеся установщики; по умолчанию перемещает в Корзину с проверкой безопасности пути перед удалением."
},
"zh-Hans": {
"displayName": "磁盘清理",
- "summary": "缓存、开发者缓存和浏览器缓存清理"
+ "summary": "扫描系统缓存、开发产物与残留安装包,默认移到废纸篓,执行前校验路径安全"
},
"zh-Hant": {
"displayName": "磁碟清理",
- "summary": "緩存、開發者緩存和瀏覽器緩存清理"
+ "summary": "掃描系統快取、開發產物與殘留安裝包,預設移到廢紙簍,執行前校驗路徑安全"
}
},
- "version": "1.0.8",
+ "version": "2.0.0",
"minHostVersion": "1.1.3",
"pluginKitVersion": 3,
"bundleRelativePath": "DiskClean.bundle",
diff --git a/Plugins/DisplayBrightness/Resources/Localizable.xcstrings b/Plugins/DisplayBrightness/Resources/Localizable.xcstrings
index 679b94e5..019ca3d5 100644
--- a/Plugins/DisplayBrightness/Resources/Localizable.xcstrings
+++ b/Plugins/DisplayBrightness/Resources/Localizable.xcstrings
@@ -2698,6 +2698,38 @@
}
}
}
+ },
+ "settings.shortcutTarget.searchDescription": {
+ "extractionState": "manual",
+ "localizations": {
+ "ar": { "stringUnit": { "state": "translated", "value": "اختر نطاق الشاشات التي تتحكم بها اختصارات السطوع." } },
+ "de": { "stringUnit": { "state": "translated", "value": "Wähle, welche Displays von den Helligkeitskurzbefehlen gesteuert werden." } },
+ "en": { "stringUnit": { "state": "translated", "value": "Choose which displays are controlled by brightness shortcuts." } },
+ "es": { "stringUnit": { "state": "translated", "value": "Elige qué pantallas controlan los atajos de brillo." } },
+ "fr": { "stringUnit": { "state": "translated", "value": "Choisissez les écrans contrôlés par les raccourcis de luminosité." } },
+ "ja": { "stringUnit": { "state": "translated", "value": "明るさのショートカットで操作するディスプレイを選択します。" } },
+ "ko": { "stringUnit": { "state": "translated", "value": "밝기 단축키로 제어할 디스플레이 범위를 선택합니다." } },
+ "pt": { "stringUnit": { "state": "translated", "value": "Escolha quais telas são controladas pelos atalhos de brilho." } },
+ "ru": { "stringUnit": { "state": "translated", "value": "Выберите дисплеи, которыми управляют сочетания яркости." } },
+ "zh-Hans": { "stringUnit": { "state": "translated", "value": "选择亮度快捷键控制的显示器范围。" } },
+ "zh-Hant": { "stringUnit": { "state": "translated", "value": "選擇亮度快速鍵控制的顯示器範圍。" } }
+ }
+ },
+ "settings.shortcutTarget.searchKeyword": {
+ "extractionState": "manual",
+ "localizations": {
+ "ar": { "stringUnit": { "state": "translated", "value": "شاشة" } },
+ "de": { "stringUnit": { "state": "translated", "value": "Bildschirm" } },
+ "en": { "stringUnit": { "state": "translated", "value": "screen" } },
+ "es": { "stringUnit": { "state": "translated", "value": "pantalla" } },
+ "fr": { "stringUnit": { "state": "translated", "value": "écran" } },
+ "ja": { "stringUnit": { "state": "translated", "value": "画面" } },
+ "ko": { "stringUnit": { "state": "translated", "value": "화면" } },
+ "pt": { "stringUnit": { "state": "translated", "value": "tela" } },
+ "ru": { "stringUnit": { "state": "translated", "value": "экран" } },
+ "zh-Hans": { "stringUnit": { "state": "translated", "value": "屏幕" } },
+ "zh-Hant": { "stringUnit": { "state": "translated", "value": "螢幕" } }
+ }
}
},
"version": "1.0"
diff --git a/Plugins/DisplayBrightness/Sources/DisplayBrightnessPlugin.swift b/Plugins/DisplayBrightness/Sources/DisplayBrightnessPlugin.swift
index 2f7576d8..a06b0129 100644
--- a/Plugins/DisplayBrightness/Sources/DisplayBrightnessPlugin.swift
+++ b/Plugins/DisplayBrightness/Sources/DisplayBrightnessPlugin.swift
@@ -119,7 +119,13 @@ private struct DisplayBrightnessShortcutSession {
}
@MainActor
-final class DisplayBrightnessPlugin: MacToolsPlugin, PluginPrimaryPanel, PluginShortcutEventHandling, DisplayTopologyRefreshing {
+final class DisplayBrightnessPlugin:
+ MacToolsPlugin,
+ PluginPrimaryPanel,
+ PluginShortcutEventHandling,
+ DisplayTopologyRefreshing,
+ PluginSettingsSearchProviding
+{
private enum Constants {
static let displayControlPrefix = "display."
static let brightnessControlSuffix = ".brightness"
@@ -235,6 +241,33 @@ final class DisplayBrightnessPlugin: MacToolsPlugin, PluginPrimaryPanel, PluginS
]
}
+ var settingsSearchEntries: [PluginSettingsSearchEntry] {
+ [
+ PluginSettingsSearchEntry(
+ id: DisplayBrightnessSettingsSearchEntryID.shortcutTarget,
+ title: localization.string(
+ "settings.shortcutTarget.title",
+ defaultValue: "快捷键目标"
+ ),
+ description: localization.string(
+ "settings.shortcutTarget.searchDescription",
+ defaultValue: "选择亮度快捷键控制的显示器范围。"
+ ),
+ keywords: [
+ localization.string(
+ "settings.shortcutTarget.sectionTitle",
+ defaultValue: "作用范围"
+ ),
+ localization.string(
+ "settings.shortcutTarget.searchKeyword",
+ defaultValue: "屏幕"
+ )
+ ],
+ systemImage: "display.2"
+ )
+ ]
+ }
+
func refresh() {
controller.refresh()
displayDisableCoordinator.refreshSnapshot()
diff --git a/Plugins/DisplayBrightness/Sources/DisplayBrightnessSettingsView.swift b/Plugins/DisplayBrightness/Sources/DisplayBrightnessSettingsView.swift
index f60edb96..f2b2b29f 100644
--- a/Plugins/DisplayBrightness/Sources/DisplayBrightnessSettingsView.swift
+++ b/Plugins/DisplayBrightness/Sources/DisplayBrightnessSettingsView.swift
@@ -1,6 +1,10 @@
import SwiftUI
import MacToolsPluginKit
+enum DisplayBrightnessSettingsSearchEntryID {
+ static let shortcutTarget = "shortcut-target"
+}
+
struct DisplayBrightnessSettingsView: View {
@ObservedObject var preferences: DisplayBrightnessShortcutPreferences
let localization: PluginLocalization
@@ -44,6 +48,10 @@ struct DisplayBrightnessSettingsView: View {
.fixedSize()
}
.pluginSettingsListRowPadding(interactive: true)
+ .pluginSettingsSearchAnchor(
+ pluginID: "display-brightness",
+ entryID: DisplayBrightnessSettingsSearchEntryID.shortcutTarget
+ )
}
.pluginSettingsCardBackground(.host)
}
diff --git a/Plugins/DisplayBrightness/plugin.json b/Plugins/DisplayBrightness/plugin.json
index a8bd5d4f..bf26b445 100644
--- a/Plugins/DisplayBrightness/plugin.json
+++ b/Plugins/DisplayBrightness/plugin.json
@@ -49,7 +49,7 @@
}
},
"version": "1.0.12",
- "minHostVersion": "1.1.3",
+ "minHostVersion": "1.1.5",
"pluginKitVersion": 3,
"bundleRelativePath": "DisplayBrightness.bundle",
"factoryClass": "DisplayBrightnessPlugin.DisplayBrightnessPluginFactory",
diff --git a/Plugins/DisplaySleep/Sources/DisplaySleepPlugin.swift b/Plugins/DisplaySleep/Sources/DisplaySleepPlugin.swift
index e45a4750..75b060ca 100644
--- a/Plugins/DisplaySleep/Sources/DisplaySleepPlugin.swift
+++ b/Plugins/DisplaySleep/Sources/DisplaySleepPlugin.swift
@@ -19,7 +19,7 @@ private struct DisplaySleepPluginProvider: PluginProvider {
}
@MainActor
-final class DisplaySleepPlugin: MacToolsPlugin, PluginPrimaryPanel {
+final class DisplaySleepPlugin: MacToolsPlugin, PluginPrimaryPanel, PluginCommandProviding {
let metadata: PluginMetadata
let primaryPanelDescriptor: PluginPrimaryPanelDescriptor
@@ -32,8 +32,10 @@ final class DisplaySleepPlugin: MacToolsPlugin, PluginPrimaryPanel {
subsystem: Bundle.main.bundleIdentifier ?? "cc.ggbond.mactools",
category: "DisplaySleepPlugin"
)
+ private let localization: PluginLocalization
init(localization: PluginLocalization = PluginLocalization(bundle: .main)) {
+ self.localization = localization
self.metadata = PluginMetadata(
id: "display-sleep",
title: localization.string("metadata.title", defaultValue: "显示器休眠"),
@@ -61,6 +63,31 @@ final class DisplaySleepPlugin: MacToolsPlugin, PluginPrimaryPanel {
)
}
+ var commandDefinitions: [PluginCommandDefinition] {
+ [
+ PluginCommandDefinition(
+ id: "execute",
+ title: localization.string(
+ "metadata.title",
+ defaultValue: "显示器休眠"
+ ),
+ description: localization.string(
+ "metadata.description",
+ defaultValue: "立即让显示器休眠"
+ ),
+ systemImage: metadata.iconName
+ )
+ ]
+ }
+
+ func handleCommand(id: String) {
+ guard id == "execute" else {
+ return
+ }
+
+ handleAction(.invokeAction(controlID: "execute"))
+ }
+
func handleAction(_ action: PluginPanelAction) {
guard case let .invokeAction(controlID) = action, controlID == "execute" else {
return
diff --git a/Plugins/DisplaySleep/Tests/DisplaySleepPluginTests.swift b/Plugins/DisplaySleep/Tests/DisplaySleepPluginTests.swift
index 4610faaf..c3fed0c7 100644
--- a/Plugins/DisplaySleep/Tests/DisplaySleepPluginTests.swift
+++ b/Plugins/DisplaySleep/Tests/DisplaySleepPluginTests.swift
@@ -1,4 +1,5 @@
import XCTest
+import MacToolsPluginKit
@testable import MacTools
@testable import DisplaySleepPlugin
@@ -55,4 +56,65 @@ final class DisplaySleepPluginTests: XCTestCase {
plugin.handleAction(.setSwitch(true))
}
+
+ func testCommandDefinitionsFollowRuntimeLanguageWithoutRecreatingPlugin() throws {
+ let preferenceKey = PluginRuntimeLocalization.preferenceUserDefaultsKey
+ let originalPreference = UserDefaults.standard.string(forKey: preferenceKey)
+ let bundleURL = try makeCommandLocalizationBundle()
+ defer {
+ try? FileManager.default.removeItem(at: bundleURL.deletingLastPathComponent())
+ restoreRuntimePreference(originalPreference, key: preferenceKey)
+ }
+ let bundle = try XCTUnwrap(Bundle(url: bundleURL))
+
+ setRuntimePreference("en", key: preferenceKey)
+ let plugin = DisplaySleepPlugin(localization: PluginLocalization(bundle: bundle))
+ XCTAssertEqual(plugin.commandDefinitions.first?.title, "Display Sleep")
+ XCTAssertEqual(plugin.commandDefinitions.first?.description, "Sleep displays now")
+
+ setRuntimePreference("zh-Hans", key: preferenceKey)
+ XCTAssertEqual(plugin.commandDefinitions.first?.title, "显示器休眠")
+ XCTAssertEqual(plugin.commandDefinitions.first?.description, "立即让显示器休眠")
+ }
+
+ private func makeCommandLocalizationBundle() throws -> URL {
+ let directory = FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString, isDirectory: true)
+ let bundleURL = directory.appendingPathComponent("DisplaySleepTests.bundle", isDirectory: true)
+ try FileManager.default.createDirectory(at: bundleURL, withIntermediateDirectories: true)
+
+ let translations = [
+ "en": ("Display Sleep", "Sleep displays now"),
+ "zh-Hans": ("显示器休眠", "立即让显示器休眠"),
+ ]
+ for (language, values) in translations {
+ let languageURL = bundleURL.appendingPathComponent("\(language).lproj", isDirectory: true)
+ try FileManager.default.createDirectory(at: languageURL, withIntermediateDirectories: true)
+ let strings = """
+ "metadata.title" = "\(values.0)";
+ "metadata.description" = "\(values.1)";
+ """
+ try strings.write(
+ to: languageURL.appendingPathComponent("Localizable.strings"),
+ atomically: true,
+ encoding: .utf8
+ )
+ }
+
+ return bundleURL
+ }
+
+ private func setRuntimePreference(_ preference: String, key: String) {
+ UserDefaults.standard.set(preference, forKey: key)
+ PluginRuntimeLocalization.source.setPreference(preference)
+ }
+
+ private func restoreRuntimePreference(_ preference: String?, key: String) {
+ if let preference {
+ UserDefaults.standard.set(preference, forKey: key)
+ } else {
+ UserDefaults.standard.removeObject(forKey: key)
+ }
+ PluginRuntimeLocalization.source.setPreference(preference)
+ }
}
diff --git a/Plugins/DisplaySleep/plugin.json b/Plugins/DisplaySleep/plugin.json
index 956f29f3..89f41850 100644
--- a/Plugins/DisplaySleep/plugin.json
+++ b/Plugins/DisplaySleep/plugin.json
@@ -49,7 +49,7 @@
}
},
"version": "1.0.6",
- "minHostVersion": "1.1.3",
+ "minHostVersion": "1.1.5",
"pluginKitVersion": 3,
"bundleRelativePath": "DisplaySleep.bundle",
"factoryClass": "DisplaySleepPlugin.DisplaySleepPluginFactory",
diff --git a/Plugins/KeepAwake/Sources/KeepAwakePlugin.swift b/Plugins/KeepAwake/Sources/KeepAwakePlugin.swift
index 6a68c367..4eaff48a 100644
--- a/Plugins/KeepAwake/Sources/KeepAwakePlugin.swift
+++ b/Plugins/KeepAwake/Sources/KeepAwakePlugin.swift
@@ -20,7 +20,12 @@ private struct KeepAwakePluginProvider: PluginProvider {
}
@MainActor
-final class KeepAwakePlugin: MacToolsPlugin, PluginPrimaryPanel, PluginPrimaryPanelCompactIndicatorProviding {
+final class KeepAwakePlugin:
+ MacToolsPlugin,
+ PluginPrimaryPanel,
+ PluginPrimaryPanelCompactIndicatorProviding,
+ PluginSettingsSearchProviding
+{
typealias SessionFactory = (
PluginLocalization,
@escaping (KeepAwakeSession.EndReason) -> Void
@@ -202,6 +207,51 @@ final class KeepAwakePlugin: MacToolsPlugin, PluginPrimaryPanel, PluginPrimaryPa
var shortcutDefinitions: [PluginShortcutDefinition] { [] }
+ var settingsSearchEntries: [PluginSettingsSearchEntry] {
+ var entries = [
+ PluginSettingsSearchEntry(
+ id: KeepAwakeSettingsSearchEntryID.keepDisplayOn,
+ title: localization.string(
+ "settings.display.keepOn",
+ defaultValue: "保持屏幕常亮"
+ ),
+ description: localization.string(
+ "settings.display.keepOn.description",
+ defaultValue: "阻止休眠运行时,防止屏幕因空闲而关闭。"
+ ),
+ keywords: [
+ localization.string("settings.display.section", defaultValue: "屏幕")
+ ],
+ systemImage: "display"
+ )
+ ]
+
+ if powerSourceState.isPortableMac {
+ entries.append(
+ PluginSettingsSearchEntry(
+ id: KeepAwakeSettingsSearchEntryID.keepAwakeWithLidClosed,
+ title: localization.string(
+ "settings.lidClose.keepAwake",
+ defaultValue: "合盖保持唤醒"
+ ),
+ description: localization.string(
+ "settings.lidClose.keepAwake.description",
+ defaultValue: "使用电池时暂停,重新接通电源后恢复。"
+ ),
+ keywords: [
+ localization.string(
+ "settings.lidClose.section",
+ defaultValue: "MacBook"
+ )
+ ],
+ systemImage: "laptopcomputer"
+ )
+ )
+ }
+
+ return entries
+ }
+
var configuration: PluginConfiguration? {
PluginConfiguration(description: metadata.defaultDescription) { [weak self, localization] _ in
KeepAwakeSettingsView(
diff --git a/Plugins/KeepAwake/Sources/KeepAwakeSettingsView.swift b/Plugins/KeepAwake/Sources/KeepAwakeSettingsView.swift
index abac477b..1707b4d3 100644
--- a/Plugins/KeepAwake/Sources/KeepAwakeSettingsView.swift
+++ b/Plugins/KeepAwake/Sources/KeepAwakeSettingsView.swift
@@ -1,6 +1,11 @@
import SwiftUI
import MacToolsPluginKit
+enum KeepAwakeSettingsSearchEntryID {
+ static let keepDisplayOn = "keep-display-on"
+ static let keepAwakeWithLidClosed = "keep-awake-with-lid-closed"
+}
+
struct KeepAwakeSettingsView: View {
@Binding var keepDisplayOn: Bool
@Binding var keepAwakeWithLidClosed: Bool
@@ -42,6 +47,10 @@ struct KeepAwakeSettingsView: View {
.padding(.horizontal, PluginSettingsTheme.Spacing.rowHorizontal)
.padding(.vertical, PluginSettingsTheme.Spacing.interactiveRowVertical)
.pluginSettingsCardBackground(.plugin)
+ .pluginSettingsSearchAnchor(
+ pluginID: "keep-awake",
+ entryID: KeepAwakeSettingsSearchEntryID.keepDisplayOn
+ )
}
if powerSourceState.isPortableMac {
@@ -76,6 +85,10 @@ struct KeepAwakeSettingsView: View {
.padding(.horizontal, PluginSettingsTheme.Spacing.rowHorizontal)
.padding(.vertical, PluginSettingsTheme.Spacing.interactiveRowVertical)
.pluginSettingsCardBackground(.plugin)
+ .pluginSettingsSearchAnchor(
+ pluginID: "keep-awake",
+ entryID: KeepAwakeSettingsSearchEntryID.keepAwakeWithLidClosed
+ )
}
}
}
diff --git a/Plugins/KeepAwake/plugin.json b/Plugins/KeepAwake/plugin.json
index 3baf1bdd..fcd21d24 100644
--- a/Plugins/KeepAwake/plugin.json
+++ b/Plugins/KeepAwake/plugin.json
@@ -49,7 +49,7 @@
}
},
"version": "1.0.10",
- "minHostVersion": "1.1.3",
+ "minHostVersion": "1.1.5",
"pluginKitVersion": 3,
"bundleRelativePath": "KeepAwake.bundle",
"factoryClass": "KeepAwakePlugin.KeepAwakePluginFactory",
diff --git a/Plugins/LockScreen/Sources/LockScreenPlugin.swift b/Plugins/LockScreen/Sources/LockScreenPlugin.swift
index 10bc72c5..dd08dfeb 100644
--- a/Plugins/LockScreen/Sources/LockScreenPlugin.swift
+++ b/Plugins/LockScreen/Sources/LockScreenPlugin.swift
@@ -20,7 +20,7 @@ private struct LockScreenPluginProvider: PluginProvider {
}
@MainActor
-final class LockScreenPlugin: MacToolsPlugin, PluginPrimaryPanel {
+final class LockScreenPlugin: MacToolsPlugin, PluginPrimaryPanel, PluginCommandProviding {
let metadata: PluginMetadata
let primaryPanelDescriptor: PluginPrimaryPanelDescriptor
@@ -33,8 +33,10 @@ final class LockScreenPlugin: MacToolsPlugin, PluginPrimaryPanel {
subsystem: Bundle.main.bundleIdentifier ?? "cc.ggbond.mactools",
category: "LockScreenPlugin"
)
+ private let localization: PluginLocalization
init(localization: PluginLocalization = PluginLocalization(bundle: .main)) {
+ self.localization = localization
self.metadata = PluginMetadata(
id: "lock-screen",
title: localization.string("metadata.title", defaultValue: "锁定屏幕"),
@@ -62,6 +64,31 @@ final class LockScreenPlugin: MacToolsPlugin, PluginPrimaryPanel {
)
}
+ var commandDefinitions: [PluginCommandDefinition] {
+ [
+ PluginCommandDefinition(
+ id: "execute",
+ title: localization.string(
+ "metadata.title",
+ defaultValue: "锁定屏幕"
+ ),
+ description: localization.string(
+ "metadata.description",
+ defaultValue: "立即锁定屏幕"
+ ),
+ systemImage: metadata.iconName
+ )
+ ]
+ }
+
+ func handleCommand(id: String) {
+ guard id == "execute" else {
+ return
+ }
+
+ handleAction(.invokeAction(controlID: "execute"))
+ }
+
func handleAction(_ action: PluginPanelAction) {
guard case let .invokeAction(controlID) = action, controlID == "execute" else {
return
diff --git a/Plugins/LockScreen/Tests/LockScreenPluginTests.swift b/Plugins/LockScreen/Tests/LockScreenPluginTests.swift
index 78769a7c..463a961f 100644
--- a/Plugins/LockScreen/Tests/LockScreenPluginTests.swift
+++ b/Plugins/LockScreen/Tests/LockScreenPluginTests.swift
@@ -1,4 +1,5 @@
import XCTest
+import MacToolsPluginKit
@testable import MacTools
@testable import LockScreenPlugin
@@ -55,4 +56,65 @@ final class LockScreenPluginTests: XCTestCase {
plugin.handleAction(.setSwitch(true))
}
+
+ func testCommandDefinitionsFollowRuntimeLanguageWithoutRecreatingPlugin() throws {
+ let preferenceKey = PluginRuntimeLocalization.preferenceUserDefaultsKey
+ let originalPreference = UserDefaults.standard.string(forKey: preferenceKey)
+ let bundleURL = try makeCommandLocalizationBundle()
+ defer {
+ try? FileManager.default.removeItem(at: bundleURL.deletingLastPathComponent())
+ restoreRuntimePreference(originalPreference, key: preferenceKey)
+ }
+ let bundle = try XCTUnwrap(Bundle(url: bundleURL))
+
+ setRuntimePreference("en", key: preferenceKey)
+ let plugin = LockScreenPlugin(localization: PluginLocalization(bundle: bundle))
+ XCTAssertEqual(plugin.commandDefinitions.first?.title, "Lock Screen")
+ XCTAssertEqual(plugin.commandDefinitions.first?.description, "Lock the screen now")
+
+ setRuntimePreference("zh-Hans", key: preferenceKey)
+ XCTAssertEqual(plugin.commandDefinitions.first?.title, "锁定屏幕")
+ XCTAssertEqual(plugin.commandDefinitions.first?.description, "立即锁定屏幕")
+ }
+
+ private func makeCommandLocalizationBundle() throws -> URL {
+ let directory = FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString, isDirectory: true)
+ let bundleURL = directory.appendingPathComponent("LockScreenTests.bundle", isDirectory: true)
+ try FileManager.default.createDirectory(at: bundleURL, withIntermediateDirectories: true)
+
+ let translations = [
+ "en": ("Lock Screen", "Lock the screen now"),
+ "zh-Hans": ("锁定屏幕", "立即锁定屏幕"),
+ ]
+ for (language, values) in translations {
+ let languageURL = bundleURL.appendingPathComponent("\(language).lproj", isDirectory: true)
+ try FileManager.default.createDirectory(at: languageURL, withIntermediateDirectories: true)
+ let strings = """
+ "metadata.title" = "\(values.0)";
+ "metadata.description" = "\(values.1)";
+ """
+ try strings.write(
+ to: languageURL.appendingPathComponent("Localizable.strings"),
+ atomically: true,
+ encoding: .utf8
+ )
+ }
+
+ return bundleURL
+ }
+
+ private func setRuntimePreference(_ preference: String, key: String) {
+ UserDefaults.standard.set(preference, forKey: key)
+ PluginRuntimeLocalization.source.setPreference(preference)
+ }
+
+ private func restoreRuntimePreference(_ preference: String?, key: String) {
+ if let preference {
+ UserDefaults.standard.set(preference, forKey: key)
+ } else {
+ UserDefaults.standard.removeObject(forKey: key)
+ }
+ PluginRuntimeLocalization.source.setPreference(preference)
+ }
}
diff --git a/Plugins/LockScreen/plugin.json b/Plugins/LockScreen/plugin.json
index 9e991e50..2560702d 100644
--- a/Plugins/LockScreen/plugin.json
+++ b/Plugins/LockScreen/plugin.json
@@ -49,7 +49,7 @@
}
},
"version": "1.0.8",
- "minHostVersion": "1.1.3",
+ "minHostVersion": "1.1.5",
"pluginKitVersion": 3,
"bundleRelativePath": "LockScreen.bundle",
"factoryClass": "LockScreenPlugin.LockScreenPluginFactory",
diff --git a/README.md b/README.md
index c3222a80..9423694a 100644
--- a/README.md
+++ b/README.md
@@ -38,7 +38,8 @@
| Stage Manager | Toggle Stage Manager to focus the current window and place other windows on the side. |
| System Mute | Mute or restore system audio output through CoreAudio on the default output device, with automatic restoration when the plugin is disabled. |
| Microphone Mute | Mute or restore the default microphone input through CoreAudio without requesting recording permission. |
-| Disk Cleanup | Scan caches, developer caches, and browser caches, with path safety checks and sensitive data protection before deletion. |
+| App Volume | On macOS 15 and later, adjust the volume of each app currently playing audio and keep preferences locally per app; first use requires System Audio Recording permission. |
+| Disk Cleanup | Scan system caches, developer artifacts (node_modules, build outputs), and leftover installers; move to Trash by default, with path safety checks, Full Disk Access guidance, and sensitive-data protection before deletion. |
| Xcode Cleanup | Scan DerivedData, device support files, archives, simulators, and preview caches by category; deletion is disabled while Xcode is running and only runs inside allowlisted roots. |
| Eject Disks | Detect visible ejectable mounts when the panel opens, including external drives, disk images, and network volumes; multiple volumes on one device are ejected once. |
| Empty Trash | Show the number of Trash items and empty Trash through Finder; the action is disabled when Trash is empty. |
@@ -60,7 +61,7 @@
| Fix Damaged Apps | Remove quarantine attributes to resolve "damaged and can't be opened" prompts by selecting a .app in a file panel and running the fix with administrator privileges. |
| Quit Apps | Select and quit running apps, or quit all at once; multiple instances of the same app are grouped into one stable entry, and reverse selection helps quickly choose the target set. |
| zsh Config | View and edit zsh configuration files such as .zshrc and .zshenv inside the app, with syntax highlighting, common snippets, and automatic backup before saving. |
-| Plugins & Settings | Switch between feature and component panels from the centered menu-bar toolbar or with ⌘1/⌘2, dismiss a menu-bar panel with Esc, and open or focus Settings from an active MacTools surface with ⌘,. Jump among General, Plugins, and About in Settings with ⌘1/⌘2/⌘3, and press ⌘F to focus search in the Marketplace, including installed plugins. Plugin settings use an always-visible native resizable sidebar; the Settings window keeps a consistent width across pages and a usable minimum size, and its Back/Forward toolbar controls (⌘[/⌘]) restore the exact Plugins subpage. Install, update, batch-update, and uninstall plugins in the Marketplace (filter by category/search; sort by not-installed first, installed first, or name using the selected app language). Dashboard and Feature Panel independently control which supported plugins appear there and their order. Double-click a button or switch row subtitle to copy it without invoking the primary control. Use a row’s icon to open settings, its eye control to show or hide it on that surface, and its contextual menu to view the Marketplace entry or uninstall without leaving the layout; manage permissions, plugin-specific settings, and global shortcuts—including standalone F1-F12 function keys plus optional shortcuts to open Settings, toggle Dashboard, or toggle Feature Panel—and export/import portable app preferences, plugin layout orders and visibility, and shortcut customizations as JSON. |
+| Plugins & Settings | Press ⌘K from a MacTools surface, or use the Plugins sidebar search launcher, to open one global palette for app navigation, General settings, plugin pages, individual plugin settings, and explicitly supported commands; use ↑/↓ and Return or the physical ⌘1–⌘9 number-row shortcuts for the first nine visible results, while VoiceOver announcements and deep links take you directly to the chosen setting or plugin row. Switch between feature and component panels from the centered menu-bar toolbar or with ⌘1/⌘2, dismiss a menu-bar panel with Esc, and open or focus Settings from an active MacTools surface with ⌘,. Jump among General, Plugins, and About in Settings with ⌘1/⌘2/⌘3, move between visible Plugins subpages with ⌃⌘↑/⌃⌘↓, and press ⌘F to focus contextual search in the Marketplace, including installed plugins. Plugin settings use an always-visible native resizable sidebar with focused ↑/↓ navigation; the Settings window keeps a consistent width across pages and a usable minimum size, and its Back/Forward toolbar controls (⌘[/⌘]) restore the exact Plugins subpage. Install, update, batch-update, and uninstall plugins in the Marketplace (filter by category/search; sort by not-installed first, installed first, or name using the selected app language). Dashboard and Feature Panel independently control which supported plugins appear there and their order. Double-click a button or switch row subtitle to copy it without invoking the primary control. Use a row’s icon to open settings, its eye control to show or hide it on that surface, and its contextual menu to view the Marketplace entry or uninstall without leaving the layout; manage permissions, plugin-specific settings, and global shortcuts—including standalone F1-F12 function keys plus optional shortcuts to open Settings, toggle Dashboard, or toggle Feature Panel—and export/import portable app preferences, plugin layout orders and visibility, and shortcut customizations as JSON. |
| Menu Bar Icon Customization | Use local images or lightweight GIF/MP4 animations as the menu bar icon, choose static or animated icons from the online gallery with clear animation badges, preserve original colors and transparency on local import, automatically adapt monochrome gallery icons to the menu bar appearance, restore the default icon, and preview every source with the same standard icon height and content inset as the default. |
| Localization | Follow the system language by default, or choose a fixed app language in Settings > General > Appearance; the picker shows each language in the system language and its native spelling, while menu-bar action buttons adapt to localized label lengths. |
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 9558029f..540818bf 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -38,7 +38,8 @@
| 台前调度 | 开启台前调度,集中显示当前窗口并把其他窗口收纳到侧边。 |
| 系统静音 | 一键静音或恢复系统音频输出,通过 CoreAudio 直接控制默认输出设备,停用插件时自动恢复。 |
| 麦克风静音 | 一键静音或恢复默认麦克风输入,通过 CoreAudio 直接控制输入设备,无需录音权限。 |
-| 磁盘清理 | 扫描缓存、开发者缓存与浏览器缓存,执行前进行路径安全和敏感数据保护校验。 |
+| 应用音量 | 在 macOS 15 及以上版本中分别调节正在播放音频的应用音量,设置会按应用保存在本机;首次使用需要系统音频录制权限。 |
+| 磁盘清理 | 扫描系统缓存、开发产物(node_modules、构建输出)与残留安装包,默认移到废纸篓;执行前做路径安全校验、完全磁盘访问引导与敏感数据保护。 |
| Xcode 清理 | 分类扫描 DerivedData、设备支持、归档、模拟器与预览缓存,Xcode 运行时自动禁用,仅在白名单根目录下执行删除。 |
| 推出磁盘 | 一键推出所有可移动磁盘,自动过滤系统卷并在无可推出磁盘时给出状态提示。 |
| 清空废纸篓 | 显示废纸篓项目数,一键通过 Finder 清空,废纸篓为空时自动禁用按钮。 |
@@ -59,7 +60,7 @@
| 修复损坏应用 | 移除应用隔离属性,解决「已损坏,无法打开」提示,通过文件面板选择 .app 并以管理员权限执行修复。 |
| 退出应用 | 选择并退出正在运行的应用,或一键退出全部;同一应用的多个实例会合并为一个稳定条目,并支持反选,方便快速圈定目标。 |
| zsh 配置 | 在应用内直接查看和编辑 zsh 配置文件(.zshrc、.zshenv 等),支持语法高亮、常用片段快速插入和保存前自动备份。 |
-| 插件与设置 | 通过菜单栏面板顶部居中的切换器选择功能或组件面板;在插件市场中安装、更新和批量更新插件(支持分类/搜索筛选;可按未安装优先、已安装优先或按所选语言的名称排序),并在各插件设置页维护权限、快捷键(支持单独使用 F1-F12 功能键)和专属设置;还可设置全局快捷键打开设置窗口。 |
+| 插件与设置 | 在任一 MacTools 界面按 ⌘K,或点击插件侧边栏的搜索入口,即可打开统一搜索面板,查找应用页面、通用设置、插件、具体插件设置项和明确支持的命令;可用 ↑/↓ 与 Return,或按 ⌘1–⌘9 快速打开前九项可见结果,辅助功能标签和深层链接会直接定位并聚焦目标设置。⌘F 继续用于插件市场等当前页面的局部搜索。通过菜单栏面板顶部居中的切换器选择功能或组件面板;在插件市场中安装、更新和批量更新插件(支持分类/搜索筛选;可按未安装优先、已安装优先或按所选语言的名称排序),并在各插件设置页维护权限、快捷键(支持单独使用 F1-F12 功能键)和专属设置;还可设置全局快捷键打开设置窗口。 |
| 状态栏图标自定义 | 上传本地图片或轻量 GIF/MP4 动画作为菜单栏图标,也可从在线图库按需下载动态图标,并支持自动扣背景和恢复默认。 |
| 多语言 | 默认跟随系统语言,也可在「设置 > 通用 > 外观」中固定应用语言;语言选择器会同时显示系统语言名称和语言本名,菜单栏操作按钮会适配不同语言的文案长度。 |
diff --git a/Sources/App/AppL10n.swift b/Sources/App/AppL10n.swift
index 47e3ba1a..1e430f91 100644
--- a/Sources/App/AppL10n.swift
+++ b/Sources/App/AppL10n.swift
@@ -24,6 +24,10 @@ enum AppL10n {
string(key, defaultValue: defaultValue, table: "Plugins")
}
+ static func search(_ key: String, defaultValue: String) -> String {
+ string(key, defaultValue: defaultValue, table: "Search")
+ }
+
static func preferencesBackup(_ key: String, defaultValue: String) -> String {
string(key, defaultValue: defaultValue, table: "PreferencesBackup")
}
@@ -44,6 +48,26 @@ enum AppL10n {
)
}
+ static func searchFormat(_ key: String, defaultValue: String, _ arguments: CVarArg...) -> String {
+ String(
+ format: search(key, defaultValue: defaultValue),
+ locale: PluginRuntimeLocalization.locale,
+ arguments: arguments
+ )
+ }
+
+ static func searchPluralFormat(
+ _ key: String,
+ defaultValue: String,
+ count: Int
+ ) -> String {
+ String(
+ format: search(key, defaultValue: defaultValue),
+ locale: PluginRuntimeLocalization.locale,
+ arguments: [count]
+ )
+ }
+
static func preferencesBackupFormat(_ key: String, defaultValue: String, _ arguments: CVarArg...) -> String {
String(
format: preferencesBackup(key, defaultValue: defaultValue),
diff --git a/Sources/App/AppWindowRouter.swift b/Sources/App/AppWindowRouter.swift
index b9668a83..8714635d 100644
--- a/Sources/App/AppWindowRouter.swift
+++ b/Sources/App/AppWindowRouter.swift
@@ -1,4 +1,5 @@
import AppKit
+import Carbon
import Combine
import SwiftUI
import MacToolsPluginKit
@@ -6,6 +7,8 @@ import MacToolsPluginKit
enum MacToolsLocalKeyboardCommand: Equatable {
case showSettings
case focusSearch
+ case showUnifiedSearch
+ case selectUnifiedSearchResult(Int)
static func resolve(for event: NSEvent) -> MacToolsLocalKeyboardCommand? {
guard event.type == .keyDown else {
@@ -17,20 +20,51 @@ enum MacToolsLocalKeyboardCommand: Equatable {
return nil
}
+ if let selectionNumber = physicalNumberRowSelection(for: event.keyCode) {
+ return .selectUnifiedSearchResult(selectionNumber)
+ }
+
switch event.charactersIgnoringModifiers?.lowercased() {
case ",":
return .showSettings
case "f":
return .focusSearch
+ case "k":
+ return .showUnifiedSearch
default:
return nil
}
}
+
+ private static func physicalNumberRowSelection(for keyCode: UInt16) -> Int? {
+ switch keyCode {
+ case UInt16(kVK_ANSI_1):
+ 1
+ case UInt16(kVK_ANSI_2):
+ 2
+ case UInt16(kVK_ANSI_3):
+ 3
+ case UInt16(kVK_ANSI_4):
+ 4
+ case UInt16(kVK_ANSI_5):
+ 5
+ case UInt16(kVK_ANSI_6):
+ 6
+ case UInt16(kVK_ANSI_7):
+ 7
+ case UInt16(kVK_ANSI_8):
+ 8
+ case UInt16(kVK_ANSI_9):
+ 9
+ default:
+ nil
+ }
+ }
}
@MainActor
final class MacToolsCommandWindow: NSWindow {
- var onLocalKeyboardCommand: ((MacToolsLocalKeyboardCommand) -> Void)?
+ var onLocalKeyboardCommand: ((MacToolsLocalKeyboardCommand) -> Bool)?
override func performKeyEquivalent(with event: NSEvent) -> Bool {
guard
@@ -40,7 +74,10 @@ final class MacToolsCommandWindow: NSWindow {
return super.performKeyEquivalent(with: event)
}
- onLocalKeyboardCommand(command)
+ guard onLocalKeyboardCommand(command) else {
+ return super.performKeyEquivalent(with: event)
+ }
+
return true
}
}
@@ -116,6 +153,11 @@ final class AppWindowRouter: NSObject, NSWindowDelegate {
presentSettings(.settings)
}
+ func showUnifiedSearch() {
+ presentSettings(.settings)
+ settingsNavigationCoordinator?.presentUnifiedSearch(origin: .keyboard)
+ }
+
func setPanelPresentationActions(
showDashboard: @escaping () -> Void,
showFeaturePanel: @escaping () -> Void
@@ -163,14 +205,10 @@ final class AppWindowRouter: NSObject, NSWindowDelegate {
hostingView.sizingOptions = []
window.contentView = hostingView
window.toolbarStyle = .unified
- window.setContentSize(SettingsWindowLayout.defaultContentSize)
- window.layoutIfNeeded()
- // SwiftUI installs its toolbar during layout and can reset window constraints.
- window.contentMinSize = SettingsWindowLayout.minimumContentSize
window.delegate = self
window.isReleasedWhenClosed = false
window.onLocalKeyboardCommand = { [weak self] command in
- self?.handleLocalKeyboardCommand(command)
+ self?.handleLocalKeyboardCommand(command) ?? false
}
window.center()
settingsNavigationCoordinator = navigationCoordinator
@@ -195,8 +233,14 @@ final class AppWindowRouter: NSObject, NSWindowDelegate {
settingsNavigationCoordinator?.navigate(to: .plugins(.configuration(pluginID)))
}
- show(window)
+ let contentSize = window.contentView?.bounds.size ?? SettingsWindowLayout.defaultContentSize
settingsWindow = window
+ show(window)
+ // SwiftUI installs its toolbar when the window becomes visible. Finish that
+ // layout before restoring the content size.
+ window.layoutIfNeeded()
+ window.setContentSize(contentSize)
+ window.layoutIfNeeded()
onProgrammaticSettingsPresentation()
if let pendingAppUpdateVersion {
@@ -206,12 +250,37 @@ final class AppWindowRouter: NSObject, NSWindowDelegate {
}
}
- private func handleLocalKeyboardCommand(_ command: MacToolsLocalKeyboardCommand) {
+ private func handleLocalKeyboardCommand(_ command: MacToolsLocalKeyboardCommand) -> Bool {
switch command {
case .showSettings:
showSettings()
+ return true
case .focusSearch:
settingsNavigationCoordinator?.requestSearchFocus()
+ return true
+ case .showUnifiedSearch:
+ showUnifiedSearch()
+ return true
+ case let .selectUnifiedSearchResult(number):
+ guard let settingsNavigationCoordinator else {
+ return false
+ }
+
+ if settingsNavigationCoordinator.requestUnifiedSearchQuickSelection(number: number) {
+ return true
+ }
+
+ switch number {
+ case 1:
+ settingsNavigationCoordinator.selectSettingsDestination(.general)
+ case 2:
+ settingsNavigationCoordinator.selectSettingsDestination(.pluginConfiguration)
+ case 3:
+ settingsNavigationCoordinator.selectSettingsDestination(.about)
+ default:
+ return false
+ }
+ return true
}
}
diff --git a/Sources/App/FeatureManagementTableView.swift b/Sources/App/FeatureManagementTableView.swift
index 2c9f3ccf..06c43c2a 100644
--- a/Sources/App/FeatureManagementTableView.swift
+++ b/Sources/App/FeatureManagementTableView.swift
@@ -75,6 +75,7 @@ struct FeatureManagementTableView: NSViewRepresentable {
let items: [FeatureManagementTableItem]
let mode: FeatureManagementTableMode
var isReorderEnabled: Bool = true
+ var highlightedPluginID: String? = nil
var onMove: (String, Int) -> Void = { _, _ in }
var onSetVisible: (String, Bool) -> Void = { _, _ in }
var onOpenSettings: (String) -> Void = { _ in }
@@ -111,6 +112,7 @@ struct FeatureManagementTableView: NSViewRepresentable {
let tableView = PluginSettingsReorderTableView(dragType: Self.dragType)
tableView.rowHeight = Self.rowHeight
tableView.intercellSpacing = NSSize(width: 0, height: Self.rowSpacing)
+ tableView.selectionHighlightStyle = .none
let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("feature"))
column.isEditable = false
@@ -146,6 +148,7 @@ struct FeatureManagementTableView: NSViewRepresentable {
items: items,
mode: mode,
isReorderEnabled: isReorderEnabled,
+ highlightedPluginID: highlightedPluginID,
contentWidth: contentWidth
)
@@ -153,16 +156,53 @@ struct FeatureManagementTableView: NSViewRepresentable {
return
}
+ let shouldMoveAccessibilityFocus =
+ coordinator.lastSignature?.highlightedPluginID != highlightedPluginID
coordinator.lastSignature = signature
tableView.reloadData()
tableView.noteNumberOfRowsChanged()
-
tableView.frame = NSRect(x: 0, y: 0, width: contentWidth, height: contentHeight)
+ updateSearchHighlight(
+ in: tableView,
+ shouldMoveAccessibilityFocus: shouldMoveAccessibilityFocus
+ )
+
scrollView.contentView.scroll(to: .zero)
scrollView.reflectScrolledClipView(scrollView.contentView)
}
+ private func updateSearchHighlight(
+ in tableView: NSTableView,
+ shouldMoveAccessibilityFocus: Bool
+ ) {
+ guard
+ let highlightedPluginID,
+ let row = items.firstIndex(where: { $0.id == highlightedPluginID })
+ else {
+ tableView.deselectAll(nil)
+ return
+ }
+
+ tableView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false)
+ guard shouldMoveAccessibilityFocus else {
+ return
+ }
+
+ NSAccessibility.post(
+ element: tableView,
+ notification: .selectedRowsChanged
+ )
+ guard let cell = tableView.view(atColumn: 0, row: row, makeIfNecessary: true) else {
+ return
+ }
+
+ NSAccessibility.post(
+ element: cell,
+ notification: .focusedUIElementChanged
+ )
+ }
+
@MainActor
final class Coordinator: NSObject, NSTableViewDataSource, NSTableViewDelegate {
var parent: FeatureManagementTableView
@@ -200,6 +240,7 @@ struct FeatureManagementTableView: NSViewRepresentable {
item: item,
mode: parent.mode,
showsHandle: parent.mode.supportsReordering && parent.isReorderEnabled,
+ isSearchHighlighted: item.id == parent.highlightedPluginID,
onSetVisible: { [weak self] isVisible in
self?.parent.onSetVisible(item.id, isVisible)
},
@@ -356,12 +397,14 @@ fileprivate struct FeatureManagementTableSignature: Equatable {
let rows: [Row]
let mode: FeatureManagementTableMode
let isReorderEnabled: Bool
+ let highlightedPluginID: String?
let contentWidth: CGFloat
init(
items: [FeatureManagementTableItem],
mode: FeatureManagementTableMode,
isReorderEnabled: Bool,
+ highlightedPluginID: String?,
contentWidth: CGFloat
) {
self.rows = items.map {
@@ -381,6 +424,7 @@ fileprivate struct FeatureManagementTableSignature: Equatable {
}
self.mode = mode
self.isReorderEnabled = isReorderEnabled
+ self.highlightedPluginID = highlightedPluginID
self.contentWidth = contentWidth.rounded(.toNearestOrAwayFromZero)
}
}
@@ -394,6 +438,8 @@ enum FeatureManagementTableUpdatePolicy {
currentMode: FeatureManagementTableMode,
previousIsReorderEnabled: Bool,
currentIsReorderEnabled: Bool,
+ previousHighlightedPluginID: String? = nil,
+ currentHighlightedPluginID: String? = nil,
previousContentWidth: CGFloat,
currentContentWidth: CGFloat
) -> Bool {
@@ -401,11 +447,13 @@ enum FeatureManagementTableUpdatePolicy {
items: previousItems,
mode: previousMode,
isReorderEnabled: previousIsReorderEnabled,
+ highlightedPluginID: previousHighlightedPluginID,
contentWidth: previousContentWidth
) != FeatureManagementTableSignature(
items: currentItems,
mode: currentMode,
isReorderEnabled: currentIsReorderEnabled,
+ highlightedPluginID: currentHighlightedPluginID,
contentWidth: currentContentWidth
)
}
@@ -647,6 +695,7 @@ private final class FeatureManagementTableCellView: NSTableCellView {
item: FeatureManagementTableItem,
mode: FeatureManagementTableMode,
showsHandle: Bool,
+ isSearchHighlighted: Bool = false,
onSetVisible: @escaping (Bool) -> Void,
onOpenSettings: @escaping () -> Void,
onOpenMarketplace: @escaping () -> Void,
@@ -681,6 +730,10 @@ private final class FeatureManagementTableCellView: NSTableCellView {
configureActions()
handleImageView.isHidden = !showsHandle
containerView.alphaValue = 1
+ containerView.layer?.borderWidth = isSearchHighlighted ? 2 : 0
+ containerView.layer?.borderColor = isSearchHighlighted
+ ? NSColor.controlAccentColor.cgColor
+ : NSColor.clear.cgColor
toolTip = item.title
iconActionButton.toolTip = hasSettings
? AppL10n.pluginsFormat(
diff --git a/Sources/App/MacToolsSearch.swift b/Sources/App/MacToolsSearch.swift
new file mode 100644
index 00000000..9f6e4889
--- /dev/null
+++ b/Sources/App/MacToolsSearch.swift
@@ -0,0 +1,818 @@
+import Foundation
+import MacToolsPluginKit
+
+enum MacToolsSearchResultKind: CaseIterable, Hashable {
+ case navigation
+ case setting
+ case command
+
+ var title: String {
+ switch self {
+ case .navigation:
+ return AppL10n.search("search.group.navigation", defaultValue: "导航")
+ case .setting:
+ return AppL10n.search("search.group.settings", defaultValue: "设置")
+ case .command:
+ return AppL10n.search("search.group.commands", defaultValue: "命令")
+ }
+ }
+
+ var actionTitle: String {
+ switch self {
+ case .navigation:
+ return AppL10n.search("search.action.open", defaultValue: "打开")
+ case .setting:
+ return AppL10n.search("search.action.goTo", defaultValue: "前往")
+ case .command:
+ return AppL10n.search("search.action.run", defaultValue: "执行")
+ }
+ }
+}
+
+enum MacToolsSearchAction: Hashable {
+ case navigate(
+ destination: SettingsNavigationDestination,
+ target: SettingsSearchRevealTarget?
+ )
+ case pluginCommand(
+ pluginID: String,
+ expectedDefinition: PluginCommandDefinition
+ )
+ case appCommand(AppShortcutAction)
+}
+
+struct MacToolsSearchResult: Identifiable, Hashable {
+ let id: String
+ let kind: MacToolsSearchResultKind
+ let title: String
+ let subtitle: String
+ let detail: String
+ let keywords: [String]
+ let systemImage: String
+ let action: MacToolsSearchAction
+ let confirmation: PluginCommandDefinition.Confirmation?
+ let suggestionPriority: Int?
+
+ var accessibilityLabel: String {
+ [title, subtitle, kind.title].filter { !$0.isEmpty }.joined(separator: ", ")
+ }
+
+ fileprivate var normalizedTitle: String {
+ Self.normalize(title)
+ }
+
+ fileprivate var normalizedSubtitle: String {
+ Self.normalize(subtitle)
+ }
+
+ fileprivate var normalizedDetail: String {
+ Self.normalize(detail)
+ }
+
+ fileprivate var normalizedKeywords: String {
+ Self.normalize(keywords.joined(separator: " "))
+ }
+
+ fileprivate static func normalize(_ value: String) -> String {
+ value
+ .folding(options: [.caseInsensitive, .diacriticInsensitive, .widthInsensitive], locale: .current)
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+}
+
+struct MacToolsSearchIndex {
+ let items: [MacToolsSearchResult]
+ private let indexedItems: [IndexedItem]
+
+ init(items: [MacToolsSearchResult]) {
+ self.items = items
+ self.indexedItems = items.map(IndexedItem.init)
+ }
+
+ func results(matching query: String) -> [MacToolsSearchResult] {
+ let normalizedQuery = MacToolsSearchResult.normalize(query)
+ guard !normalizedQuery.isEmpty else {
+ return items
+ .filter { $0.suggestionPriority != nil }
+ .sorted(by: suggestionOrder)
+ }
+
+ let tokens = normalizedQuery
+ .split(whereSeparator: \.isWhitespace)
+ .map(String.init)
+
+ return indexedItems
+ .compactMap { item -> (IndexedItem, Int)? in
+ guard tokens.allSatisfy(item.haystack.contains) else {
+ return nil
+ }
+
+ return (item, score(item, query: normalizedQuery, tokens: tokens))
+ }
+ .sorted { lhs, rhs in
+ if lhs.1 != rhs.1 {
+ return lhs.1 > rhs.1
+ }
+
+ if lhs.0.result.kind != rhs.0.result.kind {
+ return kindOrder(lhs.0.result.kind) < kindOrder(rhs.0.result.kind)
+ }
+
+ return lhs.0.result.title.localizedStandardCompare(rhs.0.result.title) == .orderedAscending
+ }
+ .map(\.0.result)
+ }
+
+ private func score(
+ _ item: IndexedItem,
+ query: String,
+ tokens: [String]
+ ) -> Int {
+ var score = 0
+
+ if item.normalizedTitle == query {
+ score += 1_000
+ } else if item.normalizedTitle.hasPrefix(query) {
+ score += 700
+ } else if item.normalizedTitle.contains(query) {
+ score += 500
+ }
+
+ for token in tokens {
+ if item.normalizedTitle.contains(token) {
+ score += 180
+ }
+ if item.normalizedSubtitle.contains(token) {
+ score += 90
+ }
+ if item.normalizedDetail.contains(token) {
+ score += 50
+ }
+ if item.normalizedKeywords.contains(token) {
+ score += 30
+ }
+ }
+
+ if item.result.kind == .navigation {
+ score += 10
+ }
+
+ return score
+ }
+
+ private func suggestionOrder(
+ _ lhs: MacToolsSearchResult,
+ _ rhs: MacToolsSearchResult
+ ) -> Bool {
+ let lhsPriority = lhs.suggestionPriority ?? .max
+ let rhsPriority = rhs.suggestionPriority ?? .max
+ if lhsPriority != rhsPriority {
+ return lhsPriority < rhsPriority
+ }
+
+ return lhs.title.localizedStandardCompare(rhs.title) == .orderedAscending
+ }
+
+ private func kindOrder(_ kind: MacToolsSearchResultKind) -> Int {
+ switch kind {
+ case .navigation: 0
+ case .setting: 1
+ case .command: 2
+ }
+ }
+
+ private struct IndexedItem {
+ let result: MacToolsSearchResult
+ let normalizedTitle: String
+ let normalizedSubtitle: String
+ let normalizedDetail: String
+ let normalizedKeywords: String
+ let haystack: String
+
+ init(result: MacToolsSearchResult) {
+ self.result = result
+ normalizedTitle = result.normalizedTitle
+ normalizedSubtitle = result.normalizedSubtitle
+ normalizedDetail = result.normalizedDetail
+ normalizedKeywords = result.normalizedKeywords
+ haystack = [
+ normalizedTitle,
+ normalizedSubtitle,
+ normalizedDetail,
+ normalizedKeywords
+ ].joined(separator: " ")
+ }
+ }
+}
+
+enum MacToolsSearchPresentation {
+ static let quickSelectionLimit = 9
+
+ static func orderedResults(
+ _ results: [MacToolsSearchResult]
+ ) -> [MacToolsSearchResult] {
+ MacToolsSearchResultKind.allCases.flatMap { kind in
+ results.filter { $0.kind == kind }
+ }
+ }
+
+ static func quickSelectionNumber(
+ for resultID: String,
+ in results: [MacToolsSearchResult]
+ ) -> Int? {
+ guard
+ let index = results.prefix(quickSelectionLimit)
+ .firstIndex(where: { $0.id == resultID })
+ else {
+ return nil
+ }
+
+ return results.distance(from: results.startIndex, to: index) + 1
+ }
+}
+
+@MainActor
+enum MacToolsSearchIndexBuilder {
+ static func build(pluginHost: PluginHost) -> MacToolsSearchIndex {
+ var items: [MacToolsSearchResult] = [
+ navigationResult(
+ id: "navigation.general",
+ title: AppL10n.settings("tab.general", defaultValue: "通用"),
+ subtitle: AppL10n.search("search.subtitle.appSettings", defaultValue: "应用设置"),
+ detail: AppL10n.search(
+ "search.detail.general",
+ defaultValue: "外观、语言、菜单栏图标、登录启动和偏好备份。"
+ ),
+ systemImage: "gearshape",
+ destination: .general,
+ suggestionPriority: 3
+ ),
+ navigationResult(
+ id: "navigation.dashboard",
+ title: AppL10n.settings("plugins.sidebar.dashboard", defaultValue: "仪表盘"),
+ subtitle: AppL10n.search("search.subtitle.plugins", defaultValue: "插件"),
+ detail: AppL10n.settings(
+ "plugins.dashboard.description",
+ defaultValue: "拖拽调整仪表盘组件的排列顺序。"
+ ),
+ systemImage: "square.grid.2x2",
+ destination: .plugins(.dashboardLayout),
+ suggestionPriority: 0
+ ),
+ navigationResult(
+ id: "navigation.feature-panel",
+ title: AppL10n.settings("plugins.sidebar.featurePanel", defaultValue: "功能面板"),
+ subtitle: AppL10n.search("search.subtitle.plugins", defaultValue: "插件"),
+ detail: AppL10n.settings(
+ "plugins.featurePanel.description",
+ defaultValue: "拖拽调整功能面板操作的排列顺序。"
+ ),
+ systemImage: "switch.2",
+ destination: .plugins(.featurePanelLayout),
+ suggestionPriority: 1
+ ),
+ navigationResult(
+ id: "navigation.marketplace",
+ title: AppL10n.settings("plugins.sidebar.marketplace", defaultValue: "市场"),
+ subtitle: AppL10n.search("search.subtitle.plugins", defaultValue: "插件"),
+ detail: AppL10n.plugins(
+ "plugin.marketplace.description",
+ defaultValue: "安装、更新和管理 MacTools 插件。"
+ ),
+ systemImage: "shippingbox",
+ destination: .plugins(.marketplace),
+ suggestionPriority: 2
+ ),
+ navigationResult(
+ id: "navigation.about",
+ title: AppL10n.settings("tab.about", defaultValue: "关于"),
+ subtitle: AppL10n.search("search.subtitle.appSettings", defaultValue: "应用设置"),
+ detail: AppL10n.search(
+ "search.detail.about",
+ defaultValue: "版本、更新和项目链接。"
+ ),
+ systemImage: "info.circle",
+ destination: .about,
+ suggestionPriority: 4
+ )
+ ]
+
+ items += generalSettingsResults(pluginHost: pluginHost)
+
+ let managementItemsByID = Dictionary(
+ uniqueKeysWithValues: pluginHost.pluginManagementItems.map { ($0.id, $0) }
+ )
+ let configurationItemsByID = Dictionary(
+ uniqueKeysWithValues: pluginHost.pluginConfigurationItems.map { ($0.pluginID, $0) }
+ )
+
+ items += pluginHost.pluginConfigurationItems.map { item in
+ let managementItem = managementItemsByID[item.pluginID]
+ return MacToolsSearchResult(
+ id: "plugin.\(item.pluginID)",
+ kind: .navigation,
+ title: item.title,
+ subtitle: AppL10n.settings(
+ "plugins.sidebar.configurationSection",
+ defaultValue: "插件设置"
+ ),
+ detail: item.description,
+ keywords: pluginMetadataKeywords(
+ pluginID: item.pluginID,
+ category: managementItem?.category,
+ releaseChannel: managementItem?.releaseChannel
+ ),
+ systemImage: item.iconName,
+ action: .navigate(
+ destination: .plugins(.configuration(item.pluginID)),
+ target: nil
+ ),
+ confirmation: nil,
+ suggestionPriority: nil
+ )
+ }
+
+ let surfaceItems = mergedSurfaceItems(pluginHost: pluginHost)
+ items += surfaceItems.compactMap { item -> MacToolsSearchResult? in
+ guard configurationItemsByID[item.id] == nil else {
+ return nil
+ }
+
+ let destination: SettingsNavigationDestination
+ let surface: PluginDisplaySurface
+ let subtitle: String
+ if item.capabilities.supportsFeaturePanel {
+ surface = .featurePanel
+ destination = .plugins(.featurePanelLayout)
+ subtitle = AppL10n.settings(
+ "plugins.sidebar.featurePanel",
+ defaultValue: "功能面板"
+ )
+ } else if item.capabilities.supportsDashboard {
+ surface = .dashboard
+ destination = .plugins(.dashboardLayout)
+ subtitle = AppL10n.settings(
+ "plugins.sidebar.dashboard",
+ defaultValue: "仪表盘"
+ )
+ } else {
+ return nil
+ }
+
+ return MacToolsSearchResult(
+ id: "plugin.\(item.id)",
+ kind: .navigation,
+ title: item.title,
+ subtitle: subtitle,
+ detail: item.description,
+ keywords: pluginMetadataKeywords(
+ pluginID: item.id,
+ category: item.category,
+ releaseChannel: item.releaseChannel
+ ),
+ systemImage: item.iconName,
+ action: .navigate(
+ destination: destination,
+ target: .surface(
+ SurfaceSettingsSearchTarget(
+ surface: surface,
+ pluginID: item.id
+ )
+ )
+ ),
+ confirmation: nil,
+ suggestionPriority: nil
+ )
+ }
+
+ items += pluginHost.pluginManagementItems.compactMap { item in
+ guard item.canUninstall else {
+ return nil
+ }
+
+ return MacToolsSearchResult(
+ id: "plugin.\(item.id)",
+ kind: .navigation,
+ title: item.title,
+ subtitle: AppL10n.settings(
+ "plugins.sidebar.marketplace",
+ defaultValue: "市场"
+ ),
+ detail: item.detailText,
+ keywords: pluginMetadataKeywords(
+ pluginID: item.id,
+ category: item.category,
+ releaseChannel: item.releaseChannel
+ ) + [item.statusText, item.version] + [item.summary].compactMap { $0 },
+ systemImage: "shippingbox",
+ action: .navigate(
+ destination: .plugins(.marketplace),
+ target: .marketplace(
+ MarketplacePluginSearchTarget(pluginID: item.id)
+ )
+ ),
+ confirmation: nil,
+ suggestionPriority: nil
+ )
+ }
+
+ items += pluginHost.pluginConfigurationItems.flatMap { item in
+ settingResults(for: item)
+ }
+
+ items += pluginHost.pluginSettingsSearchItems.compactMap { providedItem in
+ guard
+ let configuration = configurationItemsByID[providedItem.pluginID],
+ configuration.hasCustomConfiguration
+ else {
+ return nil
+ }
+
+ let entry = providedItem.entry
+ return MacToolsSearchResult(
+ id: providedItem.id,
+ kind: .setting,
+ title: entry.title,
+ subtitle: "\(configuration.title) › \(AppL10n.search("search.subtitle.customSetting", defaultValue: "设置"))",
+ detail: entry.description,
+ keywords: entry.keywords,
+ systemImage: entry.systemImage,
+ action: .navigate(
+ destination: .plugins(.configuration(providedItem.pluginID)),
+ target: .plugin(
+ PluginSettingsSearchTarget(
+ pluginID: providedItem.pluginID,
+ entryID: entry.id
+ )
+ )
+ ),
+ confirmation: nil,
+ suggestionPriority: nil
+ )
+ }
+
+ items += pluginHost.pluginCommandItems.map { item in
+ MacToolsSearchResult(
+ id: item.id,
+ kind: .command,
+ title: item.definition.title,
+ subtitle: item.pluginTitle,
+ detail: item.definition.description,
+ keywords: item.definition.keywords,
+ systemImage: item.definition.systemImage,
+ action: .pluginCommand(
+ pluginID: item.pluginID,
+ expectedDefinition: item.definition
+ ),
+ confirmation: item.definition.confirmation,
+ suggestionPriority: nil
+ )
+ }
+
+ items += AppShortcutAction.allCases.compactMap { action -> MacToolsSearchResult? in
+ guard action != .openSettings else {
+ return nil
+ }
+
+ return MacToolsSearchResult(
+ id: "app-command.\(action.rawValue)",
+ kind: .command,
+ title: action.title,
+ subtitle: AppL10n.search("search.subtitle.macTools", defaultValue: "MacTools"),
+ detail: action.description,
+ keywords: [],
+ systemImage: action.systemImage,
+ action: .appCommand(action),
+ confirmation: nil,
+ suggestionPriority: nil
+ )
+ }
+
+ return MacToolsSearchIndex(items: deduplicated(items))
+ }
+
+ private static func navigationResult(
+ id: String,
+ title: String,
+ subtitle: String,
+ detail: String,
+ systemImage: String,
+ destination: SettingsNavigationDestination,
+ suggestionPriority: Int
+ ) -> MacToolsSearchResult {
+ MacToolsSearchResult(
+ id: id,
+ kind: .navigation,
+ title: title,
+ subtitle: subtitle,
+ detail: detail,
+ keywords: [],
+ systemImage: systemImage,
+ action: .navigate(destination: destination, target: nil),
+ confirmation: nil,
+ suggestionPriority: suggestionPriority
+ )
+ }
+
+ private static func generalSettingsResults(
+ pluginHost: PluginHost
+ ) -> [MacToolsSearchResult] {
+ let shortcutKeywords = pluginHost.appShortcutItems.flatMap { item in
+ [item.title, item.description, item.bindingText]
+ }
+
+ return [
+ generalSettingResult(
+ target: .launchAtLogin,
+ title: AppL10n.settings("launchAtLogin.title", defaultValue: "开机时启动"),
+ detail: AppL10n.settings(
+ "launchAtLogin.description",
+ defaultValue: "登录系统时自动启动 MacTools 并显示在菜单栏。"
+ ),
+ keywords: [
+ AppL10n.settings("general.section.startup", defaultValue: "启动")
+ ],
+ systemImage: "power"
+ ),
+ generalSettingResult(
+ target: .appearance,
+ title: AppL10n.settings("appearance.title", defaultValue: "应用外观"),
+ detail: AppL10n.settings(
+ "appearance.description",
+ defaultValue: "自动跟随系统,也可以固定为深色或浅色。"
+ ),
+ keywords: AppAppearancePreference.allCases.map(\.title),
+ systemImage: "circle.lefthalf.filled"
+ ),
+ generalSettingResult(
+ target: .language,
+ title: AppL10n.settings("language.title", defaultValue: "语言"),
+ detail: AppL10n.settings(
+ "language.description",
+ defaultValue: "默认跟随系统语言,也可以固定为指定语言。"
+ ),
+ keywords: AppLanguagePreference.allCases.map(\.pickerTitle),
+ systemImage: "globe"
+ ),
+ generalSettingResult(
+ target: .menuBarIcon,
+ title: AppL10n.settings("menuBarIcon.title", defaultValue: "菜单栏图标"),
+ detail: AppL10n.settings(
+ "menuBarIcon.description",
+ defaultValue: "统一设置浅色和深色菜单栏图标,导入时会保留原图。"
+ ),
+ keywords: [
+ AppL10n.settings("menuBarIcon.restoreDefault", defaultValue: "恢复默认")
+ ],
+ systemImage: "menubar.rectangle"
+ ),
+ generalSettingResult(
+ target: .menuBarClickBehavior,
+ title: AppL10n.settings("menuBarClick.title", defaultValue: "交换左键与右键功能"),
+ detail: AppL10n.settings(
+ "menuBarClick.description",
+ defaultValue: "关闭时左键打开仪表盘、右键功能打开功能面板;开启后互换。"
+ ),
+ keywords: [
+ AppL10n.settings("general.section.menuBarIcon", defaultValue: "状态栏图标"),
+ AppL10n.settings(
+ "menuBarClick.rightClickShortcutNotice",
+ defaultValue: "可以使用 Option + 左键触发右键功能。"
+ )
+ ],
+ systemImage: "cursorarrow.click.2"
+ ),
+ generalSettingResult(
+ target: .appShortcuts,
+ title: AppL10n.settings("shortcuts.title", defaultValue: "键盘快捷键"),
+ detail: AppL10n.settings(
+ "shortcuts.description",
+ defaultValue: "为常用动作配置全局快捷键。编辑后立即生效,必要项不可删除。"
+ ),
+ keywords: shortcutKeywords,
+ systemImage: "command"
+ ),
+ generalSettingResult(
+ target: .preferencesBackup,
+ title: AppL10n.preferencesBackup(
+ "preferencesBackup.title",
+ defaultValue: "导出与导入偏好设置"
+ ),
+ detail: AppL10n.preferencesBackup(
+ "preferencesBackup.description",
+ defaultValue: "包含应用偏好、插件显示顺序、快捷键和支持导出的插件设置;不会包含权限、缓存、凭证或其他私有数据。"
+ ),
+ keywords: [
+ AppL10n.preferencesBackup(
+ "preferencesBackup.export",
+ defaultValue: "导出偏好设置…"
+ ),
+ AppL10n.preferencesBackup(
+ "preferencesBackup.import",
+ defaultValue: "导入偏好设置…"
+ )
+ ],
+ systemImage: "externaldrive.badge.checkmark"
+ )
+ ]
+ }
+
+ private static func generalSettingResult(
+ target: GeneralSettingsSearchTarget,
+ title: String,
+ detail: String,
+ keywords: [String],
+ systemImage: String
+ ) -> MacToolsSearchResult {
+ MacToolsSearchResult(
+ id: "general-setting.\(target.rawValue)",
+ kind: .setting,
+ title: title,
+ subtitle: AppL10n.settings("tab.general", defaultValue: "通用"),
+ detail: detail,
+ keywords: keywords,
+ systemImage: systemImage,
+ action: .navigate(destination: .general, target: .general(target)),
+ confirmation: nil,
+ suggestionPriority: nil
+ )
+ }
+
+ private static func settingResults(
+ for item: PluginConfigurationItem
+ ) -> [MacToolsSearchResult] {
+ let settings = item.settingsCards.map { card in
+ settingResult(
+ id: "setting-card.\(card.id)",
+ item: item,
+ title: card.title,
+ detail: card.description,
+ keywords: [card.statusText, card.footnote].compactMap { $0 },
+ systemImage: card.statusSystemImage,
+ entryID: card.id
+ )
+ }
+
+ let permissions = item.permissionCards.map { card in
+ settingResult(
+ id: "permission.\(card.id)",
+ item: item,
+ title: card.title,
+ detail: card.description,
+ keywords: [
+ AppL10n.settings(
+ "plugins.configuration.section.permissions",
+ defaultValue: "权限"
+ ),
+ card.statusText,
+ card.footnote
+ ].compactMap { $0 },
+ systemImage: card.iconSystemImage,
+ entryID: card.id
+ )
+ }
+
+ let shortcuts = shortcutSettingResults(for: item)
+
+ return settings + permissions + shortcuts
+ }
+
+ private static func shortcutSettingResults(
+ for item: PluginConfigurationItem
+ ) -> [MacToolsSearchResult] {
+ guard item.shortcutItems.allSatisfy({ $0.settingsGroupID != nil }) else {
+ return item.shortcutItems.map { shortcut in
+ settingResult(
+ id: "shortcut.\(shortcut.id)",
+ item: item,
+ title: shortcut.settingsControlTitle ?? shortcut.title,
+ detail: shortcut.description,
+ keywords: [
+ AppL10n.settings(
+ "plugins.configuration.section.shortcuts",
+ defaultValue: "快捷键"
+ ),
+ shortcut.bindingText
+ ],
+ systemImage: shortcut.settingsControlSystemImage ?? "command",
+ entryID: shortcut.id
+ )
+ }
+ }
+
+ var groupOrder: [String] = []
+ var groups: [String: [ShortcutSettingsItem]] = [:]
+ for shortcut in item.shortcutItems {
+ guard let groupID = shortcut.settingsGroupID else {
+ continue
+ }
+
+ if groups[groupID] == nil {
+ groupOrder.append(groupID)
+ }
+ groups[groupID, default: []].append(shortcut)
+ }
+
+ return groupOrder.compactMap { groupID in
+ guard let shortcuts = groups[groupID], let first = shortcuts.first else {
+ return nil
+ }
+
+ return settingResult(
+ id: "shortcut-group.\(item.pluginID).\(groupID)",
+ item: item,
+ title: first.settingsGroupTitle ?? first.title,
+ detail: first.settingsGroupDescription ?? first.description,
+ keywords: [
+ AppL10n.settings(
+ "plugins.configuration.section.shortcuts",
+ defaultValue: "快捷键"
+ )
+ ] + shortcuts.flatMap {
+ [$0.settingsControlTitle, $0.title, $0.bindingText].compactMap { $0 }
+ },
+ systemImage: first.settingsControlSystemImage ?? "command",
+ entryID: groupID
+ )
+ }
+ }
+
+ private static func settingResult(
+ id: String,
+ item: PluginConfigurationItem,
+ title: String,
+ detail: String,
+ keywords: [String],
+ systemImage: String,
+ entryID: String
+ ) -> MacToolsSearchResult {
+ MacToolsSearchResult(
+ id: id,
+ kind: .setting,
+ title: title,
+ subtitle: item.title,
+ detail: detail,
+ keywords: keywords,
+ systemImage: systemImage,
+ action: .navigate(
+ destination: .plugins(.configuration(item.pluginID)),
+ target: .plugin(
+ PluginSettingsSearchTarget(
+ pluginID: item.pluginID,
+ entryID: entryID
+ )
+ )
+ ),
+ confirmation: nil,
+ suggestionPriority: nil
+ )
+ }
+
+ private static func mergedSurfaceItems(pluginHost: PluginHost) -> [PluginSurfaceLayoutItem] {
+ var seenIDs: Set = []
+ return (
+ pluginHost.featurePanelLayoutItems
+ + pluginHost.featurePanelHiddenLayoutItems
+ + pluginHost.dashboardLayoutItems
+ + pluginHost.dashboardHiddenLayoutItems
+ ).filter { seenIDs.insert($0.id).inserted }
+ }
+
+ static func pluginMetadataKeywords(
+ pluginID: String,
+ category: String?,
+ releaseChannel: String?
+ ) -> [String] {
+ var keywords = [pluginID]
+
+ if let category = nonEmptyMetadataValue(category) {
+ keywords.append(category)
+ keywords.append(PluginCategory(rawString: category).displayName)
+ }
+
+ if let releaseChannel = nonEmptyMetadataValue(releaseChannel) {
+ keywords.append(releaseChannel)
+ if let channel = PluginReleaseChannel(rawString: releaseChannel) {
+ keywords.append(channel.displayName)
+ }
+ }
+
+ return keywords
+ }
+
+ private static func nonEmptyMetadataValue(_ value: String?) -> String? {
+ guard let value else {
+ return nil
+ }
+
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ return trimmed.isEmpty ? nil : trimmed
+ }
+
+ private static func deduplicated(
+ _ items: [MacToolsSearchResult]
+ ) -> [MacToolsSearchResult] {
+ var seenIDs: Set = []
+ return items.filter { seenIDs.insert($0.id).inserted }
+ }
+}
diff --git a/Sources/App/MenuBarPanelPresenter.swift b/Sources/App/MenuBarPanelPresenter.swift
index d7293102..f0701ecd 100644
--- a/Sources/App/MenuBarPanelPresenter.swift
+++ b/Sources/App/MenuBarPanelPresenter.swift
@@ -59,6 +59,7 @@ enum MenuBarPanelWindowRegistry {
enum MenuBarPanelKeyboardAction: Equatable {
case dismissPanel
case showSettings
+ case showUnifiedSearch
case selectTab(MenuBarPanelTab)
@MainActor
@@ -74,6 +75,10 @@ enum MenuBarPanelKeyboardAction: Equatable {
return .showSettings
}
+ if MacToolsLocalKeyboardCommand.resolve(for: event) == .showUnifiedSearch {
+ return .showUnifiedSearch
+ }
+
guard let tab = MenuBarPanelPresenter.keyboardShortcutTab(for: event) else {
return nil
}
@@ -127,6 +132,7 @@ final class MenuBarPanelPresenter: NSObject {
private let onDismiss: () -> Void
private let onOpenUpdate: () -> Void
private let onOpenSettings: () -> Void
+ private let onOpenUnifiedSearch: () -> Void
private let onPresentDiskCleanConfiguration: () -> Void
private let onPresentLaunchControlConfiguration: () -> Void
private let onAllPanelsClosed: () -> Void
@@ -146,6 +152,7 @@ final class MenuBarPanelPresenter: NSObject {
onDismiss: @escaping () -> Void,
onOpenUpdate: @escaping () -> Void,
onOpenSettings: @escaping () -> Void,
+ onOpenUnifiedSearch: @escaping () -> Void,
onPresentDiskCleanConfiguration: @escaping () -> Void,
onPresentLaunchControlConfiguration: @escaping () -> Void,
onAllPanelsClosed: @escaping () -> Void
@@ -155,6 +162,7 @@ final class MenuBarPanelPresenter: NSObject {
self.onDismiss = onDismiss
self.onOpenUpdate = onOpenUpdate
self.onOpenSettings = onOpenSettings
+ self.onOpenUnifiedSearch = onOpenUnifiedSearch
self.onPresentDiskCleanConfiguration = onPresentDiskCleanConfiguration
self.onPresentLaunchControlConfiguration = onPresentLaunchControlConfiguration
self.onAllPanelsClosed = onAllPanelsClosed
@@ -401,6 +409,8 @@ final class MenuBarPanelPresenter: NSObject {
onDismiss()
case .showSettings:
onOpenSettings()
+ case .showUnifiedSearch:
+ onOpenUnifiedSearch()
case let .selectTab(tab):
select(tab)
}
diff --git a/Sources/App/MenuBarStatusItemController.swift b/Sources/App/MenuBarStatusItemController.swift
index c01b84bd..57fc0900 100644
--- a/Sources/App/MenuBarStatusItemController.swift
+++ b/Sources/App/MenuBarStatusItemController.swift
@@ -104,6 +104,9 @@ final class MenuBarStatusItemController: NSObject {
onOpenSettings: { [weak self] in
self?.windowRouter.showSettings()
},
+ onOpenUnifiedSearch: { [weak self] in
+ self?.windowRouter.showUnifiedSearch()
+ },
onPresentDiskCleanConfiguration: { [weak self] in
self?.pluginHost.presentPluginConfiguration(pluginID: "disk-clean")
},
diff --git a/Sources/App/PluginManagementSettingsView.swift b/Sources/App/PluginManagementSettingsView.swift
index 24696456..ca8b0383 100644
--- a/Sources/App/PluginManagementSettingsView.swift
+++ b/Sources/App/PluginManagementSettingsView.swift
@@ -17,6 +17,8 @@ struct PluginManagementSettingsView: View {
@State private var bulkUpdateProgressText: String?
@State private var bulkUpdateProgressOpacity: Double = 0
@State private var bulkUpdateProgressHideTask: Task?
+ @State private var activeSearchTarget: MarketplacePluginSearchTarget?
+ @State private var clearSearchTargetTask: Task?
var body: some View {
VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.section) {
@@ -48,34 +50,52 @@ struct PluginManagementSettingsView: View {
marketplaceSortPicker
}
- ScrollView {
- LazyVStack(alignment: .leading, spacing: 10) {
- if filteredItems.isEmpty {
- ContentUnavailableView(
- AppL10n.plugins("plugin.filter.empty.title", defaultValue: "未找到匹配的插件"),
- systemImage: "magnifyingglass",
- description: Text(AppL10n.plugins("plugin.filter.empty.description", defaultValue: "尝试调整关键字或切换分类。"))
- )
- .frame(maxWidth: .infinity, minHeight: 180)
- } else {
- ForEach(filteredItems) { item in
- PluginManagementRow(
- item: item,
- hasSettings: configurationPluginIDs.contains(item.id),
- isBusy: activeOperationID == item.id
- || pluginHost.automaticPluginUpdateStatus.isUpdatingPlugin(id: item.id),
- isInteractionDisabled: activeOperationID != nil
- || pluginHost.automaticPluginUpdateStatus.isActive,
- onInstall: { runOperation(id: item.id) { try await pluginHost.installPluginFromCatalog(pluginID: item.id) } },
- onUpdate: { runOperation(id: item.id) { try await pluginHost.updatePluginFromCatalog(pluginID: item.id) } },
- onUninstall: { requestUninstall(item) },
- onOpenSettings: { pluginHost.presentPluginConfiguration(pluginID: item.id) },
- onRelaunch: { appRelauncher.relaunch() }
+ ScrollViewReader { proxy in
+ ScrollView {
+ LazyVStack(alignment: .leading, spacing: 10) {
+ if filteredItems.isEmpty {
+ ContentUnavailableView(
+ AppL10n.plugins("plugin.filter.empty.title", defaultValue: "未找到匹配的插件"),
+ systemImage: "magnifyingglass",
+ description: Text(AppL10n.plugins("plugin.filter.empty.description", defaultValue: "尝试调整关键字或切换分类。"))
)
+ .frame(maxWidth: .infinity, minHeight: 180)
+ } else {
+ ForEach(filteredItems) { item in
+ PluginManagementRow(
+ item: item,
+ hasSettings: configurationPluginIDs.contains(item.id),
+ isBusy: activeOperationID == item.id
+ || pluginHost.automaticPluginUpdateStatus.isUpdatingPlugin(id: item.id),
+ isInteractionDisabled: activeOperationID != nil
+ || pluginHost.automaticPluginUpdateStatus.isActive,
+ onInstall: { runOperation(id: item.id) { try await pluginHost.installPluginFromCatalog(pluginID: item.id) } },
+ onUpdate: { runOperation(id: item.id) { try await pluginHost.updatePluginFromCatalog(pluginID: item.id) } },
+ onUninstall: { requestUninstall(item) },
+ onOpenSettings: { pluginHost.presentPluginConfiguration(pluginID: item.id) },
+ onRelaunch: { appRelauncher.relaunch() }
+ )
+ .marketplaceSearchAnchor(
+ target: MarketplacePluginSearchTarget(
+ pluginID: item.id
+ ),
+ activeTarget: activeSearchTarget
+ )
+ }
}
}
+ .padding(.vertical, 2)
+ }
+ .onAppear {
+ applySearchRevealRequest(
+ navigationCoordinator.searchRevealRequest,
+ proxy: proxy
+ )
+ }
+ .onChange(of: navigationCoordinator.searchRevealRequest) {
+ _, request in
+ applySearchRevealRequest(request, proxy: proxy)
}
- .padding(.vertical, 2)
}
}
}
@@ -117,6 +137,13 @@ struct PluginManagementSettingsView: View {
}
.onDisappear {
navigationCoordinator.setSearchField(.pluginMarketplace, focused: false)
+ clearSearchTargetTask?.cancel()
+ if let activeSearchTarget {
+ navigationCoordinator.clearSearchRevealRequest(
+ matching: .marketplace(activeSearchTarget)
+ )
+ }
+ activeSearchTarget = nil
}
.onChange(of: navigationCoordinator.searchFocusRequest) { _, request in
applySearchFocusRequest(request)
@@ -129,6 +156,22 @@ struct PluginManagementSettingsView: View {
isSearchFocused = false
}
}
+ .onChange(of: pluginHost.pluginManagementItems) { _, items in
+ guard
+ let activeSearchTarget,
+ !items.contains(where: {
+ $0.id == activeSearchTarget.pluginID && $0.canUninstall
+ })
+ else {
+ return
+ }
+
+ clearSearchTargetTask?.cancel()
+ self.activeSearchTarget = nil
+ navigationCoordinator.clearSearchRevealRequest(
+ matching: .marketplace(activeSearchTarget)
+ )
+ }
.onChange(of: pluginHost.automaticPluginUpdateStatus) { _, status in
syncAutomaticBulkUpdateProgress(status)
}
@@ -146,6 +189,47 @@ struct PluginManagementSettingsView: View {
isSearchFocused = true
}
+ private func applySearchRevealRequest(
+ _ request: SettingsSearchRevealRequest?,
+ proxy: ScrollViewProxy
+ ) {
+ guard
+ let request,
+ case let .marketplace(target) = request.target
+ else {
+ return
+ }
+
+ guard pluginHost.pluginManagementItems.contains(where: {
+ $0.id == target.pluginID && $0.canUninstall
+ }) else {
+ navigationCoordinator.clearSearchRevealRequest(request)
+ return
+ }
+
+ clearSearchTargetTask?.cancel()
+ searchText = ""
+ selectedFilter = .all
+ activeSearchTarget = target
+
+ DispatchQueue.main.async {
+ withAnimation(.easeOut(duration: 0.2)) {
+ proxy.scrollTo(target.scrollID, anchor: .center)
+ }
+ }
+
+ clearSearchTargetTask = Task { @MainActor in
+ do {
+ try await Task.sleep(for: .seconds(2))
+ } catch {
+ return
+ }
+
+ activeSearchTarget = nil
+ navigationCoordinator.clearSearchRevealRequest(request)
+ }
+ }
+
private var filteredItems: [PluginManagementItem] {
let filtered = pluginHost.pluginManagementItems.filter {
PluginListFilter.matches(managementItem: $0, query: searchText, filter: selectedFilter)
@@ -624,6 +708,60 @@ private struct PluginManagementRow: View {
}
}
+private struct MarketplaceSearchAnchorModifier: ViewModifier {
+ @AccessibilityFocusState private var isAccessibilityFocused: Bool
+
+ let target: MarketplacePluginSearchTarget
+ let activeTarget: MarketplacePluginSearchTarget?
+
+ func body(content: Content) -> some View {
+ content
+ .id(target.scrollID)
+ .accessibilityFocused($isAccessibilityFocused)
+ .overlay {
+ if activeTarget == target {
+ RoundedRectangle(
+ cornerRadius: PluginSettingsTheme.Radius.hostCard,
+ style: .continuous
+ )
+ .stroke(Color.accentColor, lineWidth: 2)
+ .allowsHitTesting(false)
+ .accessibilityHidden(true)
+ }
+ }
+ .onAppear {
+ focusIfNeeded(activeTarget)
+ }
+ .onChange(of: activeTarget) { _, newValue in
+ focusIfNeeded(newValue)
+ }
+ }
+
+ private func focusIfNeeded(
+ _ activeTarget: MarketplacePluginSearchTarget?
+ ) {
+ guard activeTarget == target else {
+ return
+ }
+
+ isAccessibilityFocused = true
+ }
+}
+
+private extension View {
+ func marketplaceSearchAnchor(
+ target: MarketplacePluginSearchTarget,
+ activeTarget: MarketplacePluginSearchTarget?
+ ) -> some View {
+ modifier(
+ MarketplaceSearchAnchorModifier(
+ target: target,
+ activeTarget: activeTarget
+ )
+ )
+ }
+}
+
private extension PluginManagementItem {
var requiresRelaunchAction: Bool {
if case .restartRequired = state {
diff --git a/Sources/App/SettingsNavigationCoordinator.swift b/Sources/App/SettingsNavigationCoordinator.swift
index aefd16e1..82bbce0f 100644
--- a/Sources/App/SettingsNavigationCoordinator.swift
+++ b/Sources/App/SettingsNavigationCoordinator.swift
@@ -41,6 +41,28 @@ enum SettingsSearchField: Equatable {
case pluginMarketplace
}
+enum UnifiedSearchPresentationOrigin: Equatable {
+ case pluginSidebar
+ case keyboard
+}
+
+enum PluginSubpageMoveDirection {
+ case previous
+ case next
+}
+
+extension FeatureSettingsPane {
+ static func settingsSidebarOrder(
+ configurationIDs: some Sequence
+ ) -> [FeatureSettingsPane] {
+ [
+ .dashboardLayout,
+ .featurePanelLayout,
+ .marketplace
+ ] + configurationIDs.map(FeatureSettingsPane.configuration)
+ }
+}
+
struct SettingsSearchFocusRequest: Equatable {
let id: UInt
let field: SettingsSearchField
@@ -51,6 +73,60 @@ struct AboutUpdateActionRequest: Equatable {
let version: String
}
+enum GeneralSettingsSearchTarget: String, Hashable {
+ case launchAtLogin
+ case appearance
+ case language
+ case menuBarIcon
+ case menuBarClickBehavior
+ case appShortcuts
+ case preferencesBackup
+
+ var scrollID: String {
+ "general-search-anchor.\(rawValue)"
+ }
+}
+
+enum SettingsSearchRevealTarget: Hashable {
+ case general(GeneralSettingsSearchTarget)
+ case marketplace(MarketplacePluginSearchTarget)
+ case plugin(PluginSettingsSearchTarget)
+ case surface(SurfaceSettingsSearchTarget)
+}
+
+struct SettingsSearchRevealRequest: Equatable {
+ let id: UInt
+ let target: SettingsSearchRevealTarget
+}
+
+struct SurfaceSettingsSearchTarget: Hashable {
+ let surface: PluginDisplaySurface
+ let pluginID: String
+
+ func scrollID(isHidden: Bool) -> String {
+ let surfaceID = switch surface {
+ case .dashboard:
+ "dashboard"
+ case .featurePanel:
+ "feature-panel"
+ }
+ return "surface-search-anchor.\(surfaceID).\(isHidden ? "hidden" : "visible").\(pluginID)"
+ }
+}
+
+struct MarketplacePluginSearchTarget: Hashable {
+ let pluginID: String
+
+ var scrollID: String {
+ "marketplace-search-anchor.\(pluginID)"
+ }
+}
+
+struct UnifiedSearchQuickSelectionRequest: Equatable {
+ let id: UInt
+ let number: Int
+}
+
@MainActor
final class SettingsNavigationCoordinator: ObservableObject {
private static let maximumHistoryCount = 128
@@ -58,21 +134,54 @@ final class SettingsNavigationCoordinator: ObservableObject {
@Published private(set) var destination: SettingsNavigationDestination
@Published private(set) var searchFocusRequest: SettingsSearchFocusRequest?
@Published private(set) var aboutUpdateActionRequest: AboutUpdateActionRequest?
+ @Published private(set) var isUnifiedSearchPresented = false
+ @Published private(set) var unifiedSearchPresentationOrigin: UnifiedSearchPresentationOrigin?
+ @Published private(set) var unifiedSearchFocusRequestID: UInt = 0
+ @Published private(set) var unifiedSearchQuickSelectionRequest: UnifiedSearchQuickSelectionRequest?
+ @Published private(set) var searchRevealRequest: SettingsSearchRevealRequest?
private(set) var history: [SettingsNavigationDestination]
private(set) var historyIndex: Int
private(set) var focusedSearchField: SettingsSearchField?
private let pluginSettingsLandingPage: () -> FeatureSettingsPane
+ private let pluginSubpageOrder: () -> [FeatureSettingsPane]
private let isPluginConfigurationAvailable: (String) -> Bool
+ private let isPluginSettingsSearchTargetAvailable: (PluginSettingsSearchTarget) -> Bool
+ private let isPluginManagementAvailable: (String) -> Bool
+ private let isPluginSurfaceAvailable: (SurfaceSettingsSearchTarget) -> Bool
private let selectPluginSettingsPane: (FeatureSettingsPane) -> Bool
private var nextSearchFocusRequestID: UInt = 0
private var nextAboutUpdateActionRequestID: UInt = 0
+ private var nextSearchRevealRequestID: UInt = 0
+ private var nextUnifiedSearchQuickSelectionRequestID: UInt = 0
convenience init(pluginHost: PluginHost) {
self.init(
pluginSettingsLandingPage: { pluginHost.pluginSettingsLandingPage() },
+ pluginSubpageOrder: {
+ FeatureSettingsPane.settingsSidebarOrder(
+ configurationIDs: pluginHost.pluginConfigurationItems.map(\.id)
+ )
+ },
isPluginConfigurationAvailable: { pluginHost.hasPluginConfiguration(pluginID: $0) },
+ isPluginSettingsSearchTargetAvailable: {
+ pluginHost.hasPluginSettingsSearchTarget($0)
+ },
+ isPluginManagementAvailable: { pluginID in
+ pluginHost.pluginManagementItems.contains {
+ $0.id == pluginID && $0.canUninstall
+ }
+ },
+ isPluginSurfaceAvailable: { target in
+ let items = switch target.surface {
+ case .dashboard:
+ pluginHost.dashboardLayoutItems + pluginHost.dashboardHiddenLayoutItems
+ case .featurePanel:
+ pluginHost.featurePanelLayoutItems + pluginHost.featurePanelHiddenLayoutItems
+ }
+ return items.contains { $0.id == target.pluginID }
+ },
selectPluginSettingsPane: { pluginHost.selectFeatureSettingsPane($0) }
)
}
@@ -80,14 +189,22 @@ final class SettingsNavigationCoordinator: ObservableObject {
init(
initialDestination: SettingsNavigationDestination = .general,
pluginSettingsLandingPage: @escaping () -> FeatureSettingsPane = { .marketplace },
+ pluginSubpageOrder: @escaping () -> [FeatureSettingsPane] = { [] },
isPluginConfigurationAvailable: @escaping (String) -> Bool = { _ in true },
+ isPluginSettingsSearchTargetAvailable: @escaping (PluginSettingsSearchTarget) -> Bool = { _ in true },
+ isPluginManagementAvailable: @escaping (String) -> Bool = { _ in true },
+ isPluginSurfaceAvailable: @escaping (SurfaceSettingsSearchTarget) -> Bool = { _ in true },
selectPluginSettingsPane: @escaping (FeatureSettingsPane) -> Bool = { _ in true }
) {
self.destination = initialDestination
self.history = [initialDestination]
self.historyIndex = 0
self.pluginSettingsLandingPage = pluginSettingsLandingPage
+ self.pluginSubpageOrder = pluginSubpageOrder
self.isPluginConfigurationAvailable = isPluginConfigurationAvailable
+ self.isPluginSettingsSearchTargetAvailable = isPluginSettingsSearchTargetAvailable
+ self.isPluginManagementAvailable = isPluginManagementAvailable
+ self.isPluginSurfaceAvailable = isPluginSurfaceAvailable
self.selectPluginSettingsPane = selectPluginSettingsPane
}
@@ -131,6 +248,36 @@ final class SettingsNavigationCoordinator: ObservableObject {
activate(destination)
}
+ func movePluginSubpage(
+ _ direction: PluginSubpageMoveDirection,
+ in orderedPanes: [FeatureSettingsPane]
+ ) {
+ guard
+ case let .plugins(currentPane) = destination,
+ let currentIndex = orderedPanes.firstIndex(of: currentPane)
+ else {
+ return
+ }
+
+ let adjacentIndex: Int
+ switch direction {
+ case .previous:
+ adjacentIndex = currentIndex - 1
+ case .next:
+ adjacentIndex = currentIndex + 1
+ }
+
+ guard orderedPanes.indices.contains(adjacentIndex) else {
+ return
+ }
+
+ navigate(to: .plugins(orderedPanes[adjacentIndex]))
+ }
+
+ func movePluginSubpage(_ direction: PluginSubpageMoveDirection) {
+ movePluginSubpage(direction, in: pluginSubpageOrder())
+ }
+
func requestAboutUpdateAction(version: String) {
navigate(to: .about)
nextAboutUpdateActionRequestID &+= 1
@@ -140,6 +287,89 @@ final class SettingsNavigationCoordinator: ObservableObject {
)
}
+ func presentUnifiedSearch(origin: UnifiedSearchPresentationOrigin) {
+ unifiedSearchPresentationOrigin = origin
+ isUnifiedSearchPresented = true
+ unifiedSearchFocusRequestID &+= 1
+ }
+
+ func dismissUnifiedSearch() {
+ isUnifiedSearchPresented = false
+ unifiedSearchPresentationOrigin = nil
+ unifiedSearchQuickSelectionRequest = nil
+ }
+
+ @discardableResult
+ func requestUnifiedSearchQuickSelection(number: Int) -> Bool {
+ guard isUnifiedSearchPresented, (1...9).contains(number) else {
+ return false
+ }
+
+ nextUnifiedSearchQuickSelectionRequestID &+= 1
+ unifiedSearchQuickSelectionRequest = UnifiedSearchQuickSelectionRequest(
+ id: nextUnifiedSearchQuickSelectionRequestID,
+ number: number
+ )
+ return true
+ }
+
+ @discardableResult
+ func consumeUnifiedSearchQuickSelectionRequest(
+ _ request: UnifiedSearchQuickSelectionRequest
+ ) -> Bool {
+ guard unifiedSearchQuickSelectionRequest == request else {
+ return false
+ }
+
+ unifiedSearchQuickSelectionRequest = nil
+ return true
+ }
+
+ @discardableResult
+ func navigateFromSearch(
+ to destination: SettingsNavigationDestination,
+ target: SettingsSearchRevealTarget?
+ ) -> Bool {
+ guard
+ isAvailable(destination),
+ target.map(isAvailable) ?? true,
+ target.map({ isCompatible($0, with: destination) }) ?? true
+ else {
+ return false
+ }
+
+ dismissUnifiedSearch()
+ navigate(to: destination)
+
+ guard let target else {
+ searchRevealRequest = nil
+ return true
+ }
+
+ nextSearchRevealRequestID &+= 1
+ searchRevealRequest = SettingsSearchRevealRequest(
+ id: nextSearchRevealRequestID,
+ target: target
+ )
+ return true
+ }
+
+ func clearSearchRevealRequest(_ request: SettingsSearchRevealRequest) {
+ guard searchRevealRequest == request else {
+ return
+ }
+
+ searchRevealRequest = nil
+ }
+
+ func clearSearchRevealRequest(matching target: SettingsSearchRevealTarget) {
+ guard searchRevealRequest?.target == target else {
+ return
+ }
+
+ searchRevealRequest = nil
+ }
+
@discardableResult
func consumeAboutUpdateActionRequest(_ request: AboutUpdateActionRequest) -> Bool {
guard aboutUpdateActionRequest == request else {
@@ -169,6 +399,7 @@ final class SettingsNavigationCoordinator: ObservableObject {
@discardableResult
func requestSearchFocus() -> Bool {
guard
+ !isUnifiedSearchPresented,
let field = destination.searchField,
focusedSearchField != field
else {
@@ -224,6 +455,42 @@ final class SettingsNavigationCoordinator: ObservableObject {
return isPluginConfigurationAvailable(pluginID)
}
+ private func isAvailable(_ target: SettingsSearchRevealTarget) -> Bool {
+ switch target {
+ case .general:
+ true
+ case let .marketplace(target):
+ isPluginManagementAvailable(target.pluginID)
+ case let .plugin(target):
+ isPluginSettingsSearchTargetAvailable(target)
+ case let .surface(target):
+ isPluginSurfaceAvailable(target)
+ }
+ }
+
+ private func isCompatible(
+ _ target: SettingsSearchRevealTarget,
+ with destination: SettingsNavigationDestination
+ ) -> Bool {
+ switch (target, destination) {
+ case (.general, .general):
+ true
+ case (.marketplace, .plugins(.marketplace)):
+ true
+ case let (.plugin(target), .plugins(.configuration(pluginID))):
+ target.pluginID == pluginID
+ case let (.surface(target), .plugins(pane)):
+ switch (target.surface, pane) {
+ case (.dashboard, .dashboardLayout), (.featurePanel, .featurePanelLayout):
+ true
+ default:
+ false
+ }
+ default:
+ false
+ }
+ }
+
private func activate(_ destination: SettingsNavigationDestination) {
if let pane = destination.featureSettingsPane {
_ = selectPluginSettingsPane(pane)
diff --git a/Sources/App/SettingsView.swift b/Sources/App/SettingsView.swift
index c47b60b3..824dbdff 100644
--- a/Sources/App/SettingsView.swift
+++ b/Sources/App/SettingsView.swift
@@ -13,6 +13,7 @@ enum GeneralSettingsCardLayout {
}
struct SettingsView: View {
+ @Environment(\.accessibilityReduceTransparency) private var accessibilityReduceTransparency
@ObservedObject var pluginHost: PluginHost
@ObservedObject var navigationCoordinator: SettingsNavigationCoordinator
@ObservedObject private var runtimeLocale = PluginRuntimeLocalization.source
@@ -27,39 +28,63 @@ struct SettingsView: View {
var body: some View {
// Recreate native AppKit-backed controls when the shared locale changes.
let _ = runtimeLocale.revision
+ let orderedPluginPanes = FeatureSettingsPane.settingsSidebarOrder(
+ configurationIDs: pluginHost.pluginConfigurationItems.map(\.id)
+ )
- return TabView(selection: settingsDestinationBinding) {
- GeneralSettingsView(
- pluginHost: pluginHost,
- menuBarIconSettings: menuBarIconSettings,
- menuBarIconGallery: menuBarIconGallery,
- launchAtLoginController: launchAtLoginController
- )
- .tag(SettingsDestination.general)
- .tabItem {
- Label(AppL10n.settings("tab.general", defaultValue: "通用"), systemImage: "gearshape")
- }
+ return ZStack {
+ TabView(selection: settingsDestinationBinding) {
+ GeneralSettingsView(
+ pluginHost: pluginHost,
+ navigationCoordinator: navigationCoordinator,
+ menuBarIconSettings: menuBarIconSettings,
+ menuBarIconGallery: menuBarIconGallery,
+ launchAtLoginController: launchAtLoginController
+ )
+ .tag(SettingsDestination.general)
+ .tabItem {
+ Label(AppL10n.settings("tab.general", defaultValue: "通用"), systemImage: "gearshape")
+ }
- FeatureSettingsView(
- pluginHost: pluginHost,
- navigationCoordinator: navigationCoordinator,
- uninstallConfirmationSession: uninstallConfirmationSession,
- showDashboard: showDashboard,
- showFeaturePanel: showFeaturePanel
- )
- .tag(SettingsDestination.pluginConfiguration)
- .tabItem {
- Label(AppL10n.settings("tab.plugins", defaultValue: "插件"), systemImage: "slider.horizontal.3")
- }
+ FeatureSettingsView(
+ pluginHost: pluginHost,
+ navigationCoordinator: navigationCoordinator,
+ uninstallConfirmationSession: uninstallConfirmationSession,
+ showDashboard: showDashboard,
+ showFeaturePanel: showFeaturePanel,
+ orderedPanes: orderedPluginPanes
+ )
+ .tag(SettingsDestination.pluginConfiguration)
+ .tabItem {
+ Label(AppL10n.settings("tab.plugins", defaultValue: "插件"), systemImage: "slider.horizontal.3")
+ }
- AboutSettingsView(
- appUpdater: appUpdater,
- navigationCoordinator: navigationCoordinator
+ AboutSettingsView(
+ appUpdater: appUpdater,
+ navigationCoordinator: navigationCoordinator
+ )
+ .tag(SettingsDestination.about)
+ .tabItem {
+ Label(AppL10n.settings("tab.about", defaultValue: "关于"), systemImage: "info.circle")
+ }
+ }
+ .blur(
+ radius: navigationCoordinator.isUnifiedSearchPresented
+ && !accessibilityReduceTransparency
+ ? 2.5
+ : 0
)
- .tag(SettingsDestination.about)
- .tabItem {
- Label(AppL10n.settings("tab.about", defaultValue: "关于"), systemImage: "info.circle")
- }
+ .allowsHitTesting(!navigationCoordinator.isUnifiedSearchPresented)
+ .accessibilityHidden(navigationCoordinator.isUnifiedSearchPresented)
+
+ if navigationCoordinator.isUnifiedSearchPresented {
+ UnifiedSearchPresentationView(
+ pluginHost: pluginHost,
+ navigationCoordinator: navigationCoordinator
+ )
+ .transition(.opacity.combined(with: .scale(scale: 0.98)))
+ .zIndex(1)
+ }
}
.background {
SettingsDestinationShortcutButtons(coordinator: navigationCoordinator)
@@ -67,12 +92,16 @@ struct SettingsView: View {
.toolbar {
ToolbarItem(placement: .navigation) {
SettingsHistoryNavigationControls(coordinator: navigationCoordinator)
+ .opacity(navigationCoordinator.isUnifiedSearchPresented ? 0 : 1)
+ .allowsHitTesting(!navigationCoordinator.isUnifiedSearchPresented)
+ .accessibilityHidden(navigationCoordinator.isUnifiedSearchPresented)
}
}
.id(runtimeLocale.revision)
.frame(minWidth: 720, maxWidth: .infinity, minHeight: 480, maxHeight: .infinity)
.environment(\.locale, PluginRuntimeLocalization.locale)
.environment(\.layoutDirection, layoutDirection)
+ .animation(.easeOut(duration: 0.14), value: navigationCoordinator.isUnifiedSearchPresented)
}
private var layoutDirection: LayoutDirection {
@@ -104,9 +133,12 @@ struct SettingsDestinationShortcutButtons: View {
shortcutButton(for: .about, key: "3")
historyShortcutButton(key: "[") { coordinator.goBack() }
historyShortcutButton(key: "]") { coordinator.goForward() }
+ pluginSubpageShortcutButton(key: .upArrow, direction: .previous)
+ pluginSubpageShortcutButton(key: .downArrow, direction: .next)
}
.frame(width: 0, height: 0)
.opacity(0)
+ .disabled(coordinator.isUnifiedSearchPresented)
.accessibilityHidden(true)
}
@@ -129,6 +161,17 @@ struct SettingsDestinationShortcutButtons: View {
.keyboardShortcut(key, modifiers: [.command])
.focusable(false)
}
+
+ private func pluginSubpageShortcutButton(
+ key: KeyEquivalent,
+ direction: PluginSubpageMoveDirection
+ ) -> some View {
+ Button("") {
+ coordinator.movePluginSubpage(direction)
+ }
+ .keyboardShortcut(key, modifiers: [.control, .command])
+ .focusable(false)
+ }
}
struct SettingsHistoryNavigationControls: View {
@@ -214,51 +257,103 @@ private struct PermissionSettingsRow: View {
struct GeneralSettingsView: View {
@ObservedObject var pluginHost: PluginHost
+ @ObservedObject var navigationCoordinator: SettingsNavigationCoordinator
@ObservedObject var menuBarIconSettings: MenuBarIconSettings
@ObservedObject var menuBarIconGallery: MenuBarIconGalleryLibrary
@ObservedObject var launchAtLoginController: LaunchAtLoginController
@AppStorage(AppAppearancePreference.userDefaultsKey) private var appearancePreferenceRawValue = AppAppearancePreference.system.rawValue
@AppStorage(AppLanguagePreference.userDefaultsKey) private var languagePreferenceRawValue = AppLanguagePreference.system.rawValue
@AppStorage(MenuBarClickBehaviorPreference.userDefaultsKey) private var clickBehaviorRawValue = MenuBarClickBehaviorPreference.standard.rawValue
+ @State private var activeSearchTarget: GeneralSettingsSearchTarget?
+ @State private var clearSearchTargetTask: Task?
var body: some View {
- Form {
- Section {
- LaunchAtLoginSettingsRow(controller: launchAtLoginController)
- } header: {
- Text(AppL10n.settings("general.section.startup", defaultValue: "启动"))
- }
+ ScrollViewReader { proxy in
+ Form {
+ Section {
+ LaunchAtLoginSettingsRow(controller: launchAtLoginController)
+ .generalSettingsSearchAnchor(
+ target: .launchAtLogin,
+ activeTarget: activeSearchTarget
+ )
+ } header: {
+ Text(AppL10n.settings("general.section.startup", defaultValue: "启动"))
+ }
- Section {
- AppearanceSettingsRow(selection: appearancePreferenceBinding)
- LanguageSettingsRow(selection: languagePreferenceBinding)
- } header: {
- Text(AppL10n.settings("general.section.appearance", defaultValue: "外观"))
- }
+ Section {
+ AppearanceSettingsRow(selection: appearancePreferenceBinding)
+ .generalSettingsSearchAnchor(
+ target: .appearance,
+ activeTarget: activeSearchTarget
+ )
+ LanguageSettingsRow(selection: languagePreferenceBinding)
+ .generalSettingsSearchAnchor(
+ target: .language,
+ activeTarget: activeSearchTarget
+ )
+ } header: {
+ Text(AppL10n.settings("general.section.appearance", defaultValue: "外观"))
+ }
- Section {
- MenuBarIconSettingsView(
- iconSettings: menuBarIconSettings,
- gallery: menuBarIconGallery
+ Section {
+ MenuBarIconSettingsView(
+ iconSettings: menuBarIconSettings,
+ gallery: menuBarIconGallery
+ )
+ .generalSettingsSearchAnchor(
+ target: .menuBarIcon,
+ activeTarget: activeSearchTarget
+ )
+ MenuBarClickBehaviorSettingsRow(selection: clickBehaviorBinding)
+ .generalSettingsSearchAnchor(
+ target: .menuBarClickBehavior,
+ activeTarget: activeSearchTarget
+ )
+ } header: {
+ Text(AppL10n.settings("general.section.menuBarIcon", defaultValue: "状态栏图标"))
+ }
+
+ Section {
+ AppShortcutSettingsRows(pluginHost: pluginHost)
+ .generalSettingsSearchAnchor(
+ target: .appShortcuts,
+ activeTarget: activeSearchTarget
+ )
+ } header: {
+ Text(AppL10n.settings("shortcuts.title", defaultValue: "键盘快捷键"))
+ }
+
+ Section {
+ PreferencesBackupSettingsRow(pluginHost: pluginHost)
+ .generalSettingsSearchAnchor(
+ target: .preferencesBackup,
+ activeTarget: activeSearchTarget
+ )
+ } header: {
+ Text(AppL10n.preferencesBackup("general.section.preferencesBackup", defaultValue: "偏好设置备份"))
+ }
+ }
+ .formStyle(.grouped)
+ .onAppear {
+ applySearchRevealRequest(
+ navigationCoordinator.searchRevealRequest,
+ proxy: proxy
)
- MenuBarClickBehaviorSettingsRow(selection: clickBehaviorBinding)
- } header: {
- Text(AppL10n.settings("general.section.menuBarIcon", defaultValue: "状态栏图标"))
}
-
- Section {
- AppShortcutSettingsRows(pluginHost: pluginHost)
- } header: {
- Text(AppL10n.settings("shortcuts.title", defaultValue: "键盘快捷键"))
+ .onChange(of: navigationCoordinator.searchRevealRequest) { _, request in
+ applySearchRevealRequest(request, proxy: proxy)
}
-
- Section {
- PreferencesBackupSettingsRow(pluginHost: pluginHost)
- } header: {
- Text(AppL10n.preferencesBackup("general.section.preferencesBackup", defaultValue: "偏好设置备份"))
+ .onDisappear {
+ clearSearchTargetTask?.cancel()
+ clearSearchTargetTask = nil
+ if let activeSearchTarget {
+ navigationCoordinator.clearSearchRevealRequest(
+ matching: .general(activeSearchTarget)
+ )
+ }
+ activeSearchTarget = nil
}
}
- .formStyle(.grouped)
}
private var appearancePreferenceBinding: Binding {
@@ -291,6 +386,90 @@ struct GeneralSettingsView: View {
clickBehaviorRawValue = preference.rawValue
}
}
+
+ private func applySearchRevealRequest(
+ _ request: SettingsSearchRevealRequest?,
+ proxy: ScrollViewProxy
+ ) {
+ guard
+ let request,
+ case let .general(target) = request.target
+ else {
+ return
+ }
+
+ clearSearchTargetTask?.cancel()
+ activeSearchTarget = target
+
+ DispatchQueue.main.async {
+ withAnimation(.easeOut(duration: 0.2)) {
+ proxy.scrollTo(target.scrollID, anchor: .center)
+ }
+ }
+
+ clearSearchTargetTask = Task { @MainActor in
+ do {
+ try await Task.sleep(for: .seconds(2))
+ } catch {
+ return
+ }
+
+ activeSearchTarget = nil
+ navigationCoordinator.clearSearchRevealRequest(request)
+ }
+ }
+}
+
+private struct GeneralSettingsSearchAnchorModifier: ViewModifier {
+ @AccessibilityFocusState private var isAccessibilityFocused: Bool
+
+ let target: GeneralSettingsSearchTarget
+ let activeTarget: GeneralSettingsSearchTarget?
+
+ func body(content: Content) -> some View {
+ content
+ .id(target.scrollID)
+ .accessibilityFocused($isAccessibilityFocused)
+ .overlay {
+ if activeTarget == target {
+ RoundedRectangle(
+ cornerRadius: PluginSettingsTheme.Radius.card,
+ style: .continuous
+ )
+ .stroke(Color.accentColor, lineWidth: 2)
+ .allowsHitTesting(false)
+ .accessibilityHidden(true)
+ }
+ }
+ .onAppear {
+ focusIfNeeded(activeTarget)
+ }
+ .onChange(of: activeTarget) { _, newValue in
+ focusIfNeeded(newValue)
+ }
+ }
+
+ private func focusIfNeeded(_ activeTarget: GeneralSettingsSearchTarget?) {
+ guard activeTarget == target else {
+ return
+ }
+
+ isAccessibilityFocused = true
+ }
+}
+
+private extension View {
+ func generalSettingsSearchAnchor(
+ target: GeneralSettingsSearchTarget,
+ activeTarget: GeneralSettingsSearchTarget?
+ ) -> some View {
+ modifier(
+ GeneralSettingsSearchAnchorModifier(
+ target: target,
+ activeTarget: activeTarget
+ )
+ )
+ }
}
private struct AppShortcutSettingsRows: View {
@@ -974,12 +1153,20 @@ private struct FeatureSettingsView: View {
@ObservedObject var uninstallConfirmationSession: PluginUninstallConfirmationSession
let showDashboard: () -> Void
let showFeaturePanel: () -> Void
+ let orderedPanes: [FeatureSettingsPane]
var body: some View {
HSplitView {
FeatureSettingsSidebar(
configurationItems: pluginHost.pluginConfigurationItems,
- selection: selectionBinding
+ orderedPanes: orderedPanes,
+ selection: selectionBinding,
+ moveSelection: { direction in
+ navigationCoordinator.movePluginSubpage(direction, in: orderedPanes)
+ },
+ onSearch: {
+ navigationCoordinator.presentUnifiedSearch(origin: .pluginSidebar)
+ }
)
.frame(
minWidth: Layout.sidebarMinWidth,
@@ -1022,64 +1209,220 @@ private struct FeatureSettingsView: View {
private struct FeatureSettingsSidebar: View {
let configurationItems: [PluginConfigurationItem]
+ let orderedPanes: [FeatureSettingsPane]
@Binding var selection: FeatureSettingsPane
+ let moveSelection: (PluginSubpageMoveDirection) -> Void
+ let onSearch: () -> Void
+ @FocusState private var isSidebarFocused: Bool
var body: some View {
- List(selection: optionalSelectionBinding) {
- Section(AppL10n.settings(
- "plugins.sidebar.pluginsSection",
- defaultValue: "插件"
- )) {
- FeatureSettingsSidebarRow(
- title: AppL10n.settings("plugins.sidebar.dashboard", defaultValue: "仪表盘"),
- systemImage: "square.grid.2x2",
- iconTint: .blue
- )
- .tag(FeatureSettingsPane.dashboardLayout)
-
- FeatureSettingsSidebarRow(
- title: AppL10n.settings("plugins.sidebar.featurePanel", defaultValue: "功能面板"),
- systemImage: "switch.2",
- iconTint: .purple
- )
- .tag(FeatureSettingsPane.featurePanelLayout)
+ VStack(spacing: 0) {
+ searchLauncher
- FeatureSettingsSidebarRow(
- title: AppL10n.settings("plugins.sidebar.marketplace", defaultValue: "市场"),
- systemImage: "shippingbox",
- iconTint: .blue
- )
- .tag(FeatureSettingsPane.marketplace)
- }
+ ScrollViewReader { proxy in
+ List(selection: optionalSelectionBinding) {
+ Section {
+ ForEach(primaryPanes, id: \.self) { pane in
+ sidebarRow(for: pane)
+ }
+ } header: {
+ Text(AppL10n.settings(
+ "plugins.sidebar.pluginsSection",
+ defaultValue: "插件"
+ ))
+ .accessibilityHidden(true)
+ }
- Section(AppL10n.settings(
- "plugins.sidebar.configurationSection",
- defaultValue: "插件设置"
- )) {
- if configurationItems.isEmpty {
- Text(AppL10n.settings("plugins.sidebar.emptyConfigurations", defaultValue: "暂无可设置插件"))
- .font(PluginSettingsTheme.Typography.secondaryLabel)
- .foregroundStyle(.secondary)
- } else {
- ForEach(configurationItems) { item in
- FeatureSettingsSidebarRow(
- title: item.title,
- systemImage: item.iconName,
- iconTint: item.iconTint
- )
- .tag(FeatureSettingsPane.configuration(item.id))
+ Section {
+ if configurationPanes.isEmpty {
+ Text(emptyConfigurationsText)
+ .font(PluginSettingsTheme.Typography.secondaryLabel)
+ .foregroundStyle(.secondary)
+ .accessibilityHidden(true)
+ } else {
+ ForEach(configurationPanes, id: \.self) { pane in
+ sidebarRow(for: pane)
+ }
+ }
+ } header: {
+ Text(AppL10n.settings(
+ "plugins.sidebar.configurationSection",
+ defaultValue: "插件设置"
+ ))
+ .accessibilityHidden(true)
+ }
+ }
+ .listStyle(.sidebar)
+ .scrollContentBackground(.hidden)
+ .focusable(interactions: .activate)
+ .focused($isSidebarFocused)
+ .onMoveCommand { direction in
+ switch direction {
+ case .up:
+ moveSelection(.previous)
+ case .down:
+ moveSelection(.next)
+ default:
+ break
+ }
+ }
+ .onChange(of: selection) {
+ withAnimation {
+ proxy.scrollTo(selection)
+ }
+ }
+ .overlay {
+ if isSidebarFocused {
+ RoundedRectangle(cornerRadius: 6, style: .continuous)
+ .stroke(Color.accentColor.opacity(0.55), lineWidth: 1)
+ .padding(2)
+ .allowsHitTesting(false)
+ .accessibilityHidden(true)
}
}
+ .accessibilityElement(children: .contain)
+ .accessibilityLabel(AppL10n.settings(
+ "plugins.sidebar.accessibilityLabel",
+ defaultValue: "插件导航"
+ ))
+ .accessibilityHint(configurationPanes.isEmpty ? emptyConfigurationsText : "")
}
}
- .listStyle(.sidebar)
- .scrollContentBackground(.hidden)
.background {
SettingsSidebarMaterialBackground()
.allowsHitTesting(false)
}
}
+ private var emptyConfigurationsText: String {
+ AppL10n.settings(
+ "plugins.sidebar.emptyConfigurations",
+ defaultValue: "暂无可设置插件"
+ )
+ }
+
+ private var primaryPanes: [FeatureSettingsPane] {
+ orderedPanes.filter {
+ guard case .configuration = $0 else {
+ return true
+ }
+ return false
+ }
+ }
+
+ private var configurationPanes: [FeatureSettingsPane] {
+ orderedPanes.filter {
+ if case .configuration = $0 {
+ return true
+ }
+ return false
+ }
+ }
+
+ @ViewBuilder
+ private func sidebarRow(for pane: FeatureSettingsPane) -> some View {
+ switch pane {
+ case .dashboardLayout:
+ FeatureSettingsSidebarRow(
+ title: AppL10n.settings("plugins.sidebar.dashboard", defaultValue: "仪表盘"),
+ systemImage: "square.grid.2x2",
+ iconTint: .blue
+ )
+ .tag(pane)
+ .id(pane)
+ case .featurePanelLayout:
+ FeatureSettingsSidebarRow(
+ title: AppL10n.settings("plugins.sidebar.featurePanel", defaultValue: "功能面板"),
+ systemImage: "switch.2",
+ iconTint: .purple
+ )
+ .tag(pane)
+ .id(pane)
+ case .marketplace:
+ FeatureSettingsSidebarRow(
+ title: AppL10n.settings("plugins.sidebar.marketplace", defaultValue: "市场"),
+ systemImage: "shippingbox",
+ iconTint: .blue
+ )
+ .tag(pane)
+ .id(pane)
+ case let .configuration(pluginID):
+ if let item = configurationItems.first(where: { $0.id == pluginID }) {
+ FeatureSettingsSidebarRow(
+ title: item.title,
+ systemImage: item.iconName,
+ iconTint: item.iconTint
+ )
+ .tag(pane)
+ .id(pane)
+ }
+ }
+ }
+
+ private var searchLauncher: some View {
+ Button(action: onSearch) {
+ HStack(spacing: 8) {
+ Image(systemName: "magnifyingglass")
+ .foregroundStyle(.secondary)
+
+ Text(
+ AppL10n.search(
+ "search.title",
+ defaultValue: "搜索 MacTools"
+ )
+ )
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+
+ Spacer(minLength: 4)
+
+ Text("⌘K")
+ .font(PluginSettingsTheme.Typography.statusBadge)
+ .foregroundStyle(.secondary)
+ .padding(.horizontal, 5)
+ .padding(.vertical, 1)
+ .background(
+ Capsule(style: .continuous)
+ .fill(Color(nsColor: .quaternaryLabelColor).opacity(0.12))
+ )
+ .accessibilityHidden(true)
+ }
+ }
+ .buttonStyle(.plain)
+ .padding(.horizontal, 9)
+ .padding(.vertical, 7)
+ .background(
+ RoundedRectangle(cornerRadius: 8, style: .continuous)
+ .fill(Color(nsColor: .controlBackgroundColor))
+ )
+ .overlay {
+ RoundedRectangle(cornerRadius: 8, style: .continuous)
+ .strokeBorder(Color(nsColor: .separatorColor).opacity(0.45), lineWidth: 1)
+ }
+ .padding(.horizontal, 10)
+ .padding(.top, 10)
+ .padding(.bottom, 8)
+ .help(
+ AppL10n.search(
+ "search.prompt",
+ defaultValue: "搜索插件、设置和命令"
+ )
+ )
+ .accessibilityLabel(
+ AppL10n.search(
+ "search.title",
+ defaultValue: "搜索 MacTools"
+ )
+ )
+ .accessibilityHint(
+ AppL10n.search(
+ "search.prompt",
+ defaultValue: "搜索插件、设置和命令"
+ )
+ )
+ .accessibilityIdentifier("mactools.unified-search.launcher")
+ }
+
private var optionalSelectionBinding: Binding {
Binding(
get: { selection },
@@ -1087,6 +1430,7 @@ private struct FeatureSettingsSidebar: View {
guard let newSelection else {
return
}
+ isSidebarFocused = true
selection = newSelection
}
)
@@ -1132,6 +1476,7 @@ private struct FeatureSettingsSidebarRow: View {
.frame(width: Layout.iconWidth)
}
.font(.body)
+ .focusable(false)
.help(title)
}
}
@@ -1153,6 +1498,7 @@ private struct FeatureSettingsDetailPane: View {
switch selectedPane {
case .dashboardLayout:
SurfaceLayoutSettingsView(
+ navigationCoordinator: navigationCoordinator,
surface: .dashboard,
title: AppL10n.settings("plugins.dashboard.title", defaultValue: "仪表盘"),
description: AppL10n.settings(
@@ -1187,6 +1533,7 @@ private struct FeatureSettingsDetailPane: View {
)
case .featurePanelLayout:
SurfaceLayoutSettingsView(
+ navigationCoordinator: navigationCoordinator,
surface: .featurePanel,
title: AppL10n.settings("plugins.featurePanel.title", defaultValue: "功能面板"),
description: AppL10n.settings(
@@ -1228,6 +1575,7 @@ private struct FeatureSettingsDetailPane: View {
case let .configuration(pluginID):
PluginConfigurationDetailPane(
pluginHost: pluginHost,
+ navigationCoordinator: navigationCoordinator,
item: configurationItem(for: pluginID)
)
}
@@ -1239,6 +1587,7 @@ private struct FeatureSettingsDetailPane: View {
}
private struct SurfaceLayoutSettingsView: View {
+ @ObservedObject var navigationCoordinator: SettingsNavigationCoordinator
let surface: PluginDisplaySurface
let title: String
let description: String
@@ -1260,94 +1609,133 @@ private struct SurfaceLayoutSettingsView: View {
let onUninstall: (String) throws -> Void
@State private var pendingUninstallItem: PluginUninstallConfirmation?
@State private var uninstallErrorMessage: String?
+ @State private var activeSearchTarget: SurfaceSettingsSearchTarget?
+ @State private var clearSearchTargetTask: Task?
var body: some View {
- ScrollView {
- LazyVStack(alignment: .leading, spacing: 18) {
- HStack(alignment: .center, spacing: 12) {
- SettingsPageHeader(
- title: title,
- description: description,
- systemImage: systemImage,
- iconTint: iconTint
- )
+ ScrollViewReader { proxy in
+ ScrollView {
+ LazyVStack(alignment: .leading, spacing: 18) {
+ HStack(alignment: .center, spacing: 12) {
+ SettingsPageHeader(
+ title: title,
+ description: description,
+ systemImage: systemImage,
+ iconTint: iconTint
+ )
- Button(AppL10n.settings(
- "plugins.layout.restoreDefaultOrder",
- defaultValue: "恢复默认排列"
- ), action: onResetOrder)
- .buttonStyle(.bordered)
- .controlSize(.small)
- .disabled(items.count < 2)
+ Button(AppL10n.settings(
+ "plugins.layout.restoreDefaultOrder",
+ defaultValue: "恢复默认排列"
+ ), action: onResetOrder)
+ .buttonStyle(.bordered)
+ .controlSize(.small)
+ .disabled(items.count < 2)
- Button(action: onOpenPanel) {
- Label(openButtonTitle, systemImage: "rectangle.on.rectangle")
+ Button(action: onOpenPanel) {
+ Label(openButtonTitle, systemImage: "rectangle.on.rectangle")
+ }
+ .buttonStyle(.borderedProminent)
+ .controlSize(.small)
}
- .buttonStyle(.borderedProminent)
- .controlSize(.small)
- }
-
- if uninstallConfirmationSession.isConfirmationPaused {
- PluginUninstallConfirmationPausedBanner(session: uninstallConfirmationSession)
- }
- SettingsCardContainer {
- if items.isEmpty {
- ContentUnavailableView(
- emptyTitle,
- systemImage: systemImage,
- description: Text(emptyDescription)
- )
- .frame(maxWidth: .infinity, minHeight: 180)
- } else {
- FeatureManagementTableView(
- items: items.map {
- FeatureManagementTableItem(
- surfaceItem: $0,
- hasSettings: configurationPluginIDs.contains($0.id)
- )
- },
- mode: .surface(surface),
- onMove: onMove,
- onSetVisible: onSetVisible,
- onOpenSettings: onOpenSettings,
- onOpenMarketplace: onOpenMarketplace,
- onRequestUninstall: requestUninstall
- )
- .frame(height: FeatureManagementTableView.preferredHeight(for: items.count))
+ if uninstallConfirmationSession.isConfirmationPaused {
+ PluginUninstallConfirmationPausedBanner(session: uninstallConfirmationSession)
}
- }
-
- if !hiddenItems.isEmpty {
- VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.sectionHeaderContent) {
- Label(hiddenSectionTitle, systemImage: "eye.slash")
- .font(PluginSettingsTheme.Typography.sectionTitle)
- .foregroundStyle(.secondary)
- SettingsCardContainer {
+ SettingsCardContainer {
+ if items.isEmpty {
+ ContentUnavailableView(
+ emptyTitle,
+ systemImage: systemImage,
+ description: Text(emptyDescription)
+ )
+ .frame(maxWidth: .infinity, minHeight: 180)
+ } else {
FeatureManagementTableView(
- items: hiddenItems.map {
+ items: items.map {
FeatureManagementTableItem(
surfaceItem: $0,
hasSettings: configurationPluginIDs.contains($0.id)
)
},
mode: .surface(surface),
- isReorderEnabled: false,
+ highlightedPluginID: highlightedPluginID(in: items),
+ onMove: onMove,
onSetVisible: onSetVisible,
onOpenSettings: onOpenSettings,
onOpenMarketplace: onOpenMarketplace,
onRequestUninstall: requestUninstall
)
- .frame(height: FeatureManagementTableView.preferredHeight(for: hiddenItems.count))
+ .frame(height: FeatureManagementTableView.preferredHeight(for: items.count))
+ .overlay(alignment: .topLeading) {
+ SurfaceLayoutSearchAnchors(
+ surface: surface,
+ items: items,
+ isHidden: false
+ )
+ }
+ }
+ }
+
+ if !hiddenItems.isEmpty {
+ VStack(alignment: .leading, spacing: PluginSettingsTheme.Spacing.sectionHeaderContent) {
+ Label(hiddenSectionTitle, systemImage: "eye.slash")
+ .font(PluginSettingsTheme.Typography.sectionTitle)
+ .foregroundStyle(.secondary)
+
+ SettingsCardContainer {
+ FeatureManagementTableView(
+ items: hiddenItems.map {
+ FeatureManagementTableItem(
+ surfaceItem: $0,
+ hasSettings: configurationPluginIDs.contains($0.id)
+ )
+ },
+ mode: .surface(surface),
+ isReorderEnabled: false,
+ highlightedPluginID: highlightedPluginID(in: hiddenItems),
+ onSetVisible: onSetVisible,
+ onOpenSettings: onOpenSettings,
+ onOpenMarketplace: onOpenMarketplace,
+ onRequestUninstall: requestUninstall
+ )
+ .frame(height: FeatureManagementTableView.preferredHeight(for: hiddenItems.count))
+ .overlay(alignment: .topLeading) {
+ SurfaceLayoutSearchAnchors(
+ surface: surface,
+ items: hiddenItems,
+ isHidden: true
+ )
+ }
+ }
}
}
}
+ .padding(PluginSettingsTheme.Spacing.pagePadding)
+ .frame(maxWidth: .infinity, alignment: .topLeading)
+ }
+ .onAppear {
+ applySearchRevealRequest(
+ navigationCoordinator.searchRevealRequest,
+ proxy: proxy
+ )
+ }
+ .onChange(of: navigationCoordinator.searchRevealRequest) { _, request in
+ applySearchRevealRequest(request, proxy: proxy)
}
- .padding(PluginSettingsTheme.Spacing.pagePadding)
- .frame(maxWidth: .infinity, alignment: .topLeading)
}
.background(SettingsStyle.contentBackground)
+ .onDisappear {
+ clearSearchTargetTask?.cancel()
+ clearSearchTargetTask = nil
+ if let activeSearchTarget {
+ navigationCoordinator.clearSearchRevealRequest(
+ matching: .surface(activeSearchTarget)
+ )
+ }
+ activeSearchTarget = nil
+ }
.sheet(item: $pendingUninstallItem) { item in
PluginUninstallConfirmationSheet(
confirmation: item,
@@ -1372,6 +1760,62 @@ private struct SurfaceLayoutSettingsView: View {
}
}
+ private func highlightedPluginID(
+ in candidates: [PluginSurfaceLayoutItem]
+ ) -> String? {
+ guard
+ let pluginID = activeSearchTarget?.pluginID,
+ candidates.contains(where: { $0.id == pluginID })
+ else {
+ return nil
+ }
+
+ return pluginID
+ }
+
+ private func applySearchRevealRequest(
+ _ request: SettingsSearchRevealRequest?,
+ proxy: ScrollViewProxy
+ ) {
+ guard
+ let request,
+ case let .surface(target) = request.target,
+ target.surface == surface
+ else {
+ return
+ }
+
+ let isHidden: Bool
+ if items.contains(where: { $0.id == target.pluginID }) {
+ isHidden = false
+ } else if hiddenItems.contains(where: { $0.id == target.pluginID }) {
+ isHidden = true
+ } else {
+ navigationCoordinator.clearSearchRevealRequest(request)
+ return
+ }
+
+ clearSearchTargetTask?.cancel()
+ activeSearchTarget = target
+
+ DispatchQueue.main.async {
+ withAnimation(.easeOut(duration: 0.2)) {
+ proxy.scrollTo(target.scrollID(isHidden: isHidden), anchor: .center)
+ }
+ }
+
+ clearSearchTargetTask = Task { @MainActor in
+ do {
+ try await Task.sleep(for: .seconds(2))
+ } catch {
+ return
+ }
+
+ activeSearchTarget = nil
+ navigationCoordinator.clearSearchRevealRequest(request)
+ }
+ }
+
private func requestUninstall(_ pluginID: String) {
guard let item = (items + hiddenItems).first(where: { $0.id == pluginID && $0.canUninstall }) else {
return
@@ -1429,57 +1873,120 @@ private struct SettingsCardContainer: View {
}
}
+private struct SurfaceLayoutSearchAnchors: View {
+ let surface: PluginDisplaySurface
+ let items: [PluginSurfaceLayoutItem]
+ let isHidden: Bool
+
+ var body: some View {
+ VStack(spacing: FeatureManagementTableView.rowSpacing) {
+ ForEach(items) { item in
+ Color.clear
+ .frame(maxWidth: .infinity)
+ .frame(height: FeatureManagementTableView.rowHeight)
+ .id(
+ SurfaceSettingsSearchTarget(
+ surface: surface,
+ pluginID: item.id
+ )
+ .scrollID(isHidden: isHidden)
+ )
+ }
+ }
+ .padding(.top, FeatureManagementTableView.verticalContentInset)
+ .allowsHitTesting(false)
+ .accessibilityHidden(true)
+ }
+}
+
private struct PluginConfigurationDetailPane: View {
@ObservedObject var pluginHost: PluginHost
+ @ObservedObject var navigationCoordinator: SettingsNavigationCoordinator
let item: PluginConfigurationItem?
+ @State private var activeSearchTarget: PluginSettingsSearchTarget?
+ @State private var clearSearchTargetTask: Task?
var body: some View {
Group {
if let item {
if item.prefersFullHeight {
- VStack(alignment: .leading, spacing: 0) {
- PluginConfigurationHeader(item: item)
- .padding(PluginSettingsTheme.Spacing.pagePadding)
+ ScrollViewReader { proxy in
+ VStack(alignment: .leading, spacing: 0) {
+ PluginConfigurationHeader(item: item)
+ .padding(PluginSettingsTheme.Spacing.pagePadding)
- if item.hasCustomConfiguration {
- pluginHost.pluginConfigurationViewItem(for: item.pluginID).content
- .padding(.horizontal, PluginSettingsTheme.Spacing.pagePadding)
- .padding(.bottom, PluginSettingsTheme.Spacing.pagePadding)
- .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+ if item.hasCustomConfiguration {
+ pluginHost.pluginConfigurationViewItem(for: item.pluginID).content
+ .environment(\.pluginSettingsSearchTarget, activeSearchTarget)
+ .padding(.horizontal, PluginSettingsTheme.Spacing.pagePadding)
+ .padding(.bottom, PluginSettingsTheme.Spacing.pagePadding)
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+ }
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+ .onAppear {
+ applySearchRevealRequest(
+ navigationCoordinator.searchRevealRequest,
+ pluginID: item.pluginID,
+ proxy: proxy
+ )
+ }
+ .onChange(of: navigationCoordinator.searchRevealRequest) { _, request in
+ applySearchRevealRequest(
+ request,
+ pluginID: item.pluginID,
+ proxy: proxy
+ )
}
}
- .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
} else {
- ScrollView {
- LazyVStack(alignment: .leading, spacing: 18) {
- PluginConfigurationHeader(item: item)
-
- if !item.settingsCards.isEmpty {
- PluginSettingsCardSection(
- pluginHost: pluginHost,
- cards: item.settingsCards
- )
- }
+ ScrollViewReader { proxy in
+ ScrollView {
+ LazyVStack(alignment: .leading, spacing: 18) {
+ PluginConfigurationHeader(item: item)
+
+ if !item.settingsCards.isEmpty {
+ PluginSettingsCardSection(
+ pluginHost: pluginHost,
+ cards: item.settingsCards
+ )
+ }
- if !item.permissionCards.isEmpty {
- PluginPermissionCardSection(
- pluginHost: pluginHost,
- cards: item.permissionCards
- )
- }
+ if !item.permissionCards.isEmpty {
+ PluginPermissionCardSection(
+ pluginHost: pluginHost,
+ cards: item.permissionCards
+ )
+ }
- if item.hasCustomConfiguration {
- pluginHost.pluginConfigurationViewItem(for: item.pluginID).content
- }
+ if item.hasCustomConfiguration {
+ pluginHost.pluginConfigurationViewItem(for: item.pluginID).content
+ }
- if !item.shortcutItems.isEmpty {
- PluginShortcutSection(pluginHost: pluginHost, items: item.shortcutItems)
+ if !item.shortcutItems.isEmpty {
+ PluginShortcutSection(pluginHost: pluginHost, items: item.shortcutItems)
+ }
}
+ .environment(\.pluginSettingsSearchTarget, activeSearchTarget)
+ .padding(PluginSettingsTheme.Spacing.pagePadding)
+ .frame(maxWidth: .infinity, alignment: .topLeading)
+ }
+ .background(SettingsStyle.contentBackground)
+ .onAppear {
+ applySearchRevealRequest(
+ navigationCoordinator.searchRevealRequest,
+ pluginID: item.pluginID,
+ proxy: proxy
+ )
+ }
+ .onChange(of: navigationCoordinator.searchRevealRequest) { _, request in
+ applySearchRevealRequest(
+ request,
+ pluginID: item.pluginID,
+ proxy: proxy
+ )
}
- .padding(PluginSettingsTheme.Spacing.pagePadding)
- .frame(maxWidth: .infinity, alignment: .topLeading)
}
- .background(SettingsStyle.contentBackground)
}
} else {
ContentUnavailableView(
@@ -1494,6 +2001,62 @@ private struct PluginConfigurationDetailPane: View {
}
}
.background(SettingsStyle.contentBackground)
+ .onDisappear {
+ clearSearchTargetTask?.cancel()
+ clearSearchTargetTask = nil
+ if let activeSearchTarget {
+ navigationCoordinator.clearSearchRevealRequest(
+ matching: .plugin(activeSearchTarget)
+ )
+ }
+ activeSearchTarget = nil
+ }
+ }
+
+ private func applySearchRevealRequest(
+ _ request: SettingsSearchRevealRequest?,
+ pluginID: String,
+ proxy: ScrollViewProxy
+ ) {
+ guard
+ let target = applySearchRevealRequest(request, pluginID: pluginID)
+ else {
+ return
+ }
+
+ DispatchQueue.main.async {
+ withAnimation(.easeOut(duration: 0.2)) {
+ proxy.scrollTo(target.scrollID, anchor: .center)
+ }
+ }
+ }
+
+ @discardableResult
+ private func applySearchRevealRequest(
+ _ request: SettingsSearchRevealRequest?,
+ pluginID: String
+ ) -> PluginSettingsSearchTarget? {
+ guard
+ let request,
+ case let .plugin(target) = request.target,
+ target.pluginID == pluginID
+ else {
+ return nil
+ }
+
+ clearSearchTargetTask?.cancel()
+ activeSearchTarget = target
+ clearSearchTargetTask = Task { @MainActor in
+ do {
+ try await Task.sleep(for: .seconds(2))
+ } catch {
+ return
+ }
+
+ activeSearchTarget = nil
+ navigationCoordinator.clearSearchRevealRequest(request)
+ }
+ return target
}
}
@@ -1561,6 +2124,10 @@ private struct PluginSettingsCardSection: View {
}
}
)
+ .pluginSettingsSearchAnchor(
+ pluginID: card.pluginID,
+ entryID: card.id
+ )
if index < cards.count - 1 {
PluginSettingsListDivider()
@@ -1640,6 +2207,10 @@ private struct PluginPermissionCardSection: View {
)
.padding(.horizontal, 16)
.padding(.vertical, 10)
+ .pluginSettingsSearchAnchor(
+ pluginID: card.pluginID,
+ entryID: card.id
+ )
if index < cards.count - 1 {
PluginSettingsListDivider()
diff --git a/Sources/App/ShortcutSettingsView.swift b/Sources/App/ShortcutSettingsView.swift
index 7b6c0770..0e19c876 100644
--- a/Sources/App/ShortcutSettingsView.swift
+++ b/Sources/App/ShortcutSettingsView.swift
@@ -64,6 +64,10 @@ struct ShortcutSettingsRowsView: View {
reset(item)
}
)
+ .pluginSettingsSearchAnchor(
+ pluginID: item.pluginID,
+ entryID: item.id
+ )
if index < items.count - 1 {
PluginSettingsListDivider()
@@ -104,6 +108,10 @@ struct GroupedShortcutSettingsRowsView: View {
onClear: clear,
onReset: reset
)
+ .pluginSettingsSearchAnchor(
+ pluginID: group.items.first?.pluginID ?? "",
+ entryID: group.id
+ )
if index < groups.count - 1 {
PluginSettingsListDivider()
diff --git a/Sources/App/UnifiedSearchPaletteView.swift b/Sources/App/UnifiedSearchPaletteView.swift
new file mode 100644
index 00000000..c8234b21
--- /dev/null
+++ b/Sources/App/UnifiedSearchPaletteView.swift
@@ -0,0 +1,724 @@
+import AppKit
+import Combine
+import SwiftUI
+import MacToolsPluginKit
+
+enum UnifiedSearchPaletteLayout {
+ static let maximumWidth: CGFloat = 672
+ static let minimumWidth: CGFloat = 560
+ static let outerHorizontalPadding: CGFloat = 48
+ static let maximumResultListHeight: CGFloat = 420
+ static let minimumResultListHeight: CGFloat = 260
+ static let verticalChromeHeight: CGFloat = 202
+
+ static func width(for availableWidth: CGFloat) -> CGFloat {
+ min(
+ maximumWidth,
+ max(minimumWidth, availableWidth - outerHorizontalPadding)
+ )
+ }
+
+ static func resultListHeight(for availableHeight: CGFloat) -> CGFloat {
+ min(
+ maximumResultListHeight,
+ max(minimumResultListHeight, availableHeight - verticalChromeHeight)
+ )
+ }
+}
+
+@MainActor
+final class UnifiedSearchPaletteModel: ObservableObject {
+ @Published private(set) var results: [MacToolsSearchResult]
+
+ private let pluginHost: PluginHost
+ private var index: MacToolsSearchIndex
+ private var query = ""
+ private var pluginHostCancellable: AnyCancellable?
+ private var rebuildTask: Task?
+
+ init(pluginHost: PluginHost) {
+ self.pluginHost = pluginHost
+ let index = MacToolsSearchIndexBuilder.build(pluginHost: pluginHost)
+ self.index = index
+ self.results = MacToolsSearchPresentation.orderedResults(
+ index.results(matching: "")
+ )
+ pluginHostCancellable = pluginHost.objectWillChange.sink { [weak self] in
+ Task { @MainActor [weak self] in
+ self?.scheduleIndexRebuild()
+ }
+ }
+ }
+
+ func updateQuery(_ query: String) {
+ guard self.query != query else {
+ return
+ }
+
+ self.query = query
+ updateResults()
+ }
+
+ func refresh() {
+ rebuildTask?.cancel()
+ index = MacToolsSearchIndexBuilder.build(pluginHost: pluginHost)
+ updateResults()
+ }
+
+ private func scheduleIndexRebuild() {
+ rebuildTask?.cancel()
+ rebuildTask = Task { @MainActor [weak self] in
+ await Task.yield()
+ guard !Task.isCancelled, let self else {
+ return
+ }
+
+ index = MacToolsSearchIndexBuilder.build(pluginHost: pluginHost)
+ updateResults()
+ }
+ }
+
+ private func updateResults() {
+ results = MacToolsSearchPresentation.orderedResults(
+ index.results(matching: query)
+ )
+ }
+}
+
+struct UnifiedSearchTextField: NSViewRepresentable {
+ enum Command: Equatable {
+ case moveSelection(Int)
+ case submit
+ case cancel
+ }
+
+ @Binding var text: String
+ let placeholder: String
+ let accessibilityLabel: String
+ let focusRequestID: UInt
+ let onCommand: (Command) -> Void
+
+ func makeCoordinator() -> Coordinator {
+ Coordinator(parent: self)
+ }
+
+ func makeNSView(context: Context) -> NSTextField {
+ let field = NSTextField()
+ field.delegate = context.coordinator
+ field.isBezeled = false
+ field.drawsBackground = false
+ field.focusRingType = .none
+ field.font = .systemFont(ofSize: NSFont.systemFontSize, weight: .medium)
+ field.lineBreakMode = .byTruncatingTail
+ field.placeholderString = placeholder
+ field.setAccessibilityLabel(accessibilityLabel)
+ field.setAccessibilityIdentifier("mactools.unified-search.field")
+ context.coordinator.focus(field, for: focusRequestID)
+ return field
+ }
+
+ func updateNSView(_ field: NSTextField, context: Context) {
+ context.coordinator.parent = self
+ field.placeholderString = placeholder
+ field.setAccessibilityLabel(accessibilityLabel)
+ if field.stringValue != text {
+ field.stringValue = text
+ }
+ context.coordinator.focus(field, for: focusRequestID)
+ }
+
+ static func command(
+ for selector: Selector,
+ hasMarkedText: Bool
+ ) -> Command? {
+ guard !hasMarkedText else {
+ return nil
+ }
+
+ switch selector {
+ case #selector(NSResponder.moveDown(_:)):
+ return .moveSelection(1)
+ case #selector(NSResponder.moveUp(_:)):
+ return .moveSelection(-1)
+ case #selector(NSResponder.insertNewline(_:)):
+ return .submit
+ case #selector(NSResponder.cancelOperation(_:)):
+ return .cancel
+ default:
+ return nil
+ }
+ }
+
+ @MainActor
+ final class Coordinator: NSObject, NSTextFieldDelegate {
+ var parent: UnifiedSearchTextField
+ private var lastFocusRequestID: UInt?
+
+ init(parent: UnifiedSearchTextField) {
+ self.parent = parent
+ }
+
+ func controlTextDidChange(_ notification: Notification) {
+ guard let field = notification.object as? NSTextField else {
+ return
+ }
+
+ parent.text = field.stringValue
+ }
+
+ func control(
+ _ control: NSControl,
+ textView: NSTextView,
+ doCommandBy selector: Selector
+ ) -> Bool {
+ guard let command = UnifiedSearchTextField.command(
+ for: selector,
+ hasMarkedText: textView.hasMarkedText()
+ ) else {
+ return false
+ }
+
+ parent.onCommand(command)
+ return true
+ }
+
+ func focus(_ field: NSTextField, for requestID: UInt) {
+ guard lastFocusRequestID != requestID else {
+ return
+ }
+
+ lastFocusRequestID = requestID
+ DispatchQueue.main.async { [weak field] in
+ field?.window?.makeFirstResponder(field)
+ }
+ }
+ }
+}
+
+struct UnifiedSearchPresentationView: View {
+ @Environment(\.accessibilityReduceTransparency) private var accessibilityReduceTransparency
+ let pluginHost: PluginHost
+ @ObservedObject var navigationCoordinator: SettingsNavigationCoordinator
+
+ var body: some View {
+ GeometryReader { geometry in
+ ZStack {
+ Color.black.opacity(accessibilityReduceTransparency ? 0.30 : 0.24)
+ .ignoresSafeArea()
+ .contentShape(Rectangle())
+ .onTapGesture {
+ navigationCoordinator.dismissUnifiedSearch()
+ }
+ .accessibilityHidden(true)
+
+ UnifiedSearchPaletteView(
+ pluginHost: pluginHost,
+ navigationCoordinator: navigationCoordinator,
+ availableSize: geometry.size
+ )
+ .padding(24)
+ }
+ }
+ }
+}
+
+struct UnifiedSearchPaletteView: View {
+ private enum Layout {
+ static let rowCornerRadius: CGFloat = 8
+ }
+
+ let pluginHost: PluginHost
+ @ObservedObject var navigationCoordinator: SettingsNavigationCoordinator
+ let availableSize: CGSize
+ @StateObject private var model: UnifiedSearchPaletteModel
+ @State private var query = ""
+ @State private var selectedResultID: String?
+ @State private var pendingConfirmation: MacToolsSearchResult?
+
+ init(
+ pluginHost: PluginHost,
+ navigationCoordinator: SettingsNavigationCoordinator,
+ availableSize: CGSize
+ ) {
+ self.pluginHost = pluginHost
+ self.navigationCoordinator = navigationCoordinator
+ self.availableSize = availableSize
+ _model = StateObject(
+ wrappedValue: UnifiedSearchPaletteModel(pluginHost: pluginHost)
+ )
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ searchField
+
+ metadataRow
+
+ resultList
+
+ footer
+ }
+ .padding(16)
+ .frame(width: UnifiedSearchPaletteLayout.width(for: availableSize.width))
+ .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
+ .overlay {
+ RoundedRectangle(cornerRadius: 14, style: .continuous)
+ .strokeBorder(Color(nsColor: .separatorColor).opacity(0.45), lineWidth: 1)
+ .allowsHitTesting(false)
+ }
+ .shadow(color: .black.opacity(0.22), radius: 28, y: 12)
+ .onAppear {
+ syncSelection()
+ handleQuickSelectionRequest(
+ navigationCoordinator.unifiedSearchQuickSelectionRequest
+ )
+ }
+ .onChange(of: query) {
+ model.updateQuery(query)
+ syncSelection()
+ }
+ .onChange(of: resultIDs) {
+ syncSelection()
+ }
+ .onChange(of: navigationCoordinator.unifiedSearchQuickSelectionRequest) { _, request in
+ handleQuickSelectionRequest(request)
+ }
+ .onExitCommand {
+ navigationCoordinator.dismissUnifiedSearch()
+ }
+ .alert(item: $pendingConfirmation) { result in
+ let confirmation = result.confirmation
+ return Alert(
+ title: Text(confirmation?.title ?? result.title),
+ message: Text(confirmation?.message ?? result.detail),
+ primaryButton: .destructive(
+ Text(
+ confirmation?.confirmButtonTitle
+ ?? AppL10n.search("search.action.run", defaultValue: "执行")
+ )
+ ) {
+ execute(result)
+ },
+ secondaryButton: .cancel()
+ )
+ }
+ .accessibilityElement(children: .contain)
+ .accessibilityLabel(
+ AppL10n.search("search.title", defaultValue: "搜索 MacTools")
+ )
+ .accessibilityIdentifier("mactools.unified-search.palette")
+ }
+
+ private var searchField: some View {
+ HStack(spacing: 8) {
+ Image(systemName: "magnifyingglass")
+ .foregroundStyle(.secondary)
+
+ UnifiedSearchTextField(
+ text: $query,
+ placeholder: AppL10n.search(
+ "search.prompt",
+ defaultValue: "搜索插件、设置和命令"
+ ),
+ accessibilityLabel: AppL10n.search(
+ "search.title",
+ defaultValue: "搜索 MacTools"
+ ),
+ focusRequestID: navigationCoordinator.unifiedSearchFocusRequestID,
+ onCommand: handleSearchFieldCommand
+ )
+ .frame(maxWidth: .infinity, minHeight: 22)
+
+ if !query.isEmpty {
+ Button {
+ query = ""
+ } label: {
+ Image(systemName: "xmark.circle.fill")
+ .foregroundStyle(.secondary)
+ }
+ .buttonStyle(.plain)
+ .help(AppL10n.search("search.clear", defaultValue: "清除搜索"))
+ .accessibilityLabel(
+ AppL10n.search("search.clear", defaultValue: "清除搜索")
+ )
+ }
+
+ Text("⌘K")
+ .font(PluginSettingsTheme.Typography.statusBadge)
+ .foregroundStyle(.secondary)
+ .padding(.horizontal, 6)
+ .padding(.vertical, 2)
+ .background(
+ Capsule(style: .continuous)
+ .fill(Color(nsColor: .quaternaryLabelColor).opacity(0.12))
+ )
+ .accessibilityHidden(true)
+
+ Button {
+ navigationCoordinator.dismissUnifiedSearch()
+ } label: {
+ Text("Esc")
+ }
+ .buttonStyle(.borderless)
+ .controlSize(.small)
+ .help(AppL10n.search("search.close", defaultValue: "关闭搜索"))
+ .accessibilityLabel(
+ AppL10n.search("search.close", defaultValue: "关闭搜索")
+ )
+ }
+ .padding(.horizontal, 12)
+ .padding(.vertical, 9)
+ .background(
+ RoundedRectangle(cornerRadius: 9, style: .continuous)
+ .fill(Color(nsColor: .controlBackgroundColor))
+ )
+ .overlay {
+ RoundedRectangle(cornerRadius: 9, style: .continuous)
+ .strokeBorder(Color(nsColor: .separatorColor).opacity(0.5), lineWidth: 1)
+ }
+ }
+
+ private var metadataRow: some View {
+ HStack {
+ Text(originText)
+ Spacer()
+ Text(resultCountText)
+ }
+ .font(PluginSettingsTheme.Typography.secondaryLabel)
+ .foregroundStyle(.secondary)
+ .accessibilityElement(children: .combine)
+ }
+
+ private var resultList: some View {
+ ScrollViewReader { proxy in
+ ScrollView {
+ if results.isEmpty {
+ ContentUnavailableView(
+ AppL10n.search("search.empty.title", defaultValue: "未找到结果"),
+ systemImage: "magnifyingglass",
+ description: Text(
+ AppL10n.search(
+ "search.empty.description",
+ defaultValue: "尝试插件名称、设置、功能或命令。"
+ )
+ )
+ )
+ .frame(maxWidth: .infinity, minHeight: 220)
+ } else {
+ LazyVStack(alignment: .leading, spacing: 6) {
+ ForEach(MacToolsSearchResultKind.allCases, id: \.self) { kind in
+ let group = results.filter { $0.kind == kind }
+ if !group.isEmpty {
+ Text(kind.title)
+ .font(PluginSettingsTheme.Typography.secondaryLabel)
+ .foregroundStyle(.secondary)
+ .padding(.horizontal, 8)
+ .padding(.top, 4)
+
+ ForEach(group) { result in
+ resultRow(
+ result,
+ quickSelectionNumber: quickSelectionNumber(for: result)
+ )
+ .id(result.id)
+ }
+ }
+ }
+ }
+ }
+ }
+ .frame(
+ height: UnifiedSearchPaletteLayout.resultListHeight(
+ for: availableSize.height
+ )
+ )
+ .onChange(of: selectedResultID) { _, resultID in
+ guard let resultID else {
+ return
+ }
+
+ withAnimation(.easeOut(duration: 0.12)) {
+ proxy.scrollTo(resultID, anchor: .center)
+ }
+ }
+ }
+ }
+
+ private func resultRow(
+ _ result: MacToolsSearchResult,
+ quickSelectionNumber: Int?
+ ) -> some View {
+ let isSelected = result.id == selectedResultID
+
+ return Button {
+ selectedResultID = result.id
+ activate(result)
+ } label: {
+ HStack(spacing: 12) {
+ Image(systemName: result.systemImage)
+ .frame(width: 18)
+ .foregroundStyle(isSelected ? Color.white : Color.accentColor)
+
+ VStack(alignment: .leading, spacing: 3) {
+ Text(result.title)
+ .font(PluginSettingsTheme.Typography.rowTitle)
+ .foregroundStyle(isSelected ? Color.white : Color.primary)
+ .lineLimit(1)
+
+ Text(result.subtitle)
+ .font(PluginSettingsTheme.Typography.rowDescription)
+ .foregroundStyle(isSelected ? Color.white.opacity(0.78) : Color.secondary)
+ .lineLimit(1)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+
+ if let quickSelectionNumber {
+ Text("⌘\(quickSelectionNumber)")
+ .font(PluginSettingsTheme.Typography.statusBadge)
+ .foregroundStyle(isSelected ? Color.white.opacity(0.78) : Color.secondary)
+ .accessibilityHidden(true)
+ }
+
+ Text(result.kind.actionTitle)
+ .font(PluginSettingsTheme.Typography.statusBadge)
+ .foregroundStyle(isSelected ? Color.white : Color.accentColor)
+ .padding(.horizontal, 7)
+ .padding(.vertical, 3)
+ .background(
+ Capsule(style: .continuous)
+ .fill(
+ isSelected
+ ? Color.white.opacity(0.16)
+ : Color.accentColor.opacity(0.1)
+ )
+ )
+ }
+ .padding(.horizontal, 10)
+ .padding(.vertical, 8)
+ .background(
+ RoundedRectangle(cornerRadius: Layout.rowCornerRadius, style: .continuous)
+ .fill(isSelected ? Color.accentColor : Color.clear)
+ )
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel(result.accessibilityLabel)
+ .accessibilityHint(accessibilityHint(for: result, number: quickSelectionNumber))
+ .accessibilityAddTraits(isSelected ? .isSelected : [])
+ .accessibilityIdentifier("mactools.unified-search.result.\(result.id)")
+ }
+
+ private var footer: some View {
+ HStack {
+ if let selectedResult {
+ Text(
+ AppL10n.searchFormat(
+ "search.footer.actionFormat",
+ defaultValue: "%@“%@”",
+ selectedResult.kind.actionTitle,
+ selectedResult.title
+ )
+ )
+ .lineLimit(1)
+ } else {
+ Text(
+ AppL10n.search(
+ "search.footer.tryAnotherQuery",
+ defaultValue: "尝试其他关键词"
+ )
+ )
+ }
+
+ Spacer()
+
+ Text(
+ AppL10n.search(
+ "search.footer.keyboard",
+ defaultValue: "↑↓ 选择 ↩ 打开 ⌘1–9 快速打开"
+ )
+ )
+ }
+ .font(PluginSettingsTheme.Typography.secondaryLabel)
+ .foregroundStyle(.secondary)
+ .padding(.top, 8)
+ .overlay(alignment: .top) {
+ Divider()
+ }
+ }
+
+ private var results: [MacToolsSearchResult] {
+ model.results
+ }
+
+ private var resultIDs: [String] {
+ results.map(\.id)
+ }
+
+ private var selectedResult: MacToolsSearchResult? {
+ guard let selectedResultID else {
+ return nil
+ }
+
+ return results.first { $0.id == selectedResultID }
+ }
+
+ private var resultCountText: String {
+ AppL10n.searchPluralFormat(
+ "search.resultCountFormat",
+ defaultValue: "%d 个结果",
+ count: results.count
+ )
+ }
+
+ private var originText: String {
+ switch navigationCoordinator.unifiedSearchPresentationOrigin {
+ case .pluginSidebar:
+ return AppL10n.search(
+ "search.origin.pluginSidebar",
+ defaultValue: "来自插件导航"
+ )
+ case .keyboard:
+ return AppL10n.search(
+ "search.origin.keyboard",
+ defaultValue: "全局快捷键 ⌘K"
+ )
+ case nil:
+ return AppL10n.search(
+ "search.title",
+ defaultValue: "搜索 MacTools"
+ )
+ }
+ }
+
+ private func quickSelectionNumber(
+ for result: MacToolsSearchResult
+ ) -> Int? {
+ MacToolsSearchPresentation.quickSelectionNumber(
+ for: result.id,
+ in: results
+ )
+ }
+
+ private func accessibilityHint(
+ for result: MacToolsSearchResult,
+ number: Int?
+ ) -> String {
+ guard let number else {
+ return result.detail
+ }
+
+ return "⌘\(number). \(result.detail)"
+ }
+
+ private func syncSelection() {
+ let availableResults = results
+ if let selectedResultID,
+ availableResults.contains(where: { $0.id == selectedResultID }) {
+ return
+ }
+
+ selectedResultID = availableResults.first?.id
+ }
+
+ private func moveSelection(by offset: Int) {
+ let availableResults = results
+ guard !availableResults.isEmpty else {
+ return
+ }
+
+ let currentIndex = selectedResultID.flatMap { selectedID in
+ availableResults.firstIndex { $0.id == selectedID }
+ } ?? 0
+ let nextIndex = (currentIndex + offset + availableResults.count) % availableResults.count
+ let result = availableResults[nextIndex]
+ selectedResultID = result.id
+ announceSelection(result)
+ }
+
+ private func announceSelection(_ result: MacToolsSearchResult) {
+ NSAccessibility.post(
+ element: NSApplication.shared,
+ notification: .announcementRequested,
+ userInfo: [
+ .announcement: result.accessibilityLabel,
+ .priority: NSAccessibilityPriorityLevel.high.rawValue
+ ]
+ )
+ }
+
+ private func activateSelectedResult() {
+ guard let selectedResult else {
+ return
+ }
+
+ activate(selectedResult)
+ }
+
+ private func handleSearchFieldCommand(
+ _ command: UnifiedSearchTextField.Command
+ ) {
+ switch command {
+ case let .moveSelection(offset):
+ moveSelection(by: offset)
+ case .submit:
+ activateSelectedResult()
+ case .cancel:
+ navigationCoordinator.dismissUnifiedSearch()
+ }
+ }
+
+ private func handleQuickSelectionRequest(
+ _ request: UnifiedSearchQuickSelectionRequest?
+ ) {
+ guard
+ let request,
+ navigationCoordinator.consumeUnifiedSearchQuickSelectionRequest(request)
+ else {
+ return
+ }
+
+ guard results.indices.contains(request.number - 1) else {
+ return
+ }
+
+ let result = results[request.number - 1]
+ selectedResultID = result.id
+ activate(result)
+ }
+
+ private func activate(_ result: MacToolsSearchResult) {
+ if result.confirmation != nil {
+ pendingConfirmation = result
+ } else {
+ execute(result)
+ }
+ }
+
+ private func execute(_ result: MacToolsSearchResult) {
+ switch result.action {
+ case let .navigate(destination, target):
+ if !navigationCoordinator.navigateFromSearch(
+ to: destination,
+ target: target
+ ) {
+ model.refresh()
+ }
+ case let .pluginCommand(pluginID, expectedDefinition):
+ if pluginHost.performCommand(
+ pluginID: pluginID,
+ expectedDefinition: expectedDefinition
+ ) {
+ navigationCoordinator.dismissUnifiedSearch()
+ } else {
+ model.refresh()
+ }
+ case let .appCommand(action):
+ if pluginHost.performAppCommand(action) {
+ navigationCoordinator.dismissUnifiedSearch()
+ } else {
+ model.refresh()
+ }
+ }
+ }
+
+}
diff --git a/Sources/Core/Plugins/PluginHost.swift b/Sources/Core/Plugins/PluginHost.swift
index 892006b3..1c7e16bf 100644
--- a/Sources/Core/Plugins/PluginHost.swift
+++ b/Sources/Core/Plugins/PluginHost.swift
@@ -150,6 +150,25 @@ struct AppShortcutSettingsItem: Identifiable, Equatable {
}
}
+struct PluginProvidedSettingsSearchItem: Identifiable, Hashable {
+ let pluginID: String
+ let entry: PluginSettingsSearchEntry
+
+ var id: String {
+ "\(pluginID).settings-search.\(entry.id)"
+ }
+}
+
+struct PluginCommandItem: Identifiable, Hashable {
+ let pluginID: String
+ let pluginTitle: String
+ let definition: PluginCommandDefinition
+
+ var id: String {
+ "\(pluginID).command.\(definition.id)"
+ }
+}
+
struct PluginAutomaticUpdateStatus: Equatable {
enum Phase: Equatable {
case idle
@@ -328,6 +347,8 @@ final class PluginHost: ObservableObject {
@Published private(set) var settingsCards: [PluginSettingsCard] = []
@Published private(set) var shortcutItems: [ShortcutSettingsItem] = []
@Published private(set) var appShortcutItems: [AppShortcutSettingsItem] = []
+ @Published private(set) var pluginSettingsSearchItems: [PluginProvidedSettingsSearchItem] = []
+ @Published private(set) var pluginCommandItems: [PluginCommandItem] = []
@Published private(set) var pluginManagementItems: [PluginManagementItem] = []
@Published private(set) var pluginCatalogStatus: PluginCatalogStatus = .unavailable
@Published private(set) var automaticPluginUpdateStatus: PluginAutomaticUpdateStatus = .idle
@@ -789,6 +810,43 @@ final class PluginHost: ObservableObject {
}
}
+ @discardableResult
+ func performCommand(
+ pluginID: String,
+ expectedDefinition: PluginCommandDefinition
+ ) -> Bool {
+ guard
+ let plugin = corePlugin(for: pluginID),
+ let commandProvider = plugin as? any PluginCommandProviding,
+ (guardedValue(
+ for: plugin,
+ operation: "read command definitions",
+ commandProvider.commandDefinitions
+ ) ?? []).contains(expectedDefinition)
+ else {
+ rebuildDerivedState()
+ return false
+ }
+
+ var didPerform = false
+ handlePluginAction {
+ didPerform = guardPluginCall(plugin, operation: "perform command") {
+ commandProvider.handleCommand(id: expectedDefinition.id)
+ }
+ }
+ return didPerform
+ }
+
+ @discardableResult
+ func performAppCommand(_ action: AppShortcutAction) -> Bool {
+ guard let appPresentationHandler else {
+ return false
+ }
+
+ appPresentationHandler(action.presentationRequest)
+ return true
+ }
+
func performPermissionAction(pluginID: String, permissionID: String) {
guard let plugin = corePlugin(for: pluginID) else {
return
@@ -932,6 +990,42 @@ final class PluginHost: ObservableObject {
pluginConfigurationItems.contains(where: { $0.id == pluginID })
}
+ func hasPluginSettingsSearchTarget(
+ _ target: PluginSettingsSearchTarget
+ ) -> Bool {
+ cancelScheduledPluginStateRebuild()
+ rebuildDerivedState()
+
+ guard let item = pluginConfigurationItems.first(where: {
+ $0.pluginID == target.pluginID
+ }) else {
+ return false
+ }
+
+ if item.settingsCards.contains(where: { $0.id == target.entryID })
+ || item.permissionCards.contains(where: { $0.id == target.entryID }) {
+ return true
+ }
+
+ if item.shortcutItems.allSatisfy({ $0.settingsGroupID != nil }) {
+ if item.shortcutItems.contains(where: {
+ $0.settingsGroupID == target.entryID
+ }) {
+ return true
+ }
+ } else if item.shortcutItems.contains(where: {
+ $0.id == target.entryID
+ }) {
+ return true
+ }
+
+ return item.hasCustomConfiguration
+ && pluginSettingsSearchItems.contains {
+ $0.pluginID == target.pluginID
+ && $0.entry.id == target.entryID
+ }
+ }
+
func clearShortcutError(for shortcutID: String) {
guard shortcutErrors.removeValue(forKey: shortcutID) != nil else {
return
@@ -1846,7 +1940,11 @@ final class PluginHost: ObservableObject {
isRequired: descriptor.definition.isRequired,
canClear: !descriptor.definition.isRequired && binding != nil,
usesDefaultValue: customization == .inheritDefault,
- errorMessage: shortcutErrors[descriptor.itemID],
+ errorMessage: shortcutErrors[descriptor.itemID]
+ ?? binding.flatMap {
+ MacToolsReservedShortcutBindings.validationError(for: $0)?
+ .localizedDescription
+ },
settingsGroupID: descriptor.definition.settingsGroupID,
settingsGroupTitle: descriptor.definition.settingsGroupTitle,
settingsGroupDescription: descriptor.definition.settingsGroupDescription,
@@ -1864,13 +1962,53 @@ final class PluginHost: ObservableObject {
systemImage: action.systemImage,
bindingText: ShortcutFormatter.displayString(for: binding),
canClear: binding != nil,
- errorMessage: appShortcutErrors[action] ?? appShortcutConflictError(
- for: action,
- descriptors: shortcutDescriptors
- )
+ errorMessage: appShortcutErrors[action]
+ ?? binding.flatMap {
+ MacToolsReservedShortcutBindings.validationError(for: $0)?
+ .localizedDescription
+ }
+ ?? appShortcutConflictError(
+ for: action,
+ descriptors: shortcutDescriptors
+ )
)
}
+ pluginSettingsSearchItems = orderedCorePlugins().flatMap { plugin -> [PluginProvidedSettingsSearchItem] in
+ guard let provider = plugin as? any PluginSettingsSearchProviding else {
+ return []
+ }
+
+ let entries = guardedValue(
+ for: plugin,
+ operation: "read settings search entries",
+ provider.settingsSearchEntries
+ ) ?? []
+ return entries.map {
+ PluginProvidedSettingsSearchItem(pluginID: plugin.metadata.id, entry: $0)
+ }
+ }
+
+ pluginCommandItems = orderedPluginDescriptors().flatMap { descriptor -> [PluginCommandItem] in
+ let plugin = descriptor.plugin
+ guard let provider = plugin as? any PluginCommandProviding else {
+ return []
+ }
+
+ let definitions = guardedValue(
+ for: plugin,
+ operation: "read command definitions",
+ provider.commandDefinitions
+ ) ?? []
+ return definitions.map {
+ PluginCommandItem(
+ pluginID: plugin.metadata.id,
+ pluginTitle: descriptor.metadata.title,
+ definition: $0
+ )
+ }
+ }
+
pluginConfigurationItems = buildPluginConfigurationItems(
settingsCards: settingsCards,
permissionCards: permissionCards,
@@ -1994,20 +2132,22 @@ final class PluginHost: ObservableObject {
}
}
+ @discardableResult
private func guardPluginCall(
_ plugin: any MacToolsPlugin,
operation: String,
_ action: () -> Void
- ) {
+ ) -> Bool {
guard !isPluginIsolated(plugin) else {
- return
+ return false
}
switch PluginInvocationGuard.run(operation: operation, action) {
case .success:
- break
+ return true
case let .failure(failure):
isolatePlugin(plugin, operation: operation, failure: failure)
+ return false
}
}
@@ -2703,6 +2843,10 @@ final class PluginHost: ObservableObject {
errors[action.rawValue] = ShortcutValidationError.missingModifier.localizedDescription
} else if ShortcutKeyCode.isModifier(binding.keyCode) {
errors[action.rawValue] = ShortcutValidationError.modifierOnly.localizedDescription
+ } else if let error = MacToolsReservedShortcutBindings.validationError(
+ for: binding
+ ) {
+ errors[action.rawValue] = error.localizedDescription
}
}
}
@@ -2728,6 +2872,12 @@ final class PluginHost: ObservableObject {
guard !ShortcutKeyCode.isModifier(binding.keyCode) else {
throw ShortcutValidationError.modifierOnly
}
+
+ if let error = MacToolsReservedShortcutBindings.validationError(
+ for: binding
+ ) {
+ throw error
+ }
}
} catch {
errors[descriptor.itemID] = error.localizedDescription
@@ -2847,6 +2997,12 @@ final class PluginHost: ObservableObject {
throw ShortcutValidationError.modifierOnly
}
+ if let error = MacToolsReservedShortcutBindings.validationError(
+ for: candidate
+ ) {
+ throw error
+ }
+
if let conflict = shortcutDescriptors().first(where: {
$0.itemID != descriptor.itemID
&& resolvedBinding(for: $0) == candidate
@@ -2879,6 +3035,12 @@ final class PluginHost: ObservableObject {
throw ShortcutValidationError.modifierOnly
}
+ if let error = MacToolsReservedShortcutBindings.validationError(
+ for: binding
+ ) {
+ throw error
+ }
+
if let conflict = pluginShortcutConflict(
for: binding,
descriptors: shortcutDescriptors()
@@ -2927,6 +3089,10 @@ final class PluginHost: ObservableObject {
return nil
}
+ guard MacToolsReservedShortcutBindings.validationError(for: binding) == nil else {
+ return nil
+ }
+
return GlobalShortcutManager.Registration(
shortcutID: descriptor.itemID,
binding: binding
@@ -2935,7 +3101,8 @@ final class PluginHost: ObservableObject {
for action in AppShortcutAction.allCases {
guard let binding = resolvedAppShortcutBinding(for: action),
- pluginShortcutConflict(for: binding, descriptors: descriptors) == nil
+ pluginShortcutConflict(for: binding, descriptors: descriptors) == nil,
+ MacToolsReservedShortcutBindings.validationError(for: binding) == nil
else {
continue
}
diff --git a/Sources/Core/Shortcuts/GlobalShortcutManager.swift b/Sources/Core/Shortcuts/GlobalShortcutManager.swift
index 1a9d8c6d..73c258af 100644
--- a/Sources/Core/Shortcuts/GlobalShortcutManager.swift
+++ b/Sources/Core/Shortcuts/GlobalShortcutManager.swift
@@ -1,6 +1,56 @@
import Carbon
import MacToolsPluginKit
+enum MacToolsReservedShortcutBindings {
+ private static let commandBindings: Set = [
+ kVK_ANSI_Comma,
+ kVK_ANSI_F,
+ kVK_ANSI_K,
+ kVK_ANSI_1,
+ kVK_ANSI_2,
+ kVK_ANSI_3,
+ kVK_ANSI_4,
+ kVK_ANSI_5,
+ kVK_ANSI_6,
+ kVK_ANSI_7,
+ kVK_ANSI_8,
+ kVK_ANSI_9,
+ kVK_ANSI_LeftBracket,
+ kVK_ANSI_RightBracket
+ ].reduce(into: Set()) { bindings, keyCode in
+ bindings.insert(
+ ShortcutBinding(
+ keyCode: UInt16(keyCode),
+ modifiers: .command
+ )
+ )
+ }
+
+ private static let pluginNavigationBindings: Set = [
+ kVK_UpArrow,
+ kVK_DownArrow
+ ].reduce(into: Set()) { bindings, keyCode in
+ bindings.insert(
+ ShortcutBinding(
+ keyCode: UInt16(keyCode),
+ modifiers: [.control, .command]
+ )
+ )
+ }
+
+ static let all = commandBindings.union(pluginNavigationBindings)
+
+ static func validationError(
+ for binding: ShortcutBinding
+ ) -> ShortcutValidationError? {
+ guard all.contains(binding) else {
+ return nil
+ }
+
+ return .duplicate(ownerDescription: AppMetadata.appName)
+ }
+}
+
@MainActor
final class GlobalShortcutManager {
struct Registration: Equatable {
diff --git a/Sources/MacToolsPluginKit/PluginSearchModels.swift b/Sources/MacToolsPluginKit/PluginSearchModels.swift
new file mode 100644
index 00000000..f277c6da
--- /dev/null
+++ b/Sources/MacToolsPluginKit/PluginSearchModels.swift
@@ -0,0 +1,142 @@
+import SwiftUI
+
+public struct PluginSettingsSearchEntry: Identifiable, Hashable {
+ public let id: String
+ public let title: String
+ public let description: String
+ public let keywords: [String]
+ public let systemImage: String
+
+ public init(
+ id: String,
+ title: String,
+ description: String,
+ keywords: [String] = [],
+ systemImage: String = "slider.horizontal.3"
+ ) {
+ self.id = id
+ self.title = title
+ self.description = description
+ self.keywords = keywords
+ self.systemImage = systemImage
+ }
+}
+
+@MainActor
+public protocol PluginSettingsSearchProviding: AnyObject {
+ var settingsSearchEntries: [PluginSettingsSearchEntry] { get }
+}
+
+public struct PluginCommandDefinition: Identifiable, Hashable {
+ public struct Confirmation: Hashable {
+ public let title: String
+ public let message: String
+ public let confirmButtonTitle: String
+
+ public init(title: String, message: String, confirmButtonTitle: String) {
+ self.title = title
+ self.message = message
+ self.confirmButtonTitle = confirmButtonTitle
+ }
+ }
+
+ public let id: String
+ public let title: String
+ public let description: String
+ public let keywords: [String]
+ public let systemImage: String
+ public let confirmation: Confirmation?
+
+ public init(
+ id: String,
+ title: String,
+ description: String,
+ keywords: [String] = [],
+ systemImage: String,
+ confirmation: Confirmation? = nil
+ ) {
+ self.id = id
+ self.title = title
+ self.description = description
+ self.keywords = keywords
+ self.systemImage = systemImage
+ self.confirmation = confirmation
+ }
+}
+
+@MainActor
+public protocol PluginCommandProviding: AnyObject {
+ var commandDefinitions: [PluginCommandDefinition] { get }
+
+ func handleCommand(id: String)
+}
+
+public struct PluginSettingsSearchTarget: Hashable, Sendable {
+ public let pluginID: String
+ public let entryID: String
+
+ public init(pluginID: String, entryID: String) {
+ self.pluginID = pluginID
+ self.entryID = entryID
+ }
+
+ public var scrollID: String {
+ "plugin-search-anchor.\(pluginID).\(entryID)"
+ }
+}
+
+private struct PluginSettingsSearchTargetEnvironmentKey: EnvironmentKey {
+ static let defaultValue: PluginSettingsSearchTarget? = nil
+}
+
+public extension EnvironmentValues {
+ var pluginSettingsSearchTarget: PluginSettingsSearchTarget? {
+ get { self[PluginSettingsSearchTargetEnvironmentKey.self] }
+ set { self[PluginSettingsSearchTargetEnvironmentKey.self] = newValue }
+ }
+}
+
+private struct PluginSettingsSearchAnchorModifier: ViewModifier {
+ @Environment(\.pluginSettingsSearchTarget) private var activeTarget
+ @AccessibilityFocusState private var isAccessibilityFocused: Bool
+
+ let target: PluginSettingsSearchTarget
+
+ func body(content: Content) -> some View {
+ content
+ .id(target.scrollID)
+ .accessibilityFocused($isAccessibilityFocused)
+ .overlay {
+ if activeTarget == target {
+ RoundedRectangle(cornerRadius: PluginSettingsTheme.Radius.card, style: .continuous)
+ .stroke(Color.accentColor, lineWidth: 2)
+ .allowsHitTesting(false)
+ .accessibilityHidden(true)
+ }
+ }
+ .onAppear {
+ focusIfNeeded(activeTarget)
+ }
+ .onChange(of: activeTarget) { _, newValue in
+ focusIfNeeded(newValue)
+ }
+ }
+
+ private func focusIfNeeded(_ activeTarget: PluginSettingsSearchTarget?) {
+ guard activeTarget == target else {
+ return
+ }
+
+ isAccessibilityFocused = true
+ }
+}
+
+public extension View {
+ func pluginSettingsSearchAnchor(pluginID: String, entryID: String) -> some View {
+ modifier(
+ PluginSettingsSearchAnchorModifier(
+ target: PluginSettingsSearchTarget(pluginID: pluginID, entryID: entryID)
+ )
+ )
+ }
+}
diff --git a/Sources/Resources/Localization/InfoPlist.xcstrings b/Sources/Resources/Localization/InfoPlist.xcstrings
index 66118b60..cf365bce 100644
--- a/Sources/Resources/Localization/InfoPlist.xcstrings
+++ b/Sources/Resources/Localization/InfoPlist.xcstrings
@@ -72,6 +72,77 @@
}
}
},
+ "NSAudioCaptureUsageDescription": {
+ "extractionState": "manual",
+ "localizations": {
+ "ar": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "يعالج MacTools صوت التطبيقات محليًا وفي الوقت الفعلي فقط عند ضبط مستوى صوت كل تطبيق، ولا يسجل الصوت أو يحفظه أو يرفعه."
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "MacTools verarbeitet App-Audio nur beim Anpassen der Lautstärke einzelner Apps lokal in Echtzeit und zeichnet, speichert oder lädt keinen Ton hoch."
+ }
+ },
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "MacTools processes app audio locally in real time only when you adjust per-app volume, and never records, saves, or uploads audio."
+ }
+ },
+ "es": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "MacTools procesa el audio de las aplicaciones localmente y en tiempo real solo al ajustar el volumen de cada aplicación; nunca graba, guarda ni sube audio."
+ }
+ },
+ "fr": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "MacTools traite localement et en temps réel le son des apps uniquement lors du réglage de leur volume, sans jamais l’enregistrer, le sauvegarder ni le téléverser."
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "MacTools はアプリごとの音量を調整するときだけ、アプリの音声をローカルでリアルタイム処理し、録音、保存、アップロードは行いません。"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "MacTools는 앱별 음량을 조절할 때만 앱 오디오를 기기에서 실시간으로 처리하며 녹음, 저장 또는 업로드하지 않습니다."
+ }
+ },
+ "pt": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "O MacTools processa o áudio dos apps localmente e em tempo real apenas ao ajustar o volume de cada app, sem gravar, salvar ou enviar áudio."
+ }
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "MacTools обрабатывает звук приложений локально в реальном времени только при настройке их громкости и не записывает, не сохраняет и не загружает аудио."
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "仅在调节应用独立音量时于本机实时处理系统音频,不会录制、保存或上传声音。"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "僅在調整應用程式獨立音量時於本機即時處理系統音訊,不會錄製、儲存或上傳聲音。"
+ }
+ }
+ }
+ },
"NSBluetoothAlwaysUsageDescription": {
"extractionState": "manual",
"localizations": {
diff --git a/Sources/Resources/Localization/Plugins.xcstrings b/Sources/Resources/Localization/Plugins.xcstrings
index 041bf96d..421e1518 100644
--- a/Sources/Resources/Localization/Plugins.xcstrings
+++ b/Sources/Resources/Localization/Plugins.xcstrings
@@ -7673,6 +7673,77 @@
}
}
},
+ "plugin.marketplace.description": {
+ "extractionState": "manual",
+ "localizations": {
+ "ar": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "ثبّت إضافات MacTools وحدّثها وأدرها."
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Installiere, aktualisiere und verwalte MacTools-Plugins."
+ }
+ },
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Install, update, and manage MacTools plugins."
+ }
+ },
+ "es": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Instala, actualiza y administra plugins de MacTools."
+ }
+ },
+ "fr": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Installez, mettez à jour et gérez les modules MacTools."
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "MacToolsプラグインのインストール、アップデート、管理を行います。"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "MacTools 플러그인을 설치, 업데이트 및 관리합니다."
+ }
+ },
+ "pt": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Instale, atualize e gerencie plugins do MacTools."
+ }
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Устанавливайте, обновляйте плагины MacTools и управляйте ими."
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "安装、更新和管理 MacTools 插件。"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "安裝、更新及管理 MacTools 外掛。"
+ }
+ }
+ }
+ },
"plugin.marketplace.title": {
"extractionState": "manual",
"localizations": {
diff --git a/Sources/Resources/Localization/Search.xcstrings b/Sources/Resources/Localization/Search.xcstrings
new file mode 100644
index 00000000..16201d30
--- /dev/null
+++ b/Sources/Resources/Localization/Search.xcstrings
@@ -0,0 +1,123 @@
+{
+ "sourceLanguage": "zh-Hans",
+ "strings": {
+ "search.action.goTo": { "extractionState": "manual", "localizations": {
+ "ar": { "stringUnit": { "state": "translated", "value": "انتقال" } }, "de": { "stringUnit": { "state": "translated", "value": "Gehe zu" } }, "en": { "stringUnit": { "state": "translated", "value": "Go To" } }, "es": { "stringUnit": { "state": "translated", "value": "Ir a" } }, "fr": { "stringUnit": { "state": "translated", "value": "Accéder" } }, "ja": { "stringUnit": { "state": "translated", "value": "移動" } }, "ko": { "stringUnit": { "state": "translated", "value": "이동" } }, "pt": { "stringUnit": { "state": "translated", "value": "Ir para" } }, "ru": { "stringUnit": { "state": "translated", "value": "Перейти" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "前往" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "前往" } }
+ } },
+ "search.action.open": { "extractionState": "manual", "localizations": {
+ "ar": { "stringUnit": { "state": "translated", "value": "فتح" } }, "de": { "stringUnit": { "state": "translated", "value": "Öffnen" } }, "en": { "stringUnit": { "state": "translated", "value": "Open" } }, "es": { "stringUnit": { "state": "translated", "value": "Abrir" } }, "fr": { "stringUnit": { "state": "translated", "value": "Ouvrir" } }, "ja": { "stringUnit": { "state": "translated", "value": "開く" } }, "ko": { "stringUnit": { "state": "translated", "value": "열기" } }, "pt": { "stringUnit": { "state": "translated", "value": "Abrir" } }, "ru": { "stringUnit": { "state": "translated", "value": "Открыть" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "打开" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "開啟" } }
+ } },
+ "search.action.run": { "extractionState": "manual", "localizations": {
+ "ar": { "stringUnit": { "state": "translated", "value": "تشغيل" } }, "de": { "stringUnit": { "state": "translated", "value": "Ausführen" } }, "en": { "stringUnit": { "state": "translated", "value": "Run" } }, "es": { "stringUnit": { "state": "translated", "value": "Ejecutar" } }, "fr": { "stringUnit": { "state": "translated", "value": "Exécuter" } }, "ja": { "stringUnit": { "state": "translated", "value": "実行" } }, "ko": { "stringUnit": { "state": "translated", "value": "실행" } }, "pt": { "stringUnit": { "state": "translated", "value": "Executar" } }, "ru": { "stringUnit": { "state": "translated", "value": "Выполнить" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "执行" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "執行" } }
+ } },
+ "search.clear": { "extractionState": "manual", "localizations": {
+ "ar": { "stringUnit": { "state": "translated", "value": "مسح البحث" } }, "de": { "stringUnit": { "state": "translated", "value": "Suche löschen" } }, "en": { "stringUnit": { "state": "translated", "value": "Clear Search" } }, "es": { "stringUnit": { "state": "translated", "value": "Borrar búsqueda" } }, "fr": { "stringUnit": { "state": "translated", "value": "Effacer la recherche" } }, "ja": { "stringUnit": { "state": "translated", "value": "検索を消去" } }, "ko": { "stringUnit": { "state": "translated", "value": "검색 지우기" } }, "pt": { "stringUnit": { "state": "translated", "value": "Limpar busca" } }, "ru": { "stringUnit": { "state": "translated", "value": "Очистить поиск" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "清除搜索" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "清除搜尋" } }
+ } },
+ "search.close": { "extractionState": "manual", "localizations": {
+ "ar": { "stringUnit": { "state": "translated", "value": "إغلاق البحث" } }, "de": { "stringUnit": { "state": "translated", "value": "Suche schließen" } }, "en": { "stringUnit": { "state": "translated", "value": "Close Search" } }, "es": { "stringUnit": { "state": "translated", "value": "Cerrar búsqueda" } }, "fr": { "stringUnit": { "state": "translated", "value": "Fermer la recherche" } }, "ja": { "stringUnit": { "state": "translated", "value": "検索を閉じる" } }, "ko": { "stringUnit": { "state": "translated", "value": "검색 닫기" } }, "pt": { "stringUnit": { "state": "translated", "value": "Fechar busca" } }, "ru": { "stringUnit": { "state": "translated", "value": "Закрыть поиск" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "关闭搜索" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "關閉搜尋" } }
+ } },
+ "search.detail.about": { "extractionState": "manual", "localizations": {
+ "ar": { "stringUnit": { "state": "translated", "value": "الإصدار والتحديثات وروابط المشروع." } }, "de": { "stringUnit": { "state": "translated", "value": "Version, Updates und Projektlinks." } }, "en": { "stringUnit": { "state": "translated", "value": "Version, updates, and project links." } }, "es": { "stringUnit": { "state": "translated", "value": "Versión, actualizaciones y enlaces del proyecto." } }, "fr": { "stringUnit": { "state": "translated", "value": "Version, mises à jour et liens du projet." } }, "ja": { "stringUnit": { "state": "translated", "value": "バージョン、アップデート、プロジェクトリンク。" } }, "ko": { "stringUnit": { "state": "translated", "value": "버전, 업데이트 및 프로젝트 링크." } }, "pt": { "stringUnit": { "state": "translated", "value": "Versão, atualizações e links do projeto." } }, "ru": { "stringUnit": { "state": "translated", "value": "Версия, обновления и ссылки проекта." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "版本、更新和项目链接。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "版本、更新與專案連結。" } }
+ } },
+ "search.detail.general": { "extractionState": "manual", "localizations": {
+ "ar": { "stringUnit": { "state": "translated", "value": "المظهر واللغة وأيقونة شريط القوائم والتشغيل عند تسجيل الدخول ونسخ التفضيلات احتياطيًا." } }, "de": { "stringUnit": { "state": "translated", "value": "Erscheinungsbild, Sprache, Menüleistensymbol, Anmeldestart und Einstellungssicherung." } }, "en": { "stringUnit": { "state": "translated", "value": "Appearance, language, menu bar icon, launch at login, and preferences backup." } }, "es": { "stringUnit": { "state": "translated", "value": "Apariencia, idioma, icono de la barra de menús, inicio de sesión y copia de preferencias." } }, "fr": { "stringUnit": { "state": "translated", "value": "Apparence, langue, icône de la barre des menus, ouverture de session et sauvegarde des préférences." } }, "ja": { "stringUnit": { "state": "translated", "value": "外観、言語、メニューバーアイコン、ログイン時の起動、設定のバックアップ。" } }, "ko": { "stringUnit": { "state": "translated", "value": "모양, 언어, 메뉴 막대 아이콘, 로그인 시 실행 및 환경설정 백업." } }, "pt": { "stringUnit": { "state": "translated", "value": "Aparência, idioma, ícone da barra de menus, início de sessão e backup das preferências." } }, "ru": { "stringUnit": { "state": "translated", "value": "Оформление, язык, значок строки меню, запуск при входе и резервная копия настроек." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "外观、语言、菜单栏图标、登录启动和偏好备份。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "外觀、語言、選單列圖示、登入啟動與偏好備份。" } }
+ } },
+ "search.empty.description": { "extractionState": "manual", "localizations": {
+ "ar": { "stringUnit": { "state": "translated", "value": "جرّب اسم إضافة أو إعدادًا أو ميزة أو أمرًا." } }, "de": { "stringUnit": { "state": "translated", "value": "Versuche einen Plugin-Namen, eine Einstellung, Funktion oder einen Befehl." } }, "en": { "stringUnit": { "state": "translated", "value": "Try a plugin name, setting, feature, or command." } }, "es": { "stringUnit": { "state": "translated", "value": "Prueba con el nombre de un plugin, ajuste, función o comando." } }, "fr": { "stringUnit": { "state": "translated", "value": "Essayez un nom de module, un réglage, une fonction ou une commande." } }, "ja": { "stringUnit": { "state": "translated", "value": "プラグイン名、設定、機能、またはコマンドを試してください。" } }, "ko": { "stringUnit": { "state": "translated", "value": "플러그인 이름, 설정, 기능 또는 명령을 검색해 보세요." } }, "pt": { "stringUnit": { "state": "translated", "value": "Tente um nome de plugin, ajuste, recurso ou comando." } }, "ru": { "stringUnit": { "state": "translated", "value": "Попробуйте название плагина, настройку, функцию или команду." } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "尝试插件名称、设置、功能或命令。" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "嘗試外掛名稱、設定、功能或指令。" } }
+ } },
+ "search.empty.title": { "extractionState": "manual", "localizations": {
+ "ar": { "stringUnit": { "state": "translated", "value": "لا توجد نتائج" } }, "de": { "stringUnit": { "state": "translated", "value": "Keine Ergebnisse" } }, "en": { "stringUnit": { "state": "translated", "value": "No Results" } }, "es": { "stringUnit": { "state": "translated", "value": "Sin resultados" } }, "fr": { "stringUnit": { "state": "translated", "value": "Aucun résultat" } }, "ja": { "stringUnit": { "state": "translated", "value": "結果なし" } }, "ko": { "stringUnit": { "state": "translated", "value": "결과 없음" } }, "pt": { "stringUnit": { "state": "translated", "value": "Nenhum resultado" } }, "ru": { "stringUnit": { "state": "translated", "value": "Ничего не найдено" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "未找到结果" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "找不到結果" } }
+ } },
+ "search.footer.actionFormat": { "extractionState": "manual", "localizations": {
+ "ar": { "stringUnit": { "state": "translated", "value": "%1$@ «%2$@»" } }, "de": { "stringUnit": { "state": "translated", "value": "%1$@ „%2$@“" } }, "en": { "stringUnit": { "state": "translated", "value": "%1$@ “%2$@”" } }, "es": { "stringUnit": { "state": "translated", "value": "%1$@ «%2$@»" } }, "fr": { "stringUnit": { "state": "translated", "value": "%1$@ « %2$@ »" } }, "ja": { "stringUnit": { "state": "translated", "value": "%1$@「%2$@」" } }, "ko": { "stringUnit": { "state": "translated", "value": "%1$@ ‘%2$@’" } }, "pt": { "stringUnit": { "state": "translated", "value": "%1$@ “%2$@”" } }, "ru": { "stringUnit": { "state": "translated", "value": "%1$@ «%2$@»" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "%1$@“%2$@”" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "%1$@「%2$@」" } }
+ } },
+ "search.footer.keyboard": { "extractionState": "manual", "localizations": {
+ "ar": { "stringUnit": { "state": "translated", "value": "↑↓ تحديد ↩ فتح ⌘1–9 فتح سريع" } }, "de": { "stringUnit": { "state": "translated", "value": "↑↓ Auswählen ↩ Öffnen ⌘1–9 Schnell öffnen" } }, "en": { "stringUnit": { "state": "translated", "value": "↑↓ Select ↩ Open ⌘1–9 Quick Open" } }, "es": { "stringUnit": { "state": "translated", "value": "↑↓ Seleccionar ↩ Abrir ⌘1–9 Apertura rápida" } }, "fr": { "stringUnit": { "state": "translated", "value": "↑↓ Sélectionner ↩ Ouvrir ⌘1–9 Ouverture rapide" } }, "ja": { "stringUnit": { "state": "translated", "value": "↑↓ 選択 ↩ 開く ⌘1–9 クイックオープン" } }, "ko": { "stringUnit": { "state": "translated", "value": "↑↓ 선택 ↩ 열기 ⌘1–9 빠르게 열기" } }, "pt": { "stringUnit": { "state": "translated", "value": "↑↓ Selecionar ↩ Abrir ⌘1–9 Abertura rápida" } }, "ru": { "stringUnit": { "state": "translated", "value": "↑↓ Выбрать ↩ Открыть ⌘1–9 Быстро открыть" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "↑↓ 选择 ↩ 打开 ⌘1–9 快速打开" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "↑↓ 選擇 ↩ 開啟 ⌘1–9 快速開啟" } }
+ } },
+ "search.footer.tryAnotherQuery": { "extractionState": "manual", "localizations": {
+ "ar": { "stringUnit": { "state": "translated", "value": "جرّب بحثًا آخر" } }, "de": { "stringUnit": { "state": "translated", "value": "Andere Suche versuchen" } }, "en": { "stringUnit": { "state": "translated", "value": "Try another search" } }, "es": { "stringUnit": { "state": "translated", "value": "Prueba otra búsqueda" } }, "fr": { "stringUnit": { "state": "translated", "value": "Essayez une autre recherche" } }, "ja": { "stringUnit": { "state": "translated", "value": "別の検索を試す" } }, "ko": { "stringUnit": { "state": "translated", "value": "다른 검색어 사용" } }, "pt": { "stringUnit": { "state": "translated", "value": "Tente outra busca" } }, "ru": { "stringUnit": { "state": "translated", "value": "Попробуйте другой запрос" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "尝试其他关键词" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "嘗試其他關鍵字" } }
+ } },
+ "search.group.commands": { "extractionState": "manual", "localizations": {
+ "ar": { "stringUnit": { "state": "translated", "value": "الأوامر" } }, "de": { "stringUnit": { "state": "translated", "value": "Befehle" } }, "en": { "stringUnit": { "state": "translated", "value": "Commands" } }, "es": { "stringUnit": { "state": "translated", "value": "Comandos" } }, "fr": { "stringUnit": { "state": "translated", "value": "Commandes" } }, "ja": { "stringUnit": { "state": "translated", "value": "コマンド" } }, "ko": { "stringUnit": { "state": "translated", "value": "명령" } }, "pt": { "stringUnit": { "state": "translated", "value": "Comandos" } }, "ru": { "stringUnit": { "state": "translated", "value": "Команды" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "命令" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "指令" } }
+ } },
+ "search.group.navigation": { "extractionState": "manual", "localizations": {
+ "ar": { "stringUnit": { "state": "translated", "value": "التنقل" } }, "de": { "stringUnit": { "state": "translated", "value": "Navigation" } }, "en": { "stringUnit": { "state": "translated", "value": "Navigation" } }, "es": { "stringUnit": { "state": "translated", "value": "Navegación" } }, "fr": { "stringUnit": { "state": "translated", "value": "Navigation" } }, "ja": { "stringUnit": { "state": "translated", "value": "ナビゲーション" } }, "ko": { "stringUnit": { "state": "translated", "value": "탐색" } }, "pt": { "stringUnit": { "state": "translated", "value": "Navegação" } }, "ru": { "stringUnit": { "state": "translated", "value": "Навигация" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "导航" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "導覽" } }
+ } },
+ "search.group.settings": { "extractionState": "manual", "localizations": {
+ "ar": { "stringUnit": { "state": "translated", "value": "الإعدادات" } }, "de": { "stringUnit": { "state": "translated", "value": "Einstellungen" } }, "en": { "stringUnit": { "state": "translated", "value": "Settings" } }, "es": { "stringUnit": { "state": "translated", "value": "Ajustes" } }, "fr": { "stringUnit": { "state": "translated", "value": "Réglages" } }, "ja": { "stringUnit": { "state": "translated", "value": "設定" } }, "ko": { "stringUnit": { "state": "translated", "value": "설정" } }, "pt": { "stringUnit": { "state": "translated", "value": "Ajustes" } }, "ru": { "stringUnit": { "state": "translated", "value": "Настройки" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "设置" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "設定" } }
+ } },
+ "search.origin.keyboard": { "extractionState": "manual", "localizations": {
+ "ar": { "stringUnit": { "state": "translated", "value": "الاختصار العام ⌘K" } }, "de": { "stringUnit": { "state": "translated", "value": "Globaler Kurzbefehl ⌘K" } }, "en": { "stringUnit": { "state": "translated", "value": "Global Shortcut ⌘K" } }, "es": { "stringUnit": { "state": "translated", "value": "Atajo global ⌘K" } }, "fr": { "stringUnit": { "state": "translated", "value": "Raccourci global ⌘K" } }, "ja": { "stringUnit": { "state": "translated", "value": "グローバルショートカット ⌘K" } }, "ko": { "stringUnit": { "state": "translated", "value": "전역 단축키 ⌘K" } }, "pt": { "stringUnit": { "state": "translated", "value": "Atalho global ⌘K" } }, "ru": { "stringUnit": { "state": "translated", "value": "Глобальное сочетание ⌘K" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "全局快捷键 ⌘K" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "全域快速鍵 ⌘K" } }
+ } },
+ "search.origin.pluginSidebar": { "extractionState": "manual", "localizations": {
+ "ar": { "stringUnit": { "state": "translated", "value": "من تنقل الإضافات" } }, "de": { "stringUnit": { "state": "translated", "value": "Aus der Plugin-Navigation" } }, "en": { "stringUnit": { "state": "translated", "value": "From Plugin Navigation" } }, "es": { "stringUnit": { "state": "translated", "value": "Desde la navegación de plugins" } }, "fr": { "stringUnit": { "state": "translated", "value": "Depuis la navigation des modules" } }, "ja": { "stringUnit": { "state": "translated", "value": "プラグインナビゲーションから" } }, "ko": { "stringUnit": { "state": "translated", "value": "플러그인 탐색에서" } }, "pt": { "stringUnit": { "state": "translated", "value": "Da navegação de plugins" } }, "ru": { "stringUnit": { "state": "translated", "value": "Из навигации по плагинам" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "来自插件导航" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "來自外掛導覽" } }
+ } },
+ "search.prompt": { "extractionState": "manual", "localizations": {
+ "ar": { "stringUnit": { "state": "translated", "value": "ابحث في الإضافات والإعدادات والأوامر" } }, "de": { "stringUnit": { "state": "translated", "value": "Plugins, Einstellungen und Befehle durchsuchen" } }, "en": { "stringUnit": { "state": "translated", "value": "Search plugins, settings, and commands" } }, "es": { "stringUnit": { "state": "translated", "value": "Buscar plugins, ajustes y comandos" } }, "fr": { "stringUnit": { "state": "translated", "value": "Rechercher des modules, réglages et commandes" } }, "ja": { "stringUnit": { "state": "translated", "value": "プラグイン、設定、コマンドを検索" } }, "ko": { "stringUnit": { "state": "translated", "value": "플러그인, 설정 및 명령 검색" } }, "pt": { "stringUnit": { "state": "translated", "value": "Buscar plugins, ajustes e comandos" } }, "ru": { "stringUnit": { "state": "translated", "value": "Поиск плагинов, настроек и команд" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "搜索插件、设置和命令" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "搜尋外掛、設定與指令" } }
+ } },
+ "search.resultCountFormat": { "extractionState": "manual", "localizations": {
+ "ar": { "variations": { "plural": {
+ "zero": { "stringUnit": { "state": "translated", "value": "لا نتائج" } },
+ "one": { "stringUnit": { "state": "translated", "value": "نتيجة واحدة" } },
+ "two": { "stringUnit": { "state": "translated", "value": "نتيجتان" } },
+ "few": { "stringUnit": { "state": "translated", "value": "%d نتائج" } },
+ "many": { "stringUnit": { "state": "translated", "value": "%d نتيجة" } },
+ "other": { "stringUnit": { "state": "translated", "value": "%d نتيجة" } }
+ } } },
+ "de": { "variations": { "plural": {
+ "one": { "stringUnit": { "state": "translated", "value": "%d Ergebnis" } },
+ "other": { "stringUnit": { "state": "translated", "value": "%d Ergebnisse" } }
+ } } },
+ "en": { "variations": { "plural": {
+ "one": { "stringUnit": { "state": "translated", "value": "%d result" } },
+ "other": { "stringUnit": { "state": "translated", "value": "%d results" } }
+ } } },
+ "es": { "variations": { "plural": {
+ "one": { "stringUnit": { "state": "translated", "value": "%d resultado" } },
+ "other": { "stringUnit": { "state": "translated", "value": "%d resultados" } }
+ } } },
+ "fr": { "variations": { "plural": {
+ "one": { "stringUnit": { "state": "translated", "value": "%d résultat" } },
+ "other": { "stringUnit": { "state": "translated", "value": "%d résultats" } }
+ } } },
+ "ja": { "variations": { "plural": {
+ "other": { "stringUnit": { "state": "translated", "value": "%d件の結果" } }
+ } } },
+ "ko": { "variations": { "plural": {
+ "other": { "stringUnit": { "state": "translated", "value": "결과 %d개" } }
+ } } },
+ "pt": { "variations": { "plural": {
+ "one": { "stringUnit": { "state": "translated", "value": "%d resultado" } },
+ "other": { "stringUnit": { "state": "translated", "value": "%d resultados" } }
+ } } },
+ "ru": { "variations": { "plural": {
+ "one": { "stringUnit": { "state": "translated", "value": "%d результат" } },
+ "few": { "stringUnit": { "state": "translated", "value": "%d результата" } },
+ "many": { "stringUnit": { "state": "translated", "value": "%d результатов" } },
+ "other": { "stringUnit": { "state": "translated", "value": "%d результата" } }
+ } } },
+ "zh-Hans": { "variations": { "plural": {
+ "other": { "stringUnit": { "state": "translated", "value": "%d 个结果" } }
+ } } },
+ "zh-Hant": { "variations": { "plural": {
+ "other": { "stringUnit": { "state": "translated", "value": "%d 個結果" } }
+ } } }
+ } },
+ "search.subtitle.appSettings": { "extractionState": "manual", "localizations": {
+ "ar": { "stringUnit": { "state": "translated", "value": "إعدادات التطبيق" } }, "de": { "stringUnit": { "state": "translated", "value": "App-Einstellungen" } }, "en": { "stringUnit": { "state": "translated", "value": "App Settings" } }, "es": { "stringUnit": { "state": "translated", "value": "Ajustes de la app" } }, "fr": { "stringUnit": { "state": "translated", "value": "Réglages de l’app" } }, "ja": { "stringUnit": { "state": "translated", "value": "アプリ設定" } }, "ko": { "stringUnit": { "state": "translated", "value": "앱 설정" } }, "pt": { "stringUnit": { "state": "translated", "value": "Ajustes do app" } }, "ru": { "stringUnit": { "state": "translated", "value": "Настройки приложения" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "应用设置" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "應用程式設定" } }
+ } },
+ "search.subtitle.customSetting": { "extractionState": "manual", "localizations": {
+ "ar": { "stringUnit": { "state": "translated", "value": "الإعدادات" } }, "de": { "stringUnit": { "state": "translated", "value": "Einstellungen" } }, "en": { "stringUnit": { "state": "translated", "value": "Settings" } }, "es": { "stringUnit": { "state": "translated", "value": "Ajustes" } }, "fr": { "stringUnit": { "state": "translated", "value": "Réglages" } }, "ja": { "stringUnit": { "state": "translated", "value": "設定" } }, "ko": { "stringUnit": { "state": "translated", "value": "설정" } }, "pt": { "stringUnit": { "state": "translated", "value": "Ajustes" } }, "ru": { "stringUnit": { "state": "translated", "value": "Настройки" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "设置" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "設定" } }
+ } },
+ "search.subtitle.macTools": { "extractionState": "manual", "localizations": {
+ "ar": { "stringUnit": { "state": "translated", "value": "MacTools" } }, "de": { "stringUnit": { "state": "translated", "value": "MacTools" } }, "en": { "stringUnit": { "state": "translated", "value": "MacTools" } }, "es": { "stringUnit": { "state": "translated", "value": "MacTools" } }, "fr": { "stringUnit": { "state": "translated", "value": "MacTools" } }, "ja": { "stringUnit": { "state": "translated", "value": "MacTools" } }, "ko": { "stringUnit": { "state": "translated", "value": "MacTools" } }, "pt": { "stringUnit": { "state": "translated", "value": "MacTools" } }, "ru": { "stringUnit": { "state": "translated", "value": "MacTools" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "MacTools" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "MacTools" } }
+ } },
+ "search.subtitle.plugins": { "extractionState": "manual", "localizations": {
+ "ar": { "stringUnit": { "state": "translated", "value": "الإضافات" } }, "de": { "stringUnit": { "state": "translated", "value": "Plugins" } }, "en": { "stringUnit": { "state": "translated", "value": "Plugins" } }, "es": { "stringUnit": { "state": "translated", "value": "Plugins" } }, "fr": { "stringUnit": { "state": "translated", "value": "Modules" } }, "ja": { "stringUnit": { "state": "translated", "value": "プラグイン" } }, "ko": { "stringUnit": { "state": "translated", "value": "플러그인" } }, "pt": { "stringUnit": { "state": "translated", "value": "Plugins" } }, "ru": { "stringUnit": { "state": "translated", "value": "Плагины" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "插件" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "外掛" } }
+ } },
+ "search.title": { "extractionState": "manual", "localizations": {
+ "ar": { "stringUnit": { "state": "translated", "value": "البحث في MacTools" } }, "de": { "stringUnit": { "state": "translated", "value": "MacTools durchsuchen" } }, "en": { "stringUnit": { "state": "translated", "value": "Search MacTools" } }, "es": { "stringUnit": { "state": "translated", "value": "Buscar en MacTools" } }, "fr": { "stringUnit": { "state": "translated", "value": "Rechercher dans MacTools" } }, "ja": { "stringUnit": { "state": "translated", "value": "MacToolsを検索" } }, "ko": { "stringUnit": { "state": "translated", "value": "MacTools 검색" } }, "pt": { "stringUnit": { "state": "translated", "value": "Buscar no MacTools" } }, "ru": { "stringUnit": { "state": "translated", "value": "Поиск в MacTools" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "搜索 MacTools" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "搜尋 MacTools" } }
+ } }
+ },
+ "version": "1.0"
+}
diff --git a/Sources/Resources/Localization/Settings.xcstrings b/Sources/Resources/Localization/Settings.xcstrings
index fec70a3a..2d797c3d 100644
--- a/Sources/Resources/Localization/Settings.xcstrings
+++ b/Sources/Resources/Localization/Settings.xcstrings
@@ -241,6 +241,22 @@
"zh-Hant": { "stringUnit": { "state": "translated", "value": "功能面板" } }
}
},
+ "plugins.sidebar.accessibilityLabel": {
+ "extractionState": "manual",
+ "localizations": {
+ "ar": { "stringUnit": { "state": "translated", "value": "التنقل بين الإضافات" } },
+ "de": { "stringUnit": { "state": "translated", "value": "Plugin-Navigation" } },
+ "en": { "stringUnit": { "state": "translated", "value": "Plugin Navigation" } },
+ "es": { "stringUnit": { "state": "translated", "value": "Navegación de complementos" } },
+ "fr": { "stringUnit": { "state": "translated", "value": "Navigation des modules" } },
+ "ja": { "stringUnit": { "state": "translated", "value": "プラグインナビゲーション" } },
+ "ko": { "stringUnit": { "state": "translated", "value": "플러그인 탐색" } },
+ "pt": { "stringUnit": { "state": "translated", "value": "Navegação de plug-ins" } },
+ "ru": { "stringUnit": { "state": "translated", "value": "Навигация по плагинам" } },
+ "zh-Hans": { "stringUnit": { "state": "translated", "value": "插件导航" } },
+ "zh-Hant": { "stringUnit": { "state": "translated", "value": "外掛導覽" } }
+ }
+ },
"plugins.sidebar.pluginsSection": {
"extractionState": "manual",
"localizations": {
diff --git a/Tests/App/AppWindowRouterTests.swift b/Tests/App/AppWindowRouterTests.swift
index d54a9621..1ccd744f 100644
--- a/Tests/App/AppWindowRouterTests.swift
+++ b/Tests/App/AppWindowRouterTests.swift
@@ -41,7 +41,7 @@ final class AppWindowRouterTests: XCTestCase {
actions.present(.featurePanel)
}
- func testSettingsWindowKeepsItsWidthAcrossDestinations() throws {
+ func testSettingsWindowKeepsItsWidthAcrossDestinations() async throws {
let suiteName = "AppWindowRouterTests-\(UUID().uuidString)"
let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName))
defer { defaults.removePersistentDomain(forName: suiteName) }
@@ -52,6 +52,7 @@ final class AppWindowRouterTests: XCTestCase {
let window = try XCTUnwrap(router.settingsWindow)
let coordinator = try XCTUnwrap(router.settingsNavigationCoordinator)
let hostingView = try XCTUnwrap(window.contentView as? NSHostingView)
+ await settleWindowLayout(window)
let initialWidth = window.frame.width
let initialToolbarItemCount = window.toolbar?.items.count
@@ -61,7 +62,6 @@ final class AppWindowRouterTests: XCTestCase {
window.toolbar?.items.contains { $0.itemIdentifier == .toggleSidebar } ?? false
)
XCTAssertEqual(hostingView.sizingOptions, [])
- XCTAssertEqual(window.contentMinSize, SettingsWindowLayout.minimumContentSize)
XCTAssertEqual(
hostingView.frame.width,
SettingsWindowLayout.defaultContentSize.width,
@@ -69,7 +69,7 @@ final class AppWindowRouterTests: XCTestCase {
)
window.setContentSize(NSSize(width: 940, height: 640))
- window.layoutIfNeeded()
+ await settleWindowLayout(window)
let resizedWidth = window.frame.width
XCTAssertLessThan(resizedWidth, initialWidth)
@@ -79,7 +79,7 @@ final class AppWindowRouterTests: XCTestCase {
.general
] {
coordinator.navigate(to: destination)
- window.layoutIfNeeded()
+ await settleWindowLayout(window)
XCTAssertEqual(window.frame.width, resizedWidth, accuracy: 0.5)
XCTAssertEqual(window.toolbar?.items.count, initialToolbarItemCount)
}
@@ -87,6 +87,15 @@ final class AppWindowRouterTests: XCTestCase {
window.close()
}
+ private func settleWindowLayout(_ window: NSWindow) async {
+ await withCheckedContinuation { continuation in
+ DispatchQueue.main.async {
+ continuation.resume()
+ }
+ }
+ window.layoutIfNeeded()
+ }
+
func testSettingsWindowConstrainsLiveResizeToMinimumContentSize() throws {
let suiteName = "AppWindowRouterTests-\(UUID().uuidString)"
let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName))
@@ -199,13 +208,41 @@ final class AppWindowRouterTests: XCTestCase {
),
.focusSearch
)
+ XCTAssertEqual(
+ MacToolsLocalKeyboardCommand.resolve(
+ for: keyEvent(
+ keyCode: UInt16(kVK_ANSI_K),
+ characters: "K",
+ modifiers: [.command, .capsLock]
+ )
+ ),
+ .showUnifiedSearch
+ )
+ }
+
+ func testLocalCommandMatcherUsesPhysicalNumberRowForUnifiedSearchSelection() {
+ XCTAssertEqual(
+ MacToolsLocalKeyboardCommand.resolve(
+ for: keyEvent(
+ keyCode: UInt16(kVK_ANSI_1),
+ characters: "&"
+ )
+ ),
+ .selectUnifiedSearchResult(1)
+ )
+ XCTAssertEqual(
+ MacToolsLocalKeyboardCommand.resolve(
+ for: keyEvent(
+ keyCode: UInt16(kVK_ANSI_9),
+ characters: "ç"
+ )
+ ),
+ .selectUnifiedSearchResult(9)
+ )
}
- func testLocalCommandMatcherLeavesNavigationCloseAndQuitCommandsUntouched() {
+ func testLocalCommandMatcherLeavesCloseQuitAndUnsupportedModifiersUntouched() {
for keyCode in [
- kVK_ANSI_1,
- kVK_ANSI_2,
- kVK_ANSI_3,
kVK_ANSI_W,
kVK_ANSI_Q
] {
@@ -292,6 +329,87 @@ final class AppWindowRouterTests: XCTestCase {
window.close()
}
+ func testCommandKReusesSettingsWindowAndRefocusesUnifiedSearch() throws {
+ let suiteName = "AppWindowRouterTests-\(UUID().uuidString)"
+ let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName))
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+ let router = makeRouter(defaults: defaults)
+
+ router.showSettings()
+ let window = try XCTUnwrap(router.settingsWindow)
+ let coordinator = try XCTUnwrap(router.settingsNavigationCoordinator)
+ let commandK = keyEvent(
+ keyCode: UInt16(kVK_ANSI_K),
+ characters: "k",
+ windowNumber: window.windowNumber
+ )
+
+ XCTAssertTrue(window.performKeyEquivalent(with: commandK))
+ XCTAssertTrue(coordinator.isUnifiedSearchPresented)
+ XCTAssertEqual(coordinator.unifiedSearchPresentationOrigin, .keyboard)
+ let firstFocusRequestID = coordinator.unifiedSearchFocusRequestID
+
+ XCTAssertTrue(window.performKeyEquivalent(with: commandK))
+ XCTAssertTrue(router.settingsWindow === window)
+ XCTAssertGreaterThan(
+ coordinator.unifiedSearchFocusRequestID,
+ firstFocusRequestID
+ )
+
+ window.close()
+ }
+
+ func testPhysicalCommandNumberRequestsUnifiedSearchQuickSelection() throws {
+ let suiteName = "AppWindowRouterTests-\(UUID().uuidString)"
+ let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName))
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+ let router = makeRouter(defaults: defaults)
+
+ router.showUnifiedSearch()
+ let window = try XCTUnwrap(router.settingsWindow)
+ let coordinator = try XCTUnwrap(router.settingsNavigationCoordinator)
+
+ XCTAssertTrue(
+ window.performKeyEquivalent(
+ with: keyEvent(
+ keyCode: UInt16(kVK_ANSI_2),
+ characters: "é",
+ windowNumber: window.windowNumber
+ )
+ )
+ )
+ XCTAssertEqual(
+ coordinator.unifiedSearchQuickSelectionRequest?.number,
+ 2
+ )
+
+ window.close()
+ }
+
+ func testPhysicalCommandNumberSelectsSettingsTabWhenSearchIsClosed() throws {
+ let suiteName = "AppWindowRouterTests-\(UUID().uuidString)"
+ let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName))
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+ let router = makeRouter(defaults: defaults)
+
+ router.showSettings()
+ let window = try XCTUnwrap(router.settingsWindow)
+ let coordinator = try XCTUnwrap(router.settingsNavigationCoordinator)
+
+ XCTAssertTrue(
+ window.performKeyEquivalent(
+ with: keyEvent(
+ keyCode: UInt16(kVK_ANSI_3),
+ characters: "\"",
+ windowNumber: window.windowNumber
+ )
+ )
+ )
+ XCTAssertEqual(coordinator.destination, .about)
+
+ window.close()
+ }
+
func testAppUpdateRequestNavigatesDirectlyToAbout() throws {
let suiteName = "AppWindowRouterTests-\(UUID().uuidString)"
let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName))
diff --git a/Tests/App/FeatureManagementTableViewTests.swift b/Tests/App/FeatureManagementTableViewTests.swift
index f60b3294..2569ba18 100644
--- a/Tests/App/FeatureManagementTableViewTests.swift
+++ b/Tests/App/FeatureManagementTableViewTests.swift
@@ -120,6 +120,23 @@ final class FeatureManagementTableViewTests: XCTestCase {
))
}
+ func testUpdatePolicyRefreshesWhenSearchHighlightChanges() {
+ let items = [makeItem(id: "activity-bar", isActive: false)]
+
+ XCTAssertTrue(FeatureManagementTableUpdatePolicy.needsUpdate(
+ previousItems: items,
+ currentItems: items,
+ previousMode: .surface(.dashboard),
+ currentMode: .surface(.dashboard),
+ previousIsReorderEnabled: true,
+ currentIsReorderEnabled: true,
+ previousHighlightedPluginID: nil,
+ currentHighlightedPluginID: "activity-bar",
+ previousContentWidth: 480,
+ currentContentWidth: 480
+ ))
+ }
+
func testCapabilitySummaryCoversEverySurfaceCombination() {
XCTAssertEqual(
pluginCapabilitySummary(capabilities(dashboard: true, featurePanel: true)),
diff --git a/Tests/App/MacToolsSearchTests.swift b/Tests/App/MacToolsSearchTests.swift
new file mode 100644
index 00000000..f2117ab6
--- /dev/null
+++ b/Tests/App/MacToolsSearchTests.swift
@@ -0,0 +1,666 @@
+import AppKit
+import SwiftUI
+import XCTest
+import MacToolsPluginKit
+@testable import MacTools
+
+@MainActor
+final class MacToolsSearchTests: XCTestCase {
+ func testIndexIncludesNavigationDeclarativeSettingsCustomSettingsAndCommands() throws {
+ let plugin = SearchableTestPlugin()
+ let host = makePluginHostForTests(plugins: [plugin, SurfaceOnlySearchTestPlugin()])
+ let index = MacToolsSearchIndexBuilder.build(pluginHost: host)
+
+ XCTAssertTrue(index.items.contains {
+ $0.kind == .navigation && $0.title == plugin.metadata.title
+ })
+ XCTAssertTrue(index.items.contains {
+ $0.kind == .setting && $0.title == "自动切换"
+ })
+ XCTAssertTrue(index.items.contains {
+ $0.kind == .setting && $0.title == "辅助功能授权"
+ })
+ XCTAssertTrue(index.items.contains {
+ $0.kind == .setting && $0.title == "降低亮度"
+ })
+ XCTAssertTrue(index.items.contains {
+ $0.kind == .setting && $0.title == "快捷键目标"
+ })
+ XCTAssertTrue(index.items.contains {
+ $0.kind == .command && $0.title == "让显示器休眠"
+ })
+ XCTAssertTrue(index.items.contains {
+ $0.kind == .command && $0.title == AppShortcutAction.toggleDashboard.title
+ })
+ XCTAssertTrue(index.items.contains {
+ $0.id == "general-setting.appearance" && $0.kind == .setting
+ })
+ XCTAssertTrue(index.items.contains {
+ $0.id == "general-setting.preferencesBackup" && $0.kind == .setting
+ })
+ }
+
+ func testCustomSettingResultCarriesPluginPageAndExactSearchTarget() throws {
+ let plugin = SearchableTestPlugin()
+ let host = makePluginHostForTests(plugins: [plugin])
+ let result = try XCTUnwrap(
+ MacToolsSearchIndexBuilder.build(pluginHost: host).items.first {
+ $0.title == "快捷键目标"
+ }
+ )
+
+ guard case let .navigate(destination, target) = result.action else {
+ return XCTFail("Expected a navigation action")
+ }
+
+ XCTAssertEqual(destination, .plugins(.configuration(plugin.metadata.id)))
+ XCTAssertEqual(
+ target,
+ .plugin(
+ PluginSettingsSearchTarget(
+ pluginID: plugin.metadata.id,
+ entryID: SearchableTestPlugin.customEntryID
+ )
+ )
+ )
+ }
+
+ func testCustomSearchEntryRequiresACustomConfigurationSurface() {
+ let plugin = DeclarativeOnlySearchProviderTestPlugin()
+ let host = makePluginHostForTests(plugins: [plugin])
+ let index = MacToolsSearchIndexBuilder.build(pluginHost: host)
+
+ XCTAssertFalse(index.items.contains {
+ $0.id == "\(plugin.metadata.id).settings-search.unrendered"
+ })
+ }
+
+ func testGeneralSettingResultCarriesGeneralPageAndExactSearchTarget() throws {
+ let host = makePluginHostForTests(plugins: [])
+ let result = try XCTUnwrap(
+ MacToolsSearchIndexBuilder.build(pluginHost: host).items.first {
+ $0.id == "general-setting.language"
+ }
+ )
+
+ XCTAssertEqual(
+ result.action,
+ .navigate(destination: .general, target: .general(.language))
+ )
+ }
+
+ func testGeneralShortcutSettingMatchesIndividualShortcutTitles() {
+ let host = makePluginHostForTests(plugins: [])
+ let results = MacToolsSearchIndexBuilder.build(pluginHost: host)
+ .results(matching: AppShortcutAction.toggleDashboard.title)
+
+ XCTAssertTrue(results.contains { $0.id == "general-setting.appShortcuts" })
+ }
+
+ func testSurfaceOnlyPluginNavigatesToAndRevealsItsFeaturePanelRow() throws {
+ let plugin = SurfaceOnlySearchTestPlugin()
+ let host = makePluginHostForTests(plugins: [plugin])
+ let result = try XCTUnwrap(
+ MacToolsSearchIndexBuilder.build(pluginHost: host).items.first {
+ $0.title == plugin.metadata.title
+ }
+ )
+
+ XCTAssertEqual(
+ result.action,
+ .navigate(
+ destination: .plugins(.featurePanelLayout),
+ target: .surface(
+ SurfaceSettingsSearchTarget(
+ surface: .featurePanel,
+ pluginID: plugin.metadata.id
+ )
+ )
+ )
+ )
+ }
+
+ func testSearchUsesTitleDescriptionAndKeywordsWithAllTokenMatching() {
+ let plugin = SearchableTestPlugin()
+ let host = makePluginHostForTests(plugins: [plugin])
+ let index = MacToolsSearchIndexBuilder.build(pluginHost: host)
+
+ XCTAssertEqual(
+ index.results(matching: "快捷键 目标").first?.title,
+ "快捷键目标"
+ )
+ XCTAssertTrue(
+ index.results(matching: "外接 屏幕").contains {
+ $0.title == "快捷键目标"
+ }
+ )
+ XCTAssertTrue(index.results(matching: "不存在 屏幕").isEmpty)
+ }
+
+ func testExactTitleRanksAheadOfDescriptionOnlyMatch() {
+ let exact = MacToolsSearchResult(
+ id: "exact",
+ kind: .navigation,
+ title: "屏幕",
+ subtitle: "插件",
+ detail: "",
+ keywords: [],
+ systemImage: "display",
+ action: .navigate(destination: .plugins(.marketplace), target: nil),
+ confirmation: nil,
+ suggestionPriority: nil
+ )
+ let detailOnly = MacToolsSearchResult(
+ id: "detail",
+ kind: .setting,
+ title: "保持常亮",
+ subtitle: "阻止休眠",
+ detail: "防止屏幕因空闲而关闭",
+ keywords: [],
+ systemImage: "coffee",
+ action: .navigate(destination: .plugins(.marketplace), target: nil),
+ confirmation: nil,
+ suggestionPriority: nil
+ )
+
+ XCTAssertEqual(
+ MacToolsSearchIndex(items: [detailOnly, exact])
+ .results(matching: "屏幕")
+ .map(\.id),
+ ["exact", "detail"]
+ )
+ }
+
+ func testEmptyQueryReturnsOnlyOrderedSuggestedDestinations() {
+ let host = makePluginHostForTests(plugins: [SearchableTestPlugin()])
+ let results = MacToolsSearchIndexBuilder.build(pluginHost: host)
+ .results(matching: " ")
+
+ XCTAssertEqual(
+ results.map(\.id),
+ [
+ "navigation.dashboard",
+ "navigation.feature-panel",
+ "navigation.marketplace",
+ "navigation.general",
+ "navigation.about"
+ ]
+ )
+ XCTAssertTrue(results.allSatisfy { $0.kind == .navigation })
+ }
+
+ func testPresentationOrderMatchesVisibleGroupsAndQuickSelectionNumbers() {
+ let navigation = searchResult(id: "navigation", kind: .navigation)
+ let setting = searchResult(id: "setting", kind: .setting)
+ let command = searchResult(id: "command", kind: .command)
+ let ordered = MacToolsSearchPresentation.orderedResults([
+ command,
+ navigation,
+ setting
+ ])
+
+ XCTAssertEqual(ordered.map(\.id), ["navigation", "setting", "command"])
+ XCTAssertEqual(
+ MacToolsSearchPresentation.quickSelectionNumber(
+ for: "setting",
+ in: ordered
+ ),
+ 2
+ )
+ XCTAssertNil(
+ MacToolsSearchPresentation.quickSelectionNumber(
+ for: "missing",
+ in: ordered
+ )
+ )
+ }
+
+ func testQuickSelectionIsLimitedToFirstNineVisibleResults() {
+ let results = (1...10).map {
+ searchResult(id: "result-\($0)", kind: .setting)
+ }
+
+ XCTAssertEqual(
+ MacToolsSearchPresentation.quickSelectionNumber(
+ for: "result-9",
+ in: results
+ ),
+ 9
+ )
+ XCTAssertNil(
+ MacToolsSearchPresentation.quickSelectionNumber(
+ for: "result-10",
+ in: results
+ )
+ )
+ }
+
+ func testPaletteModelKeepsOneDerivedResultSnapshotPerQuery() {
+ let host = makePluginHostForTests(plugins: [SearchableTestPlugin()])
+ let model = UnifiedSearchPaletteModel(pluginHost: host)
+
+ XCTAssertEqual(
+ model.results.map(\.id),
+ [
+ "navigation.dashboard",
+ "navigation.feature-panel",
+ "navigation.marketplace",
+ "navigation.general",
+ "navigation.about"
+ ]
+ )
+
+ model.updateQuery("快捷键目标")
+ XCTAssertEqual(model.results.first?.title, "快捷键目标")
+ }
+
+ func testUnifiedSearchFieldLeavesCommandsToActiveInputMethod() {
+ for selector in [
+ #selector(NSResponder.moveUp(_:)),
+ #selector(NSResponder.moveDown(_:)),
+ #selector(NSResponder.insertNewline(_:)),
+ #selector(NSResponder.cancelOperation(_:))
+ ] {
+ XCTAssertNil(
+ UnifiedSearchTextField.command(
+ for: selector,
+ hasMarkedText: true
+ )
+ )
+ }
+
+ XCTAssertEqual(
+ UnifiedSearchTextField.command(
+ for: #selector(NSResponder.moveDown(_:)),
+ hasMarkedText: false
+ ),
+ .moveSelection(1)
+ )
+ XCTAssertEqual(
+ UnifiedSearchTextField.command(
+ for: #selector(NSResponder.insertNewline(_:)),
+ hasMarkedText: false
+ ),
+ .submit
+ )
+ }
+
+ func testPaletteLayoutUsesLargerMaximumSizeAndFitsMinimumSettingsWindow() {
+ XCTAssertEqual(
+ UnifiedSearchPaletteLayout.width(for: 720),
+ 672
+ )
+ XCTAssertEqual(
+ UnifiedSearchPaletteLayout.width(for: 1_200),
+ 672
+ )
+ XCTAssertEqual(
+ UnifiedSearchPaletteLayout.resultListHeight(for: 480),
+ 278
+ )
+ XCTAssertEqual(
+ UnifiedSearchPaletteLayout.resultListHeight(for: 800),
+ 420
+ )
+ }
+
+ func testPluginHostPerformsOnlyDeclaredCommands() {
+ let plugin = SearchableTestPlugin()
+ let host = makePluginHostForTests(plugins: [plugin])
+ let definition = plugin.commandDefinitions[0]
+
+ XCTAssertTrue(
+ host.performCommand(
+ pluginID: plugin.metadata.id,
+ expectedDefinition: definition
+ )
+ )
+ XCTAssertFalse(
+ host.performCommand(
+ pluginID: "missing",
+ expectedDefinition: definition
+ )
+ )
+
+ XCTAssertEqual(plugin.performedCommandIDs, ["sleep"])
+ }
+
+ func testPluginHostRejectsAStaleCommandDefinition() {
+ let plugin = SearchableTestPlugin()
+ let host = makePluginHostForTests(plugins: [plugin])
+ let staleDefinition = plugin.commandDefinitions[0]
+ plugin.commandTitle = "新的显示器命令"
+
+ XCTAssertFalse(
+ host.performCommand(
+ pluginID: plugin.metadata.id,
+ expectedDefinition: staleDefinition
+ )
+ )
+ XCTAssertTrue(plugin.performedCommandIDs.isEmpty)
+ }
+
+ func testPluginHostValidatesLiveExactSettingsTargets() {
+ let plugin = SearchableTestPlugin()
+ let host = makePluginHostForTests(plugins: [plugin])
+ let index = MacToolsSearchIndexBuilder.build(pluginHost: host)
+ let targets = index.items.compactMap { item -> PluginSettingsSearchTarget? in
+ guard
+ case let .navigate(_, .plugin(target)) = item.action,
+ target.pluginID == plugin.metadata.id
+ else {
+ return nil
+ }
+ return target
+ }
+
+ XCTAssertFalse(targets.isEmpty)
+ XCTAssertTrue(targets.allSatisfy(host.hasPluginSettingsSearchTarget))
+ XCTAssertFalse(
+ host.hasPluginSettingsSearchTarget(
+ PluginSettingsSearchTarget(
+ pluginID: plugin.metadata.id,
+ entryID: "removed-entry"
+ )
+ )
+ )
+ }
+
+ func testInstalledIncompatiblePluginIsDiscoverableInMarketplace() throws {
+ let root = FileManager.default.temporaryDirectory
+ .appendingPathComponent(
+ "MacToolsSearchTests-\(UUID().uuidString)",
+ isDirectory: true
+ )
+ defer { try? FileManager.default.removeItem(at: root) }
+
+ let defaults = UserDefaults(
+ suiteName: "MacToolsSearchTests-\(UUID().uuidString)"
+ )!
+ let store = PluginPackageStore(
+ rootDirectory: root,
+ userDefaults: defaults,
+ hostVersion: "1.0.0"
+ )
+ let packageURL = store.installedDirectory
+ .appendingPathComponent(
+ "com.example.future.mactoolsplugin",
+ isDirectory: true
+ )
+ try FileManager.default.createDirectory(
+ at: packageURL,
+ withIntermediateDirectories: true
+ )
+ let manifest = PluginPackageManifest(
+ id: "com.example.future",
+ displayName: "Future Plugin",
+ version: "2.0.0",
+ minHostVersion: "99.0.0",
+ bundleRelativePath: "Future.bundle"
+ )
+ try JSONEncoder().encode(manifest).write(
+ to: packageURL.appendingPathComponent("plugin.json")
+ )
+
+ let manager = DynamicPluginManager(packageStore: store)
+ let host = makePluginHostForTests(
+ plugins: [],
+ dynamicPluginManager: manager,
+ loadDynamicPluginsOnInit: false
+ )
+ let result = try XCTUnwrap(
+ MacToolsSearchIndexBuilder.build(pluginHost: host).items.first {
+ $0.id == "plugin.com.example.future"
+ }
+ )
+
+ XCTAssertEqual(result.title, "Future Plugin")
+ XCTAssertEqual(
+ result.action,
+ .navigate(
+ destination: .plugins(.marketplace),
+ target: .marketplace(
+ MarketplacePluginSearchTarget(
+ pluginID: "com.example.future"
+ )
+ )
+ )
+ )
+ XCTAssertTrue(result.detail.contains("99.0.0"))
+ }
+
+ func testAppCommandUsesExistingPresentationRouting() {
+ let host = makePluginHostForTests(plugins: [])
+ var requests: [AppPresentationRequest] = []
+ host.appPresentationHandler = { requests.append($0) }
+
+ XCTAssertTrue(host.performAppCommand(.toggleDashboard))
+
+ XCTAssertEqual(requests, [.toggleDashboard])
+ }
+
+ func testAppCommandFailsWithoutPresentationRouting() {
+ let host = makePluginHostForTests(plugins: [])
+
+ XCTAssertFalse(host.performAppCommand(.toggleDashboard))
+ }
+
+ private func searchResult(
+ id: String,
+ kind: MacToolsSearchResultKind
+ ) -> MacToolsSearchResult {
+ MacToolsSearchResult(
+ id: id,
+ kind: kind,
+ title: id,
+ subtitle: "",
+ detail: "",
+ keywords: [],
+ systemImage: "magnifyingglass",
+ action: .navigate(destination: .general, target: nil),
+ confirmation: nil,
+ suggestionPriority: nil
+ )
+ }
+}
+
+@MainActor
+private final class SearchableTestPlugin:
+ MacToolsPlugin,
+ PluginPrimaryPanel,
+ PluginSettingsSearchProviding,
+ PluginCommandProviding
+{
+ static let customEntryID = "shortcut-target"
+
+ let metadata = PluginMetadata(
+ id: "searchable",
+ title: "显示工具",
+ iconName: "display",
+ iconTint: Color(nsColor: .systemBlue),
+ order: 1,
+ defaultDescription: "管理内建和外接显示器亮度"
+ )
+ let primaryPanelDescriptor = PluginPrimaryPanelDescriptor(
+ controlStyle: .disclosure,
+ menuActionBehavior: .keepPresented
+ )
+ var onStateChange: (() -> Void)?
+ var requestPermissionGuidance: ((String) -> Void)?
+ var shortcutBindingResolver: ((String) -> ShortcutBinding?)?
+ var performedCommandIDs: [String] = []
+ var commandTitle = "让显示器休眠"
+
+ var primaryPanelState: PluginPanelState {
+ PluginPanelState(
+ subtitle: metadata.defaultDescription,
+ isOn: false,
+ isExpanded: false,
+ isEnabled: true,
+ isVisible: true,
+ detail: nil,
+ errorMessage: nil
+ )
+ }
+
+ var settingsSections: [PluginSettingsSection] {
+ [
+ PluginSettingsSection(
+ id: "automatic",
+ title: "自动切换",
+ description: "根据屏幕状态自动切换亮度。",
+ status: .init(
+ text: "已开启",
+ systemImage: "checkmark.circle",
+ tone: .positive
+ ),
+ footnote: nil,
+ buttonTitle: nil,
+ actionID: nil
+ )
+ ]
+ }
+
+ var permissionRequirements: [PluginPermissionRequirement] {
+ [
+ PluginPermissionRequirement(
+ id: "accessibility",
+ kind: .accessibility,
+ title: "辅助功能授权",
+ description: "允许控制显示器。"
+ )
+ ]
+ }
+
+ var shortcutDefinitions: [PluginShortcutDefinition] {
+ [
+ PluginShortcutDefinition(
+ id: "decrease",
+ title: "降低亮度",
+ description: "降低目标显示器亮度。",
+ actionID: "decrease",
+ scope: .global,
+ defaultBinding: nil,
+ isRequired: false
+ )
+ ]
+ }
+
+ var configuration: PluginConfiguration? {
+ PluginConfiguration(description: metadata.defaultDescription) { _ in
+ EmptyView()
+ }
+ }
+
+ var settingsSearchEntries: [PluginSettingsSearchEntry] {
+ [
+ PluginSettingsSearchEntry(
+ id: Self.customEntryID,
+ title: "快捷键目标",
+ description: "选择亮度快捷键控制的外接显示器。",
+ keywords: ["屏幕", "作用范围"],
+ systemImage: "display.2"
+ )
+ ]
+ }
+
+ var commandDefinitions: [PluginCommandDefinition] {
+ [
+ PluginCommandDefinition(
+ id: "sleep",
+ title: commandTitle,
+ description: "立即让所有屏幕进入休眠。",
+ systemImage: "display"
+ )
+ ]
+ }
+
+ func permissionState(for permissionID: String) -> PluginPermissionState {
+ PluginPermissionState(isGranted: true, footnote: nil)
+ }
+
+ func handleAction(_ action: PluginPanelAction) {}
+
+ func handleCommand(id: String) {
+ performedCommandIDs.append(id)
+ }
+}
+
+@MainActor
+private final class SurfaceOnlySearchTestPlugin: MacToolsPlugin, PluginPrimaryPanel {
+ let metadata = PluginMetadata(
+ id: "surface-only",
+ title: "锁定屏幕",
+ iconName: "lock",
+ iconTint: Color(nsColor: .systemGray),
+ order: 2,
+ defaultDescription: "立即锁定屏幕"
+ )
+ let primaryPanelDescriptor = PluginPrimaryPanelDescriptor(
+ controlStyle: .button,
+ menuActionBehavior: .dismissBeforeHandling
+ )
+ var onStateChange: (() -> Void)?
+ var requestPermissionGuidance: ((String) -> Void)?
+ var shortcutBindingResolver: ((String) -> ShortcutBinding?)?
+
+ var primaryPanelState: PluginPanelState {
+ PluginPanelState(
+ subtitle: metadata.defaultDescription,
+ isOn: false,
+ isExpanded: false,
+ isEnabled: true,
+ isVisible: true,
+ detail: nil,
+ errorMessage: nil
+ )
+ }
+
+ func handleAction(_ action: PluginPanelAction) {}
+}
+
+@MainActor
+private final class DeclarativeOnlySearchProviderTestPlugin:
+ MacToolsPlugin,
+ PluginSettingsSearchProviding
+{
+ let metadata = PluginMetadata(
+ id: "declarative-only-search-provider",
+ title: "Declarative Only",
+ iconName: "slider.horizontal.3",
+ iconTint: Color(nsColor: .systemBlue),
+ order: 3,
+ defaultDescription: "Declarative settings only"
+ )
+ var onStateChange: (() -> Void)?
+ var requestPermissionGuidance: ((String) -> Void)?
+ var shortcutBindingResolver: ((String) -> ShortcutBinding?)?
+
+ var settingsSections: [PluginSettingsSection] {
+ [
+ PluginSettingsSection(
+ id: "visible",
+ title: "Visible",
+ description: "Rendered by the host.",
+ status: .init(
+ text: "On",
+ systemImage: "checkmark",
+ tone: .positive
+ ),
+ footnote: nil,
+ buttonTitle: nil,
+ actionID: nil
+ )
+ ]
+ }
+
+ var settingsSearchEntries: [PluginSettingsSearchEntry] {
+ [
+ PluginSettingsSearchEntry(
+ id: "unrendered",
+ title: "Unrendered",
+ description: "There is no custom view for this anchor."
+ )
+ ]
+ }
+}
diff --git a/Tests/App/MenuBarPanelPresenterTests.swift b/Tests/App/MenuBarPanelPresenterTests.swift
index 61d47465..0c35f009 100644
--- a/Tests/App/MenuBarPanelPresenterTests.swift
+++ b/Tests/App/MenuBarPanelPresenterTests.swift
@@ -120,6 +120,15 @@ final class MenuBarPanelPresenterTests: XCTestCase {
),
.showSettings
)
+ XCTAssertEqual(
+ MenuBarPanelKeyboardAction.resolve(
+ for: makeCommandKeyEvent(
+ characters: "k",
+ keyCode: UInt16(kVK_ANSI_K)
+ )
+ ),
+ .showUnifiedSearch
+ )
XCTAssertEqual(
MenuBarPanelKeyboardAction.resolve(
for: makeCommandKeyEvent(
@@ -153,6 +162,17 @@ final class MenuBarPanelPresenterTests: XCTestCase {
XCTAssertEqual(presentationCount, 1)
}
+ func testPanelUnifiedSearchCommandUsesInjectedWindowRouterPath() {
+ var presentationCount = 0
+ let presenter = makePresenter(
+ onOpenUnifiedSearch: { presentationCount += 1 }
+ )
+
+ presenter.performKeyboardAction(.showUnifiedSearch)
+
+ XCTAssertEqual(presentationCount, 1)
+ }
+
func testEscapeUsesCoordinatedPanelDismissalPath() {
var dismissalCount = 0
let presenter = makePresenter(onDismiss: { dismissalCount += 1 })
@@ -321,7 +341,8 @@ final class MenuBarPanelPresenterTests: XCTestCase {
private func makePresenter(
onDismiss: @escaping () -> Void = {},
- onOpenSettings: @escaping () -> Void = {}
+ onOpenSettings: @escaping () -> Void = {},
+ onOpenUnifiedSearch: @escaping () -> Void = {}
) -> MenuBarPanelPresenter {
let defaults = UserDefaults(suiteName: suiteName)!
defaults.removePersistentDomain(forName: suiteName)
@@ -340,6 +361,7 @@ final class MenuBarPanelPresenterTests: XCTestCase {
onDismiss: onDismiss,
onOpenUpdate: {},
onOpenSettings: onOpenSettings,
+ onOpenUnifiedSearch: onOpenUnifiedSearch,
onPresentDiskCleanConfiguration: {},
onPresentLaunchControlConfiguration: {},
onAllPanelsClosed: {}
diff --git a/Tests/App/PluginRuntimeLocalizationTests.swift b/Tests/App/PluginRuntimeLocalizationTests.swift
index b1fa4f41..e94a4d42 100644
--- a/Tests/App/PluginRuntimeLocalizationTests.swift
+++ b/Tests/App/PluginRuntimeLocalizationTests.swift
@@ -96,6 +96,86 @@ final class PluginRuntimeLocalizationTests: XCTestCase {
)
}
+ func testUnifiedSearchResultCountUsesRuntimePluralRules() {
+ setRuntimePreference("en")
+ XCTAssertEqual(
+ AppL10n.searchPluralFormat(
+ "search.resultCountFormat",
+ defaultValue: "%d results",
+ count: 1
+ ),
+ "1 result"
+ )
+ XCTAssertEqual(
+ AppL10n.searchPluralFormat(
+ "search.resultCountFormat",
+ defaultValue: "%d results",
+ count: 2
+ ),
+ "2 results"
+ )
+
+ setRuntimePreference("ru")
+ XCTAssertEqual(
+ AppL10n.searchPluralFormat(
+ "search.resultCountFormat",
+ defaultValue: "%d результатов",
+ count: 2
+ ),
+ "2 результата"
+ )
+ XCTAssertEqual(
+ AppL10n.searchPluralFormat(
+ "search.resultCountFormat",
+ defaultValue: "%d результатов",
+ count: 5
+ ),
+ "5 результатов"
+ )
+
+ setRuntimePreference("ar")
+ XCTAssertEqual(
+ AppL10n.searchPluralFormat(
+ "search.resultCountFormat",
+ defaultValue: "%d نتيجة",
+ count: 0
+ ),
+ "لا نتائج"
+ )
+ XCTAssertEqual(
+ AppL10n.searchPluralFormat(
+ "search.resultCountFormat",
+ defaultValue: "%d نتيجة",
+ count: 2
+ ),
+ "نتيجتان"
+ )
+ XCTAssertEqual(
+ AppL10n.searchPluralFormat(
+ "search.resultCountFormat",
+ defaultValue: "%d نتيجة",
+ count: 3
+ ),
+ "3 نتائج"
+ )
+ }
+
+ func testUnifiedSearchPluginMetadataIncludesLocalizedCategoryAndStableID() {
+ setRuntimePreference("zh-Hans")
+
+ let keywords = MacToolsSearchIndexBuilder.pluginMetadataKeywords(
+ pluginID: "lock-screen",
+ category: "system",
+ releaseChannel: "beta"
+ )
+
+ XCTAssertTrue(keywords.contains("lock-screen"))
+ XCTAssertTrue(keywords.contains("system"))
+ XCTAssertTrue(keywords.contains("系统"))
+ XCTAssertTrue(keywords.contains("beta"))
+ XCTAssertTrue(keywords.contains("Beta"))
+ }
+
func testPrimaryPanelButtonTitleProviderIsReadOnEveryAccess() {
var title = "English"
let descriptor = PluginPrimaryPanelDescriptor(
diff --git a/Tests/App/SettingsDestinationShortcutButtonsTests.swift b/Tests/App/SettingsDestinationShortcutButtonsTests.swift
index 80b0af64..0fd6f512 100644
--- a/Tests/App/SettingsDestinationShortcutButtonsTests.swift
+++ b/Tests/App/SettingsDestinationShortcutButtonsTests.swift
@@ -7,6 +7,89 @@ import MacToolsPluginKit
@MainActor
final class SettingsDestinationShortcutButtonsTests: XCTestCase {
+ func testControlCommandArrowShortcutsMoveBetweenPluginSubpages() {
+ let orderedPanes: [FeatureSettingsPane] = [
+ .dashboardLayout,
+ .featurePanelLayout,
+ .marketplace,
+ .configuration("fan-control")
+ ]
+ let coordinator = SettingsNavigationCoordinator(
+ initialDestination: .plugins(.marketplace),
+ pluginSubpageOrder: { orderedPanes },
+ isPluginConfigurationAvailable: { $0 == "fan-control" }
+ )
+ let window = makeWindow(coordinator: coordinator)
+
+ XCTAssertTrue(performPluginArrowShortcut(up: true, in: window))
+ XCTAssertEqual(coordinator.destination, .plugins(.featurePanelLayout))
+
+ XCTAssertTrue(performPluginArrowShortcut(up: false, in: window))
+ XCTAssertEqual(coordinator.destination, .plugins(.marketplace))
+
+ XCTAssertTrue(performPluginArrowShortcut(up: false, in: window))
+ XCTAssertEqual(coordinator.destination, .plugins(.configuration("fan-control")))
+ }
+
+ func testControlCommandArrowShortcutsDoNothingOutsidePlugins() {
+ let coordinator = SettingsNavigationCoordinator(
+ initialDestination: .about,
+ pluginSubpageOrder: {
+ [.dashboardLayout, .featurePanelLayout, .marketplace]
+ }
+ )
+ let window = makeWindow(coordinator: coordinator)
+
+ XCTAssertTrue(performPluginArrowShortcut(up: true, in: window))
+ XCTAssertTrue(performPluginArrowShortcut(up: false, in: window))
+ XCTAssertEqual(coordinator.destination, .about)
+ XCTAssertEqual(coordinator.history, [.about])
+ }
+
+ func testControlCommandArrowShortcutsStopAtPluginBoundaries() {
+ let orderedPanes: [FeatureSettingsPane] = [
+ .dashboardLayout,
+ .featurePanelLayout,
+ .marketplace
+ ]
+ let coordinator = SettingsNavigationCoordinator(
+ initialDestination: .plugins(.dashboardLayout),
+ pluginSubpageOrder: { orderedPanes }
+ )
+ let window = makeWindow(coordinator: coordinator)
+
+ XCTAssertTrue(performPluginArrowShortcut(up: true, in: window))
+ XCTAssertEqual(coordinator.history, [.plugins(.dashboardLayout)])
+
+ coordinator.navigate(to: .plugins(.marketplace))
+ XCTAssertTrue(performPluginArrowShortcut(up: false, in: window))
+ XCTAssertEqual(
+ coordinator.history,
+ [.plugins(.dashboardLayout), .plugins(.marketplace)]
+ )
+ }
+
+ func testControlCommandArrowReadsConfigurationsAddedAfterShortcutRegistration() {
+ var configurationIDs: [String] = []
+ let coordinator = SettingsNavigationCoordinator(
+ initialDestination: .plugins(.marketplace),
+ pluginSubpageOrder: {
+ FeatureSettingsPane.settingsSidebarOrder(
+ configurationIDs: configurationIDs
+ )
+ },
+ isPluginConfigurationAvailable: { configurationIDs.contains($0) }
+ )
+ let window = makeWindow(coordinator: coordinator)
+
+ XCTAssertTrue(performPluginArrowShortcut(up: false, in: window))
+ XCTAssertEqual(coordinator.destination, .plugins(.marketplace))
+
+ configurationIDs = ["fan-control"]
+ XCTAssertTrue(performPluginArrowShortcut(up: false, in: window))
+ XCTAssertEqual(coordinator.destination, .plugins(.configuration("fan-control")))
+ }
+
func testCommandNumberShortcutsSelectMatchingDestinations() {
let coordinator = SettingsNavigationCoordinator(
pluginSettingsLandingPage: { .featurePanelLayout }
@@ -66,7 +149,7 @@ final class SettingsDestinationShortcutButtonsTests: XCTestCase {
backing: .buffered,
defer: false
)
- window.onLocalKeyboardCommand = { _ in }
+ window.onLocalKeyboardCommand = { _ in false }
window.contentView = NSHostingView(
rootView: SettingsDestinationShortcutButtons(coordinator: coordinator)
)
@@ -74,15 +157,38 @@ final class SettingsDestinationShortcutButtonsTests: XCTestCase {
return window
}
+ private func performPluginArrowShortcut(up: Bool, in window: NSWindow) -> Bool {
+ performShortcut(
+ key: up ? "\u{F700}" : "\u{F701}",
+ keyCode: UInt16(up ? kVK_UpArrow : kVK_DownArrow),
+ modifiers: [.control, .command],
+ in: window
+ )
+ }
+
private func performCommandShortcut(
key: String,
keyCode: UInt16,
in window: NSWindow
+ ) -> Bool {
+ performShortcut(
+ key: key,
+ keyCode: keyCode,
+ modifiers: [.command],
+ in: window
+ )
+ }
+
+ private func performShortcut(
+ key: String,
+ keyCode: UInt16,
+ modifiers: NSEvent.ModifierFlags,
+ in window: NSWindow
) -> Bool {
let event = NSEvent.keyEvent(
with: .keyDown,
location: .zero,
- modifierFlags: [.command],
+ modifierFlags: modifiers,
timestamp: 0,
windowNumber: window.windowNumber,
context: nil,
diff --git a/Tests/App/SettingsNavigationCoordinatorTests.swift b/Tests/App/SettingsNavigationCoordinatorTests.swift
index 08c549a0..b261d530 100644
--- a/Tests/App/SettingsNavigationCoordinatorTests.swift
+++ b/Tests/App/SettingsNavigationCoordinatorTests.swift
@@ -1,8 +1,145 @@
import XCTest
+import MacToolsPluginKit
@testable import MacTools
@MainActor
final class SettingsNavigationCoordinatorTests: XCTestCase {
+ func testPluginSidebarOrderPlacesBuiltInPanesBeforeDisplayedConfigurations() {
+ XCTAssertEqual(
+ FeatureSettingsPane.settingsSidebarOrder(
+ configurationIDs: ["calendar", "fan-control"]
+ ),
+ [
+ .dashboardLayout,
+ .featurePanelLayout,
+ .marketplace,
+ .configuration("calendar"),
+ .configuration("fan-control")
+ ]
+ )
+ }
+
+ func testMovesPluginSubpageInSuppliedVisibleOrder() {
+ let orderedPanes: [FeatureSettingsPane] = [
+ .configuration("fan-control"),
+ .marketplace,
+ .dashboardLayout
+ ]
+ let coordinator = SettingsNavigationCoordinator(
+ initialDestination: .plugins(.marketplace),
+ isPluginConfigurationAvailable: { $0 == "fan-control" }
+ )
+
+ coordinator.movePluginSubpage(.previous, in: orderedPanes)
+ XCTAssertEqual(coordinator.destination, .plugins(.configuration("fan-control")))
+
+ coordinator.movePluginSubpage(.next, in: orderedPanes)
+ XCTAssertEqual(coordinator.destination, .plugins(.marketplace))
+
+ coordinator.movePluginSubpage(.next, in: orderedPanes)
+ XCTAssertEqual(coordinator.destination, .plugins(.dashboardLayout))
+ }
+
+ func testPluginSubpageMovementStopsAtBothBoundaries() {
+ let orderedPanes: [FeatureSettingsPane] = [
+ .dashboardLayout,
+ .featurePanelLayout,
+ .marketplace
+ ]
+ let coordinator = SettingsNavigationCoordinator(
+ initialDestination: .plugins(.dashboardLayout)
+ )
+
+ coordinator.movePluginSubpage(.previous, in: orderedPanes)
+ XCTAssertEqual(coordinator.destination, .plugins(.dashboardLayout))
+ XCTAssertEqual(coordinator.history, [.plugins(.dashboardLayout)])
+
+ coordinator.navigate(to: .plugins(.marketplace))
+ coordinator.movePluginSubpage(.next, in: orderedPanes)
+ XCTAssertEqual(coordinator.destination, .plugins(.marketplace))
+ XCTAssertEqual(
+ coordinator.history,
+ [.plugins(.dashboardLayout), .plugins(.marketplace)]
+ )
+ }
+
+ func testPluginSubpageMovementDoesNothingOutsidePlugins() {
+ let coordinator = SettingsNavigationCoordinator(initialDestination: .about)
+
+ coordinator.movePluginSubpage(
+ .next,
+ in: [.dashboardLayout, .featurePanelLayout, .marketplace]
+ )
+
+ XCTAssertEqual(coordinator.destination, .about)
+ XCTAssertEqual(coordinator.history, [.about])
+ }
+
+ func testPluginSubpageMovementRecordsHistoryAndInvalidatesForwardHistory() {
+ let orderedPanes: [FeatureSettingsPane] = [
+ .dashboardLayout,
+ .featurePanelLayout,
+ .marketplace
+ ]
+ let coordinator = SettingsNavigationCoordinator(
+ initialDestination: .plugins(.dashboardLayout)
+ )
+ coordinator.navigate(to: .plugins(.featurePanelLayout))
+ coordinator.navigate(to: .plugins(.marketplace))
+ coordinator.goBack()
+
+ coordinator.movePluginSubpage(.previous, in: orderedPanes)
+
+ XCTAssertEqual(coordinator.destination, .plugins(.dashboardLayout))
+ XCTAssertEqual(
+ coordinator.history,
+ [
+ .plugins(.dashboardLayout),
+ .plugins(.featurePanelLayout),
+ .plugins(.dashboardLayout)
+ ]
+ )
+ XCTAssertFalse(coordinator.canGoForward)
+ }
+
+ func testPluginSubpageMovementIgnoresRemovedCurrentConfiguration() {
+ var availableConfigurationIDs: Set = ["fan-control"]
+ let coordinator = SettingsNavigationCoordinator(
+ initialDestination: .plugins(.configuration("fan-control")),
+ isPluginConfigurationAvailable: { availableConfigurationIDs.contains($0) }
+ )
+ availableConfigurationIDs.remove("fan-control")
+
+ coordinator.movePluginSubpage(
+ .previous,
+ in: [.dashboardLayout, .featurePanelLayout, .marketplace]
+ )
+
+ XCTAssertEqual(coordinator.destination, .plugins(.configuration("fan-control")))
+ XCTAssertEqual(coordinator.history, [.plugins(.configuration("fan-control"))])
+ }
+
+ func testPluginSubpageMovementReadsLatestDynamicOrder() {
+ var configurationIDs: [String] = []
+ let coordinator = SettingsNavigationCoordinator(
+ initialDestination: .plugins(.marketplace),
+ pluginSubpageOrder: {
+ FeatureSettingsPane.settingsSidebarOrder(
+ configurationIDs: configurationIDs
+ )
+ },
+ isPluginConfigurationAvailable: { configurationIDs.contains($0) }
+ )
+
+ coordinator.movePluginSubpage(.next)
+ XCTAssertEqual(coordinator.destination, .plugins(.marketplace))
+
+ configurationIDs = ["fan-control"]
+ coordinator.movePluginSubpage(.next)
+
+ XCTAssertEqual(coordinator.destination, .plugins(.configuration("fan-control")))
+ }
+
func testRecordsCompletePluginDestinationsAndRestoresExactPaneDuringTraversal() {
let coordinator = SettingsNavigationCoordinator(
isPluginConfigurationAvailable: { $0 == "fan-control" }
@@ -210,4 +347,283 @@ final class SettingsNavigationCoordinatorTests: XCTestCase {
XCTAssertNotEqual(firstRequest.id, secondRequest.id)
}
+
+ func testUnifiedSearchPresentationTracksOriginAndRepeatedFocusRequests() {
+ let coordinator = SettingsNavigationCoordinator()
+
+ coordinator.presentUnifiedSearch(origin: .pluginSidebar)
+ let firstFocusRequestID = coordinator.unifiedSearchFocusRequestID
+
+ XCTAssertTrue(coordinator.isUnifiedSearchPresented)
+ XCTAssertEqual(coordinator.unifiedSearchPresentationOrigin, .pluginSidebar)
+
+ coordinator.presentUnifiedSearch(origin: .keyboard)
+
+ XCTAssertTrue(coordinator.isUnifiedSearchPresented)
+ XCTAssertEqual(coordinator.unifiedSearchPresentationOrigin, .keyboard)
+ XCTAssertGreaterThan(coordinator.unifiedSearchFocusRequestID, firstFocusRequestID)
+
+ coordinator.dismissUnifiedSearch()
+
+ XCTAssertFalse(coordinator.isUnifiedSearchPresented)
+ XCTAssertNil(coordinator.unifiedSearchPresentationOrigin)
+ }
+
+ func testLocalSearchFocusDoesNotMoveBehindUnifiedSearch() {
+ let coordinator = SettingsNavigationCoordinator()
+ coordinator.navigate(to: .plugins(.marketplace))
+ coordinator.presentUnifiedSearch(origin: .keyboard)
+
+ XCTAssertFalse(coordinator.requestSearchFocus())
+ XCTAssertNil(coordinator.searchFocusRequest)
+ }
+
+ func testUnifiedSearchQuickSelectionRequestsAreValidatedAndRepeatable() throws {
+ let coordinator = SettingsNavigationCoordinator()
+
+ XCTAssertFalse(coordinator.requestUnifiedSearchQuickSelection(number: 1))
+ coordinator.presentUnifiedSearch(origin: .keyboard)
+ XCTAssertFalse(coordinator.requestUnifiedSearchQuickSelection(number: 0))
+ XCTAssertFalse(coordinator.requestUnifiedSearchQuickSelection(number: 10))
+
+ XCTAssertTrue(coordinator.requestUnifiedSearchQuickSelection(number: 1))
+ let firstRequest = try XCTUnwrap(coordinator.unifiedSearchQuickSelectionRequest)
+ XCTAssertEqual(firstRequest.number, 1)
+
+ XCTAssertTrue(coordinator.requestUnifiedSearchQuickSelection(number: 1))
+ let secondRequest = try XCTUnwrap(coordinator.unifiedSearchQuickSelectionRequest)
+ XCTAssertNotEqual(firstRequest.id, secondRequest.id)
+
+ coordinator.dismissUnifiedSearch()
+ XCTAssertNil(coordinator.unifiedSearchQuickSelectionRequest)
+ }
+
+ func testUnifiedSearchQuickSelectionRequestCanOnlyBeConsumedOnce() throws {
+ let coordinator = SettingsNavigationCoordinator()
+ coordinator.presentUnifiedSearch(origin: .keyboard)
+ XCTAssertTrue(coordinator.requestUnifiedSearchQuickSelection(number: 2))
+ let request = try XCTUnwrap(
+ coordinator.unifiedSearchQuickSelectionRequest
+ )
+
+ XCTAssertTrue(
+ coordinator.consumeUnifiedSearchQuickSelectionRequest(request)
+ )
+ XCTAssertNil(coordinator.unifiedSearchQuickSelectionRequest)
+ XCTAssertFalse(
+ coordinator.consumeUnifiedSearchQuickSelectionRequest(request)
+ )
+ }
+
+ func testSearchNavigationKeepsPaletteOpenForUnavailablePlugin() {
+ let coordinator = SettingsNavigationCoordinator(
+ isPluginConfigurationAvailable: { _ in false }
+ )
+ coordinator.presentUnifiedSearch(origin: .keyboard)
+
+ coordinator.navigateFromSearch(
+ to: .plugins(.configuration("removed-plugin")),
+ target: .plugin(
+ PluginSettingsSearchTarget(
+ pluginID: "removed-plugin",
+ entryID: "setting"
+ )
+ )
+ )
+
+ XCTAssertTrue(coordinator.isUnifiedSearchPresented)
+ XCTAssertEqual(coordinator.destination, .general)
+ XCTAssertEqual(coordinator.history, [.general])
+ XCTAssertNil(coordinator.searchRevealRequest)
+ }
+
+ func testSearchNavigationKeepsPaletteOpenForUnavailableSurfacePlugin() {
+ let coordinator = SettingsNavigationCoordinator(
+ isPluginSurfaceAvailable: { _ in false }
+ )
+ let target = SurfaceSettingsSearchTarget(
+ surface: .featurePanel,
+ pluginID: "removed-plugin"
+ )
+ coordinator.presentUnifiedSearch(origin: .keyboard)
+
+ coordinator.navigateFromSearch(
+ to: .plugins(.featurePanelLayout),
+ target: .surface(target)
+ )
+
+ XCTAssertTrue(coordinator.isUnifiedSearchPresented)
+ XCTAssertEqual(coordinator.destination, .general)
+ XCTAssertEqual(coordinator.history, [.general])
+ XCTAssertNil(coordinator.searchRevealRequest)
+ }
+
+ func testSearchNavigationKeepsPaletteOpenForUnavailableExactPluginEntry() {
+ let coordinator = SettingsNavigationCoordinator(
+ isPluginConfigurationAvailable: { $0 == "installed-plugin" },
+ isPluginSettingsSearchTargetAvailable: { _ in false }
+ )
+ let target = PluginSettingsSearchTarget(
+ pluginID: "installed-plugin",
+ entryID: "removed-setting"
+ )
+ coordinator.presentUnifiedSearch(origin: .keyboard)
+
+ XCTAssertFalse(
+ coordinator.navigateFromSearch(
+ to: .plugins(.configuration("installed-plugin")),
+ target: .plugin(target)
+ )
+ )
+ XCTAssertTrue(coordinator.isUnifiedSearchPresented)
+ XCTAssertEqual(coordinator.destination, .general)
+ XCTAssertEqual(coordinator.history, [.general])
+ XCTAssertNil(coordinator.searchRevealRequest)
+ }
+
+ func testSearchNavigationRejectsMismatchedTargetAndDestination() {
+ let coordinator = SettingsNavigationCoordinator()
+ coordinator.presentUnifiedSearch(origin: .keyboard)
+
+ XCTAssertFalse(
+ coordinator.navigateFromSearch(
+ to: .plugins(.featurePanelLayout),
+ target: .surface(
+ SurfaceSettingsSearchTarget(
+ surface: .dashboard,
+ pluginID: "display"
+ )
+ )
+ )
+ )
+ XCTAssertTrue(coordinator.isUnifiedSearchPresented)
+ XCTAssertEqual(coordinator.destination, .general)
+ XCTAssertNil(coordinator.searchRevealRequest)
+ }
+
+ func testSearchNavigationRevealsAvailableMarketplacePlugin() throws {
+ let target = MarketplacePluginSearchTarget(pluginID: "failed-plugin")
+ let coordinator = SettingsNavigationCoordinator(
+ isPluginManagementAvailable: { $0 == target.pluginID }
+ )
+ coordinator.presentUnifiedSearch(origin: .keyboard)
+
+ XCTAssertTrue(
+ coordinator.navigateFromSearch(
+ to: .plugins(.marketplace),
+ target: .marketplace(target)
+ )
+ )
+ XCTAssertFalse(coordinator.isUnifiedSearchPresented)
+ XCTAssertEqual(coordinator.destination, .plugins(.marketplace))
+ XCTAssertEqual(
+ try XCTUnwrap(coordinator.searchRevealRequest).target,
+ .marketplace(target)
+ )
+ }
+
+ func testSearchNavigationAllowsAvailableSurfacePlugin() throws {
+ let expectedTarget = SurfaceSettingsSearchTarget(
+ surface: .dashboard,
+ pluginID: "hidden-plugin"
+ )
+ var validatedTargets: [SurfaceSettingsSearchTarget] = []
+ let coordinator = SettingsNavigationCoordinator(
+ isPluginSurfaceAvailable: { target in
+ validatedTargets.append(target)
+ return target == expectedTarget
+ }
+ )
+ coordinator.presentUnifiedSearch(origin: .keyboard)
+
+ coordinator.navigateFromSearch(
+ to: .plugins(.dashboardLayout),
+ target: .surface(expectedTarget)
+ )
+
+ XCTAssertEqual(validatedTargets, [expectedTarget])
+ XCTAssertFalse(coordinator.isUnifiedSearchPresented)
+ XCTAssertEqual(coordinator.destination, .plugins(.dashboardLayout))
+ XCTAssertEqual(
+ try XCTUnwrap(coordinator.searchRevealRequest).target,
+ .surface(expectedTarget)
+ )
+ }
+
+ func testSearchNavigationDismissesPaletteAndPublishesExactRevealTarget() throws {
+ let coordinator = SettingsNavigationCoordinator(
+ isPluginConfigurationAvailable: { $0 == "keep-awake" }
+ )
+ let target = PluginSettingsSearchTarget(
+ pluginID: "keep-awake",
+ entryID: "keep-display-on"
+ )
+ coordinator.presentUnifiedSearch(origin: .keyboard)
+
+ coordinator.navigateFromSearch(
+ to: .plugins(.configuration("keep-awake")),
+ target: .plugin(target)
+ )
+
+ XCTAssertFalse(coordinator.isUnifiedSearchPresented)
+ XCTAssertEqual(
+ coordinator.destination,
+ .plugins(.configuration("keep-awake"))
+ )
+ let request = try XCTUnwrap(coordinator.searchRevealRequest)
+ XCTAssertEqual(request.target, .plugin(target))
+
+ coordinator.clearSearchRevealRequest(request)
+ XCTAssertNil(coordinator.searchRevealRequest)
+ }
+
+ func testPageLevelSearchNavigationClearsPreviousRevealTarget() {
+ let coordinator = SettingsNavigationCoordinator(
+ isPluginConfigurationAvailable: { $0 == "keep-awake" }
+ )
+ let target = PluginSettingsSearchTarget(
+ pluginID: "keep-awake",
+ entryID: "keep-display-on"
+ )
+
+ coordinator.navigateFromSearch(
+ to: .plugins(.configuration("keep-awake")),
+ target: .plugin(target)
+ )
+ coordinator.navigateFromSearch(to: .about, target: nil)
+
+ XCTAssertEqual(coordinator.destination, .about)
+ XCTAssertNil(coordinator.searchRevealRequest)
+ }
+
+ func testGeneralSettingSearchNavigationPublishesExactRevealTarget() throws {
+ let coordinator = SettingsNavigationCoordinator(
+ initialDestination: .about
+ )
+ coordinator.presentUnifiedSearch(origin: .keyboard)
+
+ coordinator.navigateFromSearch(
+ to: .general,
+ target: .general(.language)
+ )
+
+ XCTAssertFalse(coordinator.isUnifiedSearchPresented)
+ XCTAssertEqual(coordinator.destination, .general)
+ let request = try XCTUnwrap(coordinator.searchRevealRequest)
+ XCTAssertEqual(request.target, .general(.language))
+ }
+
+ func testClearingRevealTargetOnlyClearsTheMatchingRequest() throws {
+ let coordinator = SettingsNavigationCoordinator(initialDestination: .about)
+ coordinator.navigateFromSearch(
+ to: .general,
+ target: .general(.language)
+ )
+
+ coordinator.clearSearchRevealRequest(matching: .general(.appearance))
+ XCTAssertNotNil(coordinator.searchRevealRequest)
+
+ coordinator.clearSearchRevealRequest(matching: .general(.language))
+ XCTAssertNil(coordinator.searchRevealRequest)
+ }
}
diff --git a/Tests/Core/Plugins/PluginHostTestSupport.swift b/Tests/Core/Plugins/PluginHostTestSupport.swift
index 9de8404d..485a42bb 100644
--- a/Tests/Core/Plugins/PluginHostTestSupport.swift
+++ b/Tests/Core/Plugins/PluginHostTestSupport.swift
@@ -5,16 +5,20 @@ import MacToolsPluginKit
@MainActor
func makePluginHostForTests(
plugins: [any MacToolsPlugin],
- suiteName: String = "PluginHostTestSupport-\(UUID().uuidString)"
+ suiteName: String = "PluginHostTestSupport-\(UUID().uuidString)",
+ dynamicPluginManager: DynamicPluginManager? = nil,
+ loadDynamicPluginsOnInit: Bool = true
) -> PluginHost {
let defaults = UserDefaults(suiteName: suiteName)!
defaults.removePersistentDomain(forName: suiteName)
return PluginHost(
plugins: plugins,
+ dynamicPluginManager: dynamicPluginManager,
shortcutStore: ShortcutStore(userDefaults: defaults),
pluginDisplayPreferencesStore: PluginDisplayPreferencesStore(userDefaults: defaults),
preferencesBackupStore: PreferencesBackupStore(userDefaults: defaults),
- globalShortcutManager: GlobalShortcutManager()
+ globalShortcutManager: GlobalShortcutManager(),
+ loadDynamicPluginsOnInit: loadDynamicPluginsOnInit
)
}
diff --git a/Tests/Core/Plugins/PluginLocalizationCatalogAuditTests.swift b/Tests/Core/Plugins/PluginLocalizationCatalogAuditTests.swift
index aad8e5ae..5365e808 100644
--- a/Tests/Core/Plugins/PluginLocalizationCatalogAuditTests.swift
+++ b/Tests/Core/Plugins/PluginLocalizationCatalogAuditTests.swift
@@ -35,6 +35,7 @@ final class PluginLocalizationCatalogAuditTests: XCTestCase {
"plugin.capability.featurePanel",
"plugin.capability.settingsOnly",
"plugin.capability.unknown",
+ "plugin.marketplace.description",
]
private let pluginLayoutSettingsLocalizationKeys = [
@@ -51,6 +52,7 @@ final class PluginLocalizationCatalogAuditTests: XCTestCase {
"plugins.featurePanel.open",
"plugins.featurePanel.title",
"plugins.layout.restoreDefaultOrder",
+ "plugins.sidebar.accessibilityLabel",
"plugins.sidebar.dashboard",
"plugins.sidebar.featurePanel",
"plugins.sidebar.pluginsSection",
@@ -118,6 +120,33 @@ final class PluginLocalizationCatalogAuditTests: XCTestCase {
XCTAssertTrue(failures.isEmpty, failures.joined(separator: "\n"))
}
+ func testUnifiedSearchPluginProvidersRequireFirstCompatibleHostVersion() throws {
+ let expectedMinimumHostVersion = "1.1.5"
+ let pluginNames = [
+ "DisplayBrightness",
+ "DisplaySleep",
+ "KeepAwake",
+ "LockScreen",
+ ]
+
+ for pluginName in pluginNames {
+ let manifestURL = repositoryRoot
+ .appending(path: "Plugins")
+ .appending(path: pluginName)
+ .appending(path: "plugin.json")
+ let manifest = try jsonObject(at: manifestURL)
+
+ let minimumHostVersion = try XCTUnwrap(manifest["minHostVersion"] as? String)
+ XCTAssertTrue(
+ PluginVersionComparator.isVersion(
+ minimumHostVersion,
+ atLeast: expectedMinimumHostVersion
+ ),
+ "\(pluginName) must not be published to hosts that predate unified-search PluginKit symbols"
+ )
+ }
+ }
+
func testPluginManagementLocalizationKeysCoverAllSupportedLanguages() throws {
let catalogURL = repositoryRoot
.appending(path: "Sources")
@@ -175,6 +204,80 @@ final class PluginLocalizationCatalogAuditTests: XCTestCase {
XCTAssertTrue(failures.isEmpty, failures.joined(separator: "\n"))
}
+ func testUnifiedSearchLocalizationKeysCoverAllSupportedLanguages() throws {
+ let localizationDirectory = repositoryRoot
+ .appending(path: "Sources")
+ .appending(path: "Resources")
+ .appending(path: "Localization")
+ let catalogURL = localizationDirectory.appending(path: "Search.xcstrings")
+ let catalog = try jsonObject(at: catalogURL)
+ guard let strings = catalog["strings"] as? [String: [String: Any]] else {
+ throw AuditError.invalidCatalog(catalogURL.path)
+ }
+
+ let appDirectory = repositoryRoot.appending(path: "Sources").appending(path: "App")
+ let sourceNames = [
+ "MacToolsSearch.swift",
+ "SettingsView.swift",
+ "UnifiedSearchPaletteView.swift",
+ ]
+ let keys = try sourceNames.reduce(into: Set()) { result, name in
+ let source = try String(
+ contentsOf: appDirectory.appending(path: name),
+ encoding: .utf8
+ )
+ result.formUnion(staticLocalizationKeys(in: source).filter { $0.hasPrefix("search.") })
+ }
+
+ var failures: [String] = []
+ for key in keys.sorted() {
+ validate(
+ key: key,
+ in: strings,
+ pluginName: "Unified Search",
+ failures: &failures
+ )
+ }
+
+ XCTAssertFalse(keys.isEmpty, "Unified Search: no static localization keys were discovered")
+ XCTAssertTrue(failures.isEmpty, failures.joined(separator: "\n"))
+ }
+
+ func testUnifiedSearchResultCountUsesRequiredPluralForms() throws {
+ let catalogURL = repositoryRoot
+ .appending(path: "Sources")
+ .appending(path: "Resources")
+ .appending(path: "Localization")
+ .appending(path: "Search.xcstrings")
+ let catalog = try jsonObject(at: catalogURL)
+ let strings = try XCTUnwrap(catalog["strings"] as? [String: [String: Any]])
+ let entry = try XCTUnwrap(strings["search.resultCountFormat"])
+ let localizations = try XCTUnwrap(entry["localizations"] as? [String: Any])
+ let expectedCategories: [String: Set] = [
+ "ar": ["zero", "one", "two", "few", "many", "other"],
+ "de": ["one", "other"],
+ "en": ["one", "other"],
+ "es": ["one", "other"],
+ "fr": ["one", "other"],
+ "ja": ["other"],
+ "ko": ["other"],
+ "pt": ["one", "other"],
+ "ru": ["one", "few", "many", "other"],
+ "zh-Hans": ["other"],
+ "zh-Hant": ["other"],
+ ]
+
+ for (language, categories) in expectedCategories {
+ let localization = try XCTUnwrap(localizations[language] as? [String: Any])
+ let variations = try XCTUnwrap(localization["variations"] as? [String: Any])
+ let plural = try XCTUnwrap(variations["plural"] as? [String: Any])
+ XCTAssertTrue(
+ categories.isSubset(of: Set(plural.keys)),
+ "\(language) is missing plural categories \(categories.subtracting(plural.keys))"
+ )
+ }
+ }
+
private func validate(
key: String,
in catalog: [String: [String: Any]],
@@ -188,14 +291,36 @@ final class PluginLocalizationCatalogAuditTests: XCTestCase {
let localizations = entry["localizations"] as? [String: Any]
for language in supportedLanguages {
- let value = ((localizations?[language] as? [String: Any])?["stringUnit"] as? [String: Any])?["value"] as? String
- guard let value, !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
+ guard
+ let localization = localizations?[language],
+ containsTranslatedValue(in: localization)
+ else {
failures.append("\(pluginName): localization key \(key) is missing a translated value for \(language)")
continue
}
}
}
+ private func containsTranslatedValue(in value: Any) -> Bool {
+ if let dictionary = value as? [String: Any] {
+ if
+ let stringUnit = dictionary["stringUnit"] as? [String: Any],
+ let translatedValue = stringUnit["value"] as? String,
+ !translatedValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ {
+ return true
+ }
+
+ return dictionary.values.contains(where: containsTranslatedValue)
+ }
+
+ if let array = value as? [Any] {
+ return array.contains(where: containsTranslatedValue)
+ }
+
+ return false
+ }
+
private func pluginDirectories() throws -> [URL] {
try FileManager.default.contentsOfDirectory(
at: repositoryRoot.appending(path: "Plugins"),
@@ -233,7 +358,7 @@ final class PluginLocalizationCatalogAuditTests: XCTestCase {
private func staticLocalizationKeys(in source: String) -> Set {
let expression = try! NSRegularExpression(
- pattern: #"(?:\b(?:self\.)?[A-Za-z_]\w*|PluginLocalization\([^\n]*\))\.(?:string|format)\s*\(\s*\"([^\"]+)\"\s*,\s*defaultValue\s*:"#
+ pattern: #"(?:\b(?:self\.)?[A-Za-z_]\w*|PluginLocalization\([^\n]*\))\.(?:string|format|search|searchFormat|searchPluralFormat)\s*\(\s*\"([^\"]+)\"\s*,\s*defaultValue\s*:"#
)
let range = NSRange(source.startIndex..., in: source)
return Set(expression.matches(in: source, range: range).compactMap { match in
diff --git a/Tests/Core/Shortcuts/AppShortcutTests.swift b/Tests/Core/Shortcuts/AppShortcutTests.swift
index 914b01e4..39d7874a 100644
--- a/Tests/Core/Shortcuts/AppShortcutTests.swift
+++ b/Tests/Core/Shortcuts/AppShortcutTests.swift
@@ -1,4 +1,5 @@
import AppKit
+import Carbon
import XCTest
import MacToolsPluginKit
@testable import MacTools
@@ -92,6 +93,179 @@ final class AppShortcutTests: XCTestCase {
XCTAssertFalse(try item(.toggleDashboard, in: host).canClear)
}
+ func testFixedMacToolsCommandsAreReservedFromConfigurableShortcuts() {
+ let commandKeyCodes = [
+ kVK_ANSI_Comma,
+ kVK_ANSI_F,
+ kVK_ANSI_K,
+ kVK_ANSI_1,
+ kVK_ANSI_2,
+ kVK_ANSI_3,
+ kVK_ANSI_4,
+ kVK_ANSI_5,
+ kVK_ANSI_6,
+ kVK_ANSI_7,
+ kVK_ANSI_8,
+ kVK_ANSI_9,
+ kVK_ANSI_LeftBracket,
+ kVK_ANSI_RightBracket
+ ]
+
+ for keyCode in commandKeyCodes {
+ XCTAssertNotNil(
+ MacToolsReservedShortcutBindings.validationError(
+ for: ShortcutBinding(
+ keyCode: UInt16(keyCode),
+ modifiers: .command
+ )
+ )
+ )
+ }
+
+ for keyCode in [kVK_UpArrow, kVK_DownArrow] {
+ XCTAssertNotNil(
+ MacToolsReservedShortcutBindings.validationError(
+ for: ShortcutBinding(
+ keyCode: UInt16(keyCode),
+ modifiers: [.control, .command]
+ )
+ )
+ )
+ }
+
+ XCTAssertNil(
+ MacToolsReservedShortcutBindings.validationError(
+ for: ShortcutBinding(
+ keyCode: UInt16(kVK_ANSI_K),
+ modifiers: [.option, .command]
+ )
+ )
+ )
+ XCTAssertNil(
+ MacToolsReservedShortcutBindings.validationError(
+ for: ShortcutBinding(
+ keyCode: UInt16(kVK_ANSI_L),
+ modifiers: .command
+ )
+ )
+ )
+ }
+
+ func testAppAndPluginShortcutRecordersRejectReservedBindings() throws {
+ let reservedBinding = ShortcutBinding(
+ keyCode: UInt16(kVK_ANSI_K),
+ modifiers: .command
+ )
+ let manager = GlobalShortcutManager()
+ let host = makeHost(
+ defaults: try makeDefaults(),
+ plugins: [AppShortcutTestPlugin(defaultBinding: nil)],
+ manager: manager
+ )
+ let expectedError = ShortcutValidationError.duplicate(
+ ownerDescription: AppMetadata.appName
+ ).localizedDescription
+
+ XCTAssertEqual(
+ host.setAppShortcutBindingAndReturnError(
+ reservedBinding,
+ for: .toggleDashboard
+ ),
+ expectedError
+ )
+ XCTAssertEqual(
+ host.setShortcutBindingAndReturnError(
+ reservedBinding,
+ for: AppShortcutTestPlugin.shortcutItemID
+ ),
+ expectedError
+ )
+ XCTAssertFalse(
+ manager.debugRegistrationsForTests.contains {
+ $0.binding == reservedBinding
+ }
+ )
+ }
+
+ func testStoredReservedBindingsRemainVisibleButDoNotRegisterGlobally() throws {
+ let defaults = try makeDefaults()
+ let reservedBinding = ShortcutBinding(
+ keyCode: UInt16(kVK_ANSI_K),
+ modifiers: .command
+ )
+ let store = ShortcutStore(userDefaults: defaults)
+ store.setCustomization(
+ .custom(reservedBinding),
+ for: AppShortcutAction.toggleDashboard.rawValue
+ )
+ store.setCustomization(
+ .custom(reservedBinding),
+ for: AppShortcutTestPlugin.shortcutItemID
+ )
+ let manager = GlobalShortcutManager()
+ let host = makeHost(
+ defaults: defaults,
+ plugins: [AppShortcutTestPlugin(defaultBinding: nil)],
+ manager: manager
+ )
+ let expectedError = ShortcutValidationError.duplicate(
+ ownerDescription: AppMetadata.appName
+ ).localizedDescription
+
+ XCTAssertEqual(
+ try item(.toggleDashboard, in: host).errorMessage,
+ expectedError
+ )
+ XCTAssertEqual(
+ host.shortcutItems.first {
+ $0.id == AppShortcutTestPlugin.shortcutItemID
+ }?.errorMessage,
+ expectedError
+ )
+ XCTAssertTrue(
+ manager.debugRegistrationsForTests.allSatisfy {
+ $0.binding != reservedBinding
+ }
+ )
+ }
+
+ func testImportRejectsReservedBindingsAtomically() throws {
+ let plugin = AppShortcutTestPlugin(defaultBinding: nil)
+ let host = makeHost(defaults: try makeDefaults(), plugins: [plugin])
+ let backup = PreferencesBackup(
+ application: validApplicationPreferences,
+ pluginDisplay: PluginDisplayPreferencesBackup(
+ orderedPluginIDs: [plugin.metadata.id],
+ hiddenPluginIDs: []
+ ),
+ shortcutCustomizations: [
+ AppShortcutAction.openSettings.rawValue: .custom(
+ ShortcutBinding(
+ keyCode: UInt16(kVK_ANSI_K),
+ modifiers: .command
+ )
+ ),
+ AppShortcutTestPlugin.shortcutItemID: .custom(
+ ShortcutBinding(
+ keyCode: UInt16(kVK_ANSI_4),
+ modifiers: .command
+ )
+ )
+ ]
+ )
+
+ let result = try host.importPreferences(backup)
+
+ XCTAssertEqual(
+ Set(result.shortcutErrors.keys),
+ [
+ AppShortcutAction.openSettings.rawValue,
+ AppShortcutTestPlugin.shortcutItemID
+ ]
+ )
+ XCTAssertTrue(host.makePreferencesBackup().shortcutCustomizations.isEmpty)
+ }
+
func testAppAndPluginShortcutsRejectConflictsInBothDirections() throws {
let pluginBinding = ShortcutBinding(keyCode: 5, modifiers: [.command, .option])
let appBinding = ShortcutBinding(keyCode: 6, modifiers: [.command, .shift])
diff --git a/changes/unreleased/app-volume-host-permission.md b/changes/unreleased/app-volume-host-permission.md
new file mode 100644
index 00000000..252f980b
--- /dev/null
+++ b/changes/unreleased/app-volume-host-permission.md
@@ -0,0 +1,7 @@
+---
+release: app
+type: changed
+area: Permissions
+---
+
+MacTools now explains its local-only System Audio Recording use when an installed plugin provides per-app volume controls.
diff --git a/changes/unreleased/app-volume-plugin.md b/changes/unreleased/app-volume-plugin.md
new file mode 100644
index 00000000..b99d044f
--- /dev/null
+++ b/changes/unreleased/app-volume-plugin.md
@@ -0,0 +1,7 @@
+---
+release: plugin
+type: added
+area: Audio
+---
+
+Added App Volume controls for adjusting and remembering the volume of each app currently playing audio on macOS 15 or later.
diff --git a/changes/unreleased/diskclean-rewrite-2.0.0.md b/changes/unreleased/diskclean-rewrite-2.0.0.md
new file mode 100644
index 00000000..b54c5d38
--- /dev/null
+++ b/changes/unreleased/diskclean-rewrite-2.0.0.md
@@ -0,0 +1,7 @@
+---
+release: plugin
+type: changed
+area: Disk Cleanup
+---
+
+Disk Cleanup is rewritten for faster scanning and safer deletion. It now covers system caches, developer artifacts, and leftover installers; moves items to Trash by default; skips Full Disk Access–protected paths until authorized; blocks incomplete or locked results from cleanup; and rejects overly broad developer-artifact scan folders such as Home or system locations.
diff --git a/changes/unreleased/native-settings-navigation.md b/changes/unreleased/native-settings-navigation.md
index b8120396..74fbf685 100644
--- a/changes/unreleased/native-settings-navigation.md
+++ b/changes/unreleased/native-settings-navigation.md
@@ -4,4 +4,4 @@ type: changed
area: Settings
---
-Plugin settings now use an always-visible, appearance-adaptive native resizable sidebar, while the Settings window keeps a consistent width, preserves a usable minimum size, and provides Back and Forward controls in its toolbar.
+Plugin settings now use an always-visible, appearance-adaptive native resizable sidebar with focused arrow-key navigation and window-wide Control-Command-Up/Down shortcuts. The Settings window keeps a consistent width, preserves a usable minimum size, and provides Back and Forward controls in its toolbar.
diff --git a/changes/unreleased/plugin-search-discovery.md b/changes/unreleased/plugin-search-discovery.md
new file mode 100644
index 00000000..02bd34b5
--- /dev/null
+++ b/changes/unreleased/plugin-search-discovery.md
@@ -0,0 +1,7 @@
+---
+release: plugin
+type: added
+area: Plugin SDK
+---
+
+Display Brightness and Prevent Sleep expose direct setting destinations, while Display Sleep and Lock Screen opt in to safe command discovery.
diff --git a/changes/unreleased/unified-search.md b/changes/unreleased/unified-search.md
new file mode 100644
index 00000000..822b56f2
--- /dev/null
+++ b/changes/unreleased/unified-search.md
@@ -0,0 +1,7 @@
+---
+release: app
+type: added
+area: Settings
+---
+
+Press Command-K or use the Plugins sidebar launcher to search app destinations, General settings, plugins, individual plugin settings, and supported commands in one keyboard- and accessibility-friendly palette, with physical Command-1 through Command-9 number-row selection, spoken selection changes, exact setting and plugin-row navigation, localized result counts, and a responsive, dimmed presentation that keeps results prominent. Fixed MacTools navigation shortcuts are protected from conflicting global shortcut assignments.
diff --git a/docs/plugins/app-volume.md b/docs/plugins/app-volume.md
new file mode 100644
index 00000000..186d147a
--- /dev/null
+++ b/docs/plugins/app-volume.md
@@ -0,0 +1,30 @@
+# App Volume Plugin
+
+The App Volume plugin provides per-application output volume controls on macOS 15 or later. It discovers Core Audio processes that are currently producing output, groups helper processes under their responsible foreground application, and stores each app's preferred volume locally by bundle identifier.
+
+## Audio Routing
+
+For every app whose preferred volume is below 100%, the plugin creates a private Core Audio process tap and a private aggregate output route. The tap suppresses the app's original path only while MacTools is actively reading it. The real-time callback applies a short gain ramp and writes the adjusted PCM samples to the current output device. Returning an app to 100%, stopping playback, changing the output device, disabling the plugin, or quitting MacTools removes the route and restores the original path.
+
+The first release supports standard two-channel output devices. It does not create a route when the current output layout cannot be confirmed as stereo, preventing an incompatible buffer layout from muting or corrupting multi-channel output.
+
+## Privacy and Permission
+
+macOS requires System Audio Recording permission before one app can process another app's audio. MacTools requests this permission only after the user moves an app below 100% or explicitly checks the permission card. Audio processing stays in the real-time Core Audio callback and is never recorded, saved, or uploaded.
+
+## Development Validation
+
+Generate the plugin targets and run the focused tests with:
+
+```bash
+make generate
+xcodebuild \
+ -project MacTools.xcodeproj \
+ -scheme MacTools \
+ -configuration Debug \
+ -derivedDataPath build/DerivedData \
+ test \
+ -only-testing:MacToolsTests/AppVolumePluginTests
+```
+
+Manual audio validation should cover built-in speakers, wired headphones, Bluetooth output, output-device switching while audio is playing, app termination, plugin disable, and permission denial.
diff --git a/docs/plugins/local-native-plugins.md b/docs/plugins/local-native-plugins.md
index c513eb93..b08c18a8 100644
--- a/docs/plugins/local-native-plugins.md
+++ b/docs/plugins/local-native-plugins.md
@@ -90,6 +90,8 @@ Finder Sync, Share, Quick Look, and other macOS app extensions are host-level ta
In Debug development, `make run` builds the main `MacTools` scheme, then synchronizes the freshly built plugin bundles from `build/DerivedData/Build/Products/Debug` into `build/LocalPlugins/Packages`, generates `build/LocalPlugins/catalog.dev.json`, and updates `~/Library/Application Support/MacTools Dev/Plugins/Installed`. This keeps the local marketplace and installed plugins on the latest source code without running a separate plugin build.
+Debug package copies normalize `minHostVersion` to the locally built app version because the host and plugin bundles come from the same checkout. Release packages preserve each source manifest's declared minimum host version.
+
If the plugin needs extra frameworks, private include paths, bundle resources, helper/tool targets, or target dependencies, add only those differences in `Plugins//project.yml`. If the plugin package contains an extra executable inside the bundle resources, declare it in `plugin.json.package.signPaths` so release packaging signs it before signing the bundle.
Plugin UI copy should be localized by the plugin itself. Put plugin string catalogs under `Plugins//Resources`, then look them up from the plugin bundle, for example:
@@ -144,6 +146,12 @@ Recommended mapping:
Avoid copying a plugin-local settings style enum. If a token is missing, add it to `PluginSettingsTheme` instead of hard-coding the same value in multiple plugins.
+### Unified Search
+
+MacTools automatically indexes a plugin's configuration page, declarative settings cards, permission rows, and shortcut definitions. A custom `PluginConfiguration` can expose individual destinations by conforming to `PluginSettingsSearchProviding`; apply `pluginSettingsSearchAnchor(pluginID:entryID:)` to the matching row so selecting a result scrolls to, highlights, and exposes accessibility focus on that control.
+
+Commands are never inferred from panel buttons. A plugin must explicitly conform to `PluginCommandProviding` and publish only actions that are safe and useful in the global palette. Commands that need an extra user decision should provide confirmation metadata. Destructive actions should remain in their contextual plugin UI unless their complete safety flow can be represented by that confirmation.
+
## Install Location
Installed plugins are copied into:
diff --git a/docs/pr-assets/app-volume/app-volume-panel-zh.jpg b/docs/pr-assets/app-volume/app-volume-panel-zh.jpg
new file mode 100644
index 00000000..1bd5c112
Binary files /dev/null and b/docs/pr-assets/app-volume/app-volume-panel-zh.jpg differ
diff --git a/docs/pr-assets/unified-search/general-setting-en.jpg b/docs/pr-assets/unified-search/general-setting-en.jpg
new file mode 100644
index 00000000..5cc191b3
Binary files /dev/null and b/docs/pr-assets/unified-search/general-setting-en.jpg differ
diff --git a/docs/pr-assets/unified-search/general-setting-reveal-en.jpg b/docs/pr-assets/unified-search/general-setting-reveal-en.jpg
new file mode 100644
index 00000000..0c073310
Binary files /dev/null and b/docs/pr-assets/unified-search/general-setting-reveal-en.jpg differ
diff --git a/docs/pr-assets/unified-search/mixed-results-en.jpg b/docs/pr-assets/unified-search/mixed-results-en.jpg
new file mode 100644
index 00000000..24f071a9
Binary files /dev/null and b/docs/pr-assets/unified-search/mixed-results-en.jpg differ
diff --git a/docs/pr-assets/unified-search/overview-ar.jpg b/docs/pr-assets/unified-search/overview-ar.jpg
new file mode 100644
index 00000000..9ad4dde3
Binary files /dev/null and b/docs/pr-assets/unified-search/overview-ar.jpg differ
diff --git a/docs/pr-assets/unified-search/overview-de.jpg b/docs/pr-assets/unified-search/overview-de.jpg
new file mode 100644
index 00000000..535c8c44
Binary files /dev/null and b/docs/pr-assets/unified-search/overview-de.jpg differ
diff --git a/docs/pr-assets/unified-search/overview-en.jpg b/docs/pr-assets/unified-search/overview-en.jpg
new file mode 100644
index 00000000..6bb0652b
Binary files /dev/null and b/docs/pr-assets/unified-search/overview-en.jpg differ
diff --git a/docs/pr-assets/unified-search/overview-light-en.jpg b/docs/pr-assets/unified-search/overview-light-en.jpg
new file mode 100644
index 00000000..32df4be8
Binary files /dev/null and b/docs/pr-assets/unified-search/overview-light-en.jpg differ
diff --git a/docs/pr-assets/unified-search/overview-zh-Hans.jpg b/docs/pr-assets/unified-search/overview-zh-Hans.jpg
new file mode 100644
index 00000000..89c4ffa5
Binary files /dev/null and b/docs/pr-assets/unified-search/overview-zh-Hans.jpg differ
diff --git a/docs/pr-assets/unified-search/plugin-options-brightness-light-en.jpg b/docs/pr-assets/unified-search/plugin-options-brightness-light-en.jpg
new file mode 100644
index 00000000..46f39570
Binary files /dev/null and b/docs/pr-assets/unified-search/plugin-options-brightness-light-en.jpg differ
diff --git a/docs/pr-assets/unified-search/plugin-options-keep-awake-light-en.jpg b/docs/pr-assets/unified-search/plugin-options-keep-awake-light-en.jpg
new file mode 100644
index 00000000..5edd53cd
Binary files /dev/null and b/docs/pr-assets/unified-search/plugin-options-keep-awake-light-en.jpg differ
diff --git a/scripts/plugins/build-local-plugins.sh b/scripts/plugins/build-local-plugins.sh
index 6afceb9a..95344ae0 100755
--- a/scripts/plugins/build-local-plugins.sh
+++ b/scripts/plugins/build-local-plugins.sh
@@ -1,6 +1,15 @@
#!/usr/bin/env bash
set -euo pipefail
+if [[ -z "${PYTHON3:-}" ]]; then
+ if [[ -x /usr/bin/python3 ]]; then
+ PYTHON3=/usr/bin/python3
+ else
+ PYTHON3=python3
+ fi
+fi
+export PYTHON3
+
usage() {
cat <<'USAGE'
Usage:
@@ -138,6 +147,16 @@ else:
PY
}
+copy_manifest_for_configuration() {
+ local source="$1"
+ local destination="$2"
+ "$PYTHON3" "$REPO_ROOT/scripts/plugins/copy-plugin-manifest.py" copy \
+ --source "$source" \
+ --destination "$destination" \
+ --configuration "$CONFIGURATION" \
+ --app-version-config "$REPO_ROOT/Configs/AppVersion.xcconfig"
+}
+
discover_candidates() {
if [[ -d "$SOURCE_DIR" && "$SOURCE_DIR" == *.mactoolsplugin ]]; then
printf '%s\n' "$SOURCE_DIR"
@@ -332,7 +351,7 @@ package_source_dir() {
local package_path="$PACKAGES_DIR/$plugin_id.mactoolsplugin"
rm -rf "$package_path"
mkdir -p "$package_path/$(dirname "$bundle_relative_path")"
- ditto "$manifest" "$package_path/plugin.json"
+ copy_manifest_for_configuration "$manifest" "$package_path/plugin.json"
ditto "$bundle_path" "$package_path/$bundle_relative_path"
if [[ -n "$SIGN_IDENTITY" ]]; then
diff --git a/scripts/plugins/copy-plugin-manifest.py b/scripts/plugins/copy-plugin-manifest.py
new file mode 100644
index 00000000..6b2e6117
--- /dev/null
+++ b/scripts/plugins/copy-plugin-manifest.py
@@ -0,0 +1,71 @@
+#!/usr/bin/env python3
+"""Copy a plugin manifest, normalizing Debug compatibility to the local host."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import pathlib
+import re
+import shutil
+
+
+MARKETING_VERSION_PATTERN = re.compile(
+ r"^\s*MARKETING_VERSION\s*=\s*(\S+)\s*$",
+ re.MULTILINE,
+)
+
+
+def development_host_version(config_path: pathlib.Path) -> str:
+ match = MARKETING_VERSION_PATTERN.search(config_path.read_text(encoding="utf-8"))
+ if match is None:
+ raise ValueError(f"Unable to determine MARKETING_VERSION from {config_path}")
+ return match.group(1)
+
+
+def copy_manifest(
+ source: pathlib.Path,
+ destination: pathlib.Path,
+ configuration: str,
+ app_version_config: pathlib.Path,
+) -> None:
+ if configuration != "Debug":
+ shutil.copy2(source, destination)
+ return
+
+ manifest = json.loads(source.read_text(encoding="utf-8"))
+ manifest["minHostVersion"] = development_host_version(app_version_config)
+ destination.write_text(
+ json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
+ encoding="utf-8",
+ )
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser()
+ subparsers = parser.add_subparsers(dest="command", required=True)
+
+ copy_parser = subparsers.add_parser("copy")
+ copy_parser.add_argument("--source", type=pathlib.Path, required=True)
+ copy_parser.add_argument("--destination", type=pathlib.Path, required=True)
+ copy_parser.add_argument("--configuration", required=True)
+ copy_parser.add_argument("--app-version-config", type=pathlib.Path, required=True)
+
+ version_parser = subparsers.add_parser("host-version")
+ version_parser.add_argument("--app-version-config", type=pathlib.Path, required=True)
+
+ args = parser.parse_args()
+ if args.command == "host-version":
+ print(development_host_version(args.app_version_config))
+ return
+
+ copy_manifest(
+ source=args.source,
+ destination=args.destination,
+ configuration=args.configuration,
+ app_version_config=args.app_version_config,
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/plugins/sync-debug-plugins.sh b/scripts/plugins/sync-debug-plugins.sh
index e8db312f..2134ef3a 100755
--- a/scripts/plugins/sync-debug-plugins.sh
+++ b/scripts/plugins/sync-debug-plugins.sh
@@ -101,6 +101,12 @@ OUTPUT_DIR="$(mkdir -p "$OUTPUT_DIR" && cd "$OUTPUT_DIR" && pwd)"
PACKAGES_DIR="$OUTPUT_DIR/Packages"
CATALOG_PATH="$OUTPUT_DIR/catalog.dev.json"
STATE_DIR="$OUTPUT_DIR/.sync-state"
+MANIFEST_COPY_SCRIPT="$REPO_ROOT/scripts/plugins/copy-plugin-manifest.py"
+APP_VERSION_CONFIG="$REPO_ROOT/Configs/AppVersion.xcconfig"
+DEVELOPMENT_HOST_VERSION="$(
+ "$PYTHON3" "$MANIFEST_COPY_SCRIPT" host-version \
+ --app-version-config "$APP_VERSION_CONFIG"
+)"
mkdir -p "$PACKAGES_DIR"
mkdir -p "$STATE_DIR"
@@ -126,7 +132,7 @@ discover_plugin_records() {
plugin_filters_serialized+=$'\n'"$plugin_filter"
done
- "$PYTHON3" - "$SOURCE_DIR" "$PRODUCTS_DIR" "$plugin_filters_serialized" <<'PY'
+ "$PYTHON3" - "$SOURCE_DIR" "$PRODUCTS_DIR" "$plugin_filters_serialized" "$DEVELOPMENT_HOST_VERSION" <<'PY'
import hashlib
import json
import os
@@ -136,6 +142,7 @@ import sys
source_dir = pathlib.Path(sys.argv[1])
products_dir = pathlib.Path(sys.argv[2])
serialized_filters = sys.argv[3] if len(sys.argv) > 3 else ""
+development_host_version = sys.argv[4]
filters = {item for item in serialized_filters.splitlines() if item}
def emit_error(message: str) -> None:
@@ -164,6 +171,9 @@ def discover_candidates() -> list[pathlib.Path]:
def input_fingerprint(manifest: pathlib.Path, bundle: pathlib.Path) -> str:
digest = hashlib.sha256()
+ digest.update(b"debug-manifest-v1\0")
+ digest.update(development_host_version.encode("utf-8"))
+ digest.update(b"\0")
def is_ignored(path: pathlib.Path) -> bool:
relative_parts = path.relative_to(bundle).parts
@@ -286,7 +296,11 @@ while IFS=$'\t' read -r plugin_root manifest plugin_id bundle_relative_path bund
if [[ "$fingerprint" != "$previous_fingerprint" ]] || ! package_is_complete "$package_path" "$bundle_relative_path"; then
rm -rf "$package_path"
mkdir -p "$package_path/$(dirname "$bundle_relative_path")"
- ditto "$manifest" "$package_path/plugin.json"
+ "$PYTHON3" "$MANIFEST_COPY_SCRIPT" copy \
+ --source "$manifest" \
+ --destination "$package_path/plugin.json" \
+ --configuration Debug \
+ --app-version-config "$APP_VERSION_CONFIG"
ditto "$bundle_path" "$package_path/$bundle_relative_path"
printf '%s\n' "$fingerprint" > "$state_path"
synced_count=$((synced_count + 1))
diff --git a/scripts/tests/test_copy_plugin_manifest.py b/scripts/tests/test_copy_plugin_manifest.py
new file mode 100644
index 00000000..b3445492
--- /dev/null
+++ b/scripts/tests/test_copy_plugin_manifest.py
@@ -0,0 +1,143 @@
+import json
+import pathlib
+import subprocess
+import sys
+import tempfile
+import unittest
+
+
+REPO_ROOT = pathlib.Path(__file__).resolve().parents[2]
+SCRIPT = REPO_ROOT / "scripts/plugins/copy-plugin-manifest.py"
+SYNC_SCRIPT = REPO_ROOT / "scripts/plugins/sync-debug-plugins.sh"
+APP_VERSION_CONFIG = REPO_ROOT / "Configs/AppVersion.xcconfig"
+
+
+class CopyPluginManifestTests(unittest.TestCase):
+ def test_debug_copy_uses_local_host_version(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary_directory:
+ root = pathlib.Path(temporary_directory)
+ source = root / "plugin.json"
+ destination = root / "copied.json"
+ config = root / "AppVersion.xcconfig"
+ source.write_text(
+ json.dumps({"id": "example", "minHostVersion": "99.0"}),
+ encoding="utf-8",
+ )
+ config.write_text("MARKETING_VERSION = 1.2.3\n", encoding="utf-8")
+
+ subprocess.run(
+ [
+ sys.executable, str(SCRIPT), "copy",
+ "--source", str(source),
+ "--destination", str(destination),
+ "--configuration", "Debug",
+ "--app-version-config", str(config),
+ ],
+ check=True,
+ )
+
+ self.assertEqual(
+ json.loads(destination.read_text(encoding="utf-8"))["minHostVersion"],
+ "1.2.3",
+ )
+
+ def test_release_copy_preserves_manifest_bytes(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary_directory:
+ root = pathlib.Path(temporary_directory)
+ source = root / "plugin.json"
+ destination = root / "copied.json"
+ config = root / "AppVersion.xcconfig"
+ original = b'{ "id": "example", "minHostVersion": "2.0" }\n'
+ source.write_bytes(original)
+ config.write_text("MARKETING_VERSION = 1.2.3\n", encoding="utf-8")
+
+ subprocess.run(
+ [
+ sys.executable, str(SCRIPT), "copy",
+ "--source", str(source),
+ "--destination", str(destination),
+ "--configuration", "Release",
+ "--app-version-config", str(config),
+ ],
+ check=True,
+ )
+
+ self.assertEqual(destination.read_bytes(), original)
+
+ def test_debug_sync_normalizes_and_caches_packaged_manifest(self) -> None:
+ with tempfile.TemporaryDirectory() as temporary_directory:
+ root = pathlib.Path(temporary_directory)
+ source = root / "Source"
+ products = root / "Products"
+ output = root / "Output"
+ bundle = products / "Example.bundle"
+ source.mkdir()
+ bundle.mkdir(parents=True)
+ (bundle / "payload").write_text("debug bundle", encoding="utf-8")
+ (source / "plugin.json").write_text(
+ json.dumps(
+ {
+ "id": "example-debug-plugin",
+ "displayName": "Example",
+ "version": "1.0.0",
+ "minHostVersion": "99.0.0",
+ "pluginKitVersion": 3,
+ "bundleRelativePath": "Example.bundle",
+ }
+ ),
+ encoding="utf-8",
+ )
+
+ command = [
+ str(SYNC_SCRIPT),
+ "--source-dir", str(source),
+ "--products-dir", str(products),
+ "--output-dir", str(output),
+ "--skip-install",
+ ]
+ first_run = subprocess.run(
+ command,
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+ second_run = subprocess.run(
+ command,
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+
+ host_version = subprocess.run(
+ [
+ sys.executable,
+ str(SCRIPT),
+ "host-version",
+ "--app-version-config",
+ str(APP_VERSION_CONFIG),
+ ],
+ check=True,
+ capture_output=True,
+ text=True,
+ ).stdout.strip()
+ packaged_manifest = json.loads(
+ (
+ output
+ / "Packages/example-debug-plugin.mactoolsplugin/plugin.json"
+ ).read_text(encoding="utf-8")
+ )
+ catalog = json.loads(
+ (output / "catalog.dev.json").read_text(encoding="utf-8")
+ )
+
+ self.assertIn("Synced 1 changed", first_run.stdout)
+ self.assertIn("skipped 1 unchanged", second_run.stdout)
+ self.assertEqual(packaged_manifest["minHostVersion"], host_version)
+ self.assertEqual(
+ catalog["plugins"][0]["minimumHostVersion"],
+ host_version,
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()