From 677c3ce47faf800de243a33a2e601d4fd5e884dd Mon Sep 17 00:00:00 2001 From: cdricms <36056008+cdricms@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:46:27 +0200 Subject: [PATCH] feat(apple): add a Free up space action to reclaim leaked storage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delete all transfers only clears core records, and the blob-store cache is reclaimed by the core's own timer. Neither touches the app's temporary directory (leftover picker/staging copies — hundreds of MB on macOS) or the stray .Trash folders that accumulate in app-owned directories and can't be removed via Files/Finder. Add a non-destructive Free up space button that empties the temp directory and removes .Trash folders under the core data dir (and, on iOS, the fixed Documents receive folder), reporting the bytes reclaimed. Guarded against running while a transfer is in flight; never touches received files, the core database, or user-chosen macOS receive folders. --- .../Features/Settings/SettingsModel.swift | 79 +++++++++++++++++++ .../Features/Settings/SettingsSections.swift | 17 ++++ localization/strings.json | 62 +++++++++++++++ .../composeResources/values-de/strings.xml | 4 + .../composeResources/values-es/strings.xml | 4 + .../composeResources/values-fr/strings.xml | 4 + .../composeResources/values-it/strings.xml | 4 + .../composeResources/values-nl/strings.xml | 4 + .../composeResources/values-pl/strings.xml | 4 + .../composeResources/values-pt/strings.xml | 4 + .../composeResources/values-ru/strings.xml | 4 + .../composeResources/values/strings.xml | 4 + 12 files changed, 194 insertions(+) diff --git a/apple/VniDrop/Features/Settings/SettingsModel.swift b/apple/VniDrop/Features/Settings/SettingsModel.swift index a6a3f3c..6ccdb96 100644 --- a/apple/VniDrop/Features/Settings/SettingsModel.swift +++ b/apple/VniDrop/Features/Settings/SettingsModel.swift @@ -56,6 +56,7 @@ struct SettingsState: Equatable { var storage: StorageBreakdown? var isCalculatingStorage = false var isDeletingTransfers = false + var isCleaningStorage = false static func == (lhs: SettingsState, rhs: SettingsState) -> Bool { lhs.selectedSection == rhs.selectedSection && lhs.username == rhs.username @@ -72,6 +73,7 @@ struct SettingsState: Equatable { && lhs.bugLogPreviewBytes == rhs.bugLogPreviewBytes && lhs.storage == rhs.storage && lhs.isCalculatingStorage == rhs.isCalculatingStorage && lhs.isDeletingTransfers == rhs.isDeletingTransfers + && lhs.isCleaningStorage == rhs.isCleaningStorage && lhs.deviceInfo?.operatingSystem == rhs.deviceInfo?.operatingSystem } } @@ -315,6 +317,83 @@ final class SettingsModel: ObservableObject { } } + /// Reclaims disk space the core's transfer deletion doesn't touch: the app's + /// temporary directory (leftover picker/staging copies) and any stray `.Trash` + /// folders that accumulate inside app-owned directories. Never touches received + /// files, the core database, or user-chosen receive folders. + func freeUpSpace() { + if state.isCleaningStorage { return } + // Purging staging while a transfer is mid-flight could break it. + let hasActive = repository.state.transfers.contains { + $0.status == .sharing || $0.status == .importing || $0.status == .receiving + } + if hasActive { + messages.tryShow(UiMessage(text: .resource(L10n.Storage.cleanupBusy), tone: .warning)) + return + } + state.isCleaningStorage = true + let tempDir = NSTemporaryDirectory() + let dataDir = environment.defaultCoreDataDir + // Only clean the receive folder's trash when it is app-owned (iOS fixed + // Documents), never a user-chosen macOS folder like ~/Downloads. + let receiveTrashRoot = fileSystemService.supportsCustomReceiveFolders ? nil : state.receiveFolder?.value + Task { + let freed = await Task.detached { + SettingsModel.reclaimJunk(tempDir: tempDir, dataDir: dataDir, receiveTrashRoot: receiveTrashRoot) + }.value + state.isCleaningStorage = false + loadStorageUsage() + messages.show(UiMessage( + text: .dynamic(L10n.Storage.cleanupFreed(size: formatBytes(freed))), + tone: .success + )) + } + } + + /// Deletes temp-directory contents and `.Trash` folders under the given roots, + /// returning the number of bytes reclaimed. Runs off the main actor. + nonisolated static func reclaimJunk(tempDir: String, dataDir: String, receiveTrashRoot: String?) -> UInt64 { + let fm = FileManager.default + var freed: UInt64 = 0 + // Empty the temporary directory. + if let entries = try? fm.contentsOfDirectory(atPath: tempDir) { + for name in entries { + let path = (tempDir as NSString).appendingPathComponent(name) + freed += itemSize(path) + try? fm.removeItem(atPath: path) + } + } + // Remove stray `.Trash` folders inside app-owned directories. + for root in [dataDir, receiveTrashRoot].compactMap({ $0 }) { + for trash in trashDirectories(under: root) { + freed += directorySize(trash) + try? fm.removeItem(atPath: trash) + } + } + return freed + } + + /// Paths of every directory named `.Trash` under `root` (not descending into them). + private nonisolated static func trashDirectories(under root: String) -> [String] { + let url = URL(fileURLWithPath: root, isDirectory: true) + guard let enumerator = FileManager.default.enumerator( + at: url, includingPropertiesForKeys: [.isDirectoryKey] + ) else { return [] } + var result: [String] = [] + for case let fileURL as URL in enumerator where fileURL.lastPathComponent == ".Trash" { + result.append(fileURL.path) + enumerator.skipDescendants() + } + return result + } + + /// Allocated size of a file or directory (0 if missing). + private nonisolated static func itemSize(_ path: String) -> UInt64 { + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) else { return 0 } + return isDirectory.boolValue ? directorySize(path) : fileSize(path) + } + nonisolated static func fileSize(_ path: String) -> UInt64 { let values = try? URL(fileURLWithPath: path).resourceValues( forKeys: [.isRegularFileKey, .totalFileAllocatedSizeKey, .fileSizeKey] diff --git a/apple/VniDrop/Features/Settings/SettingsSections.swift b/apple/VniDrop/Features/Settings/SettingsSections.swift index 8457dd4..5b639b0 100644 --- a/apple/VniDrop/Features/Settings/SettingsSections.swift +++ b/apple/VniDrop/Features/Settings/SettingsSections.swift @@ -99,6 +99,23 @@ struct StorageSettings: View { Text(String(localized: L10n.Storage.footer)) } + Section { + Button { + model.freeUpSpace() + } label: { + HStack { + Text(model.state.isCleaningStorage + ? String(localized: L10n.Storage.cleaning) + : String(localized: L10n.Storage.freeUpSpace)) + if model.state.isCleaningStorage { + Spacer() + ProgressView() + } + } + } + .disabled(model.state.isCleaningStorage) + } + Section { Button(role: .destructive) { showDeleteConfirmation = true diff --git a/localization/strings.json b/localization/strings.json index 02cf6c7..f8bed6c 100644 --- a/localization/strings.json +++ b/localization/strings.json @@ -3140,6 +3140,68 @@ "ru": "Вычисление…" } }, + "storage_cleaning": { + "context": "Settings > Storage: free-up-space button while cleanup runs.", + "translations": { + "en": "Cleaning up…", + "fr": "Nettoyage…", + "es": "Limpiando…", + "it": "Pulizia…", + "de": "Wird bereinigt…", + "pt": "A limpar…", + "pl": "Czyszczenie…", + "nl": "Opschonen…", + "ru": "Очистка…" + } + }, + "storage_cleanup_busy": { + "context": "Settings > Storage: shown when cleanup is blocked by in-flight transfers.", + "translations": { + "en": "Finish active transfers before freeing up space", + "fr": "Terminez les transferts en cours avant de libérer de l'espace", + "es": "Finaliza las transferencias activas antes de liberar espacio", + "it": "Completa i trasferimenti attivi prima di liberare spazio", + "de": "Beende aktive Übertragungen, bevor du Speicher freigibst", + "pt": "Conclui as transferências ativas antes de libertar espaço", + "pl": "Zakończ aktywne transfery przed zwolnieniem miejsca", + "nl": "Voltooi actieve overdrachten voordat je ruimte vrijmaakt", + "ru": "Завершите активные передачи перед освобождением места" + } + }, + "storage_cleanup_freed": { + "context": "Settings > Storage: cleanup success. {size} = amount freed.", + "args": [ + { + "name": "size", + "type": "string" + } + ], + "translations": { + "en": "Freed {size}", + "fr": "{size} libéré", + "es": "Se liberó {size}", + "it": "Liberati {size}", + "de": "{size} freigegeben", + "pt": "Libertado {size}", + "pl": "Zwolniono {size}", + "nl": "{size} vrijgemaakt", + "ru": "Освобождено {size}" + } + }, + "storage_free_up_space": { + "context": "Settings > Storage: button that clears temporary files and stray trash.", + "translations": { + "en": "Free up space", + "fr": "Libérer de l'espace", + "es": "Liberar espacio", + "it": "Libera spazio", + "de": "Speicher freigeben", + "pt": "Libertar espaço", + "pl": "Zwolnij miejsce", + "nl": "Ruimte vrijmaken", + "ru": "Освободить место" + } + }, "storage_delete_transfers": { "context": "Settings > Storage: button to delete all transfer records.", "translations": { diff --git a/shared/src/commonMain/composeResources/values-de/strings.xml b/shared/src/commonMain/composeResources/values-de/strings.xml index da1a6ad..b85e8d5 100644 --- a/shared/src/commonMain/composeResources/values-de/strings.xml +++ b/shared/src/commonMain/composeResources/values-de/strings.xml @@ -203,6 +203,10 @@ Wird empfangen Beendet Wird berechnet… + Wird bereinigt… + Beende aktive Übertragungen, bevor du Speicher freigibst + %1$s freigegeben + Speicher freigeben Alle Übertragungen löschen Dadurch werden alle Datensätze gesendeter und empfangener Übertragungen aus Ihrem Verlauf gelöscht. Ihre empfangenen Dateien werden nicht gelöscht. Nicht mehr benötigte zwischengespeicherte freigegebene Inhalte werden automatisch bereinigt; dies kann etwas dauern. Dies kann nicht rückgängig gemacht werden. App-Daten diff --git a/shared/src/commonMain/composeResources/values-es/strings.xml b/shared/src/commonMain/composeResources/values-es/strings.xml index b7ff17e..a1dcb0e 100644 --- a/shared/src/commonMain/composeResources/values-es/strings.xml +++ b/shared/src/commonMain/composeResources/values-es/strings.xml @@ -203,6 +203,10 @@ Recibiendo Detenido Calculando… + Limpiando… + Finaliza las transferencias activas antes de liberar espacio + Se liberó %1$s + Liberar espacio Eliminar todas las transferencias Esto borra de su historial todos los registros de transferencias enviadas y recibidas. Sus archivos recibidos no se eliminan. El contenido compartido en caché que ya no se necesita se recupera automáticamente, lo que puede tardar un poco. Esto no se puede deshacer. Datos de la aplicación diff --git a/shared/src/commonMain/composeResources/values-fr/strings.xml b/shared/src/commonMain/composeResources/values-fr/strings.xml index 126bb38..a49c270 100644 --- a/shared/src/commonMain/composeResources/values-fr/strings.xml +++ b/shared/src/commonMain/composeResources/values-fr/strings.xml @@ -203,6 +203,10 @@ Réception Arrêté Calcul… + Nettoyage… + Terminez les transferts en cours avant de libérer de l\'espace + %1$s libéré + Libérer de l\'espace Supprimer tous les transferts Cela efface de votre historique tous les enregistrements de transferts envoyés et reçus. Vos fichiers reçus ne sont pas supprimés. Le contenu partagé mis en cache qui n’est plus nécessaire est récupéré automatiquement, ce qui peut prendre un peu de temps. Cette action est irréversible. Données de l’app diff --git a/shared/src/commonMain/composeResources/values-it/strings.xml b/shared/src/commonMain/composeResources/values-it/strings.xml index 10f1aad..bb26acb 100644 --- a/shared/src/commonMain/composeResources/values-it/strings.xml +++ b/shared/src/commonMain/composeResources/values-it/strings.xml @@ -203,6 +203,10 @@ Ricezione Interrotto Calcolo… + Pulizia… + Completa i trasferimenti attivi prima di liberare spazio + Liberati %1$s + Libera spazio Elimina tutti i trasferimenti Questo cancella dalla cronologia tutti i record dei trasferimenti inviati e ricevuti. I file ricevuti non vengono eliminati. Il contenuto condiviso nella cache che non serve più viene recuperato automaticamente, operazione che può richiedere un po’ di tempo. Questa azione non può essere annullata. Dati dell’app diff --git a/shared/src/commonMain/composeResources/values-nl/strings.xml b/shared/src/commonMain/composeResources/values-nl/strings.xml index b281934..60d3791 100644 --- a/shared/src/commonMain/composeResources/values-nl/strings.xml +++ b/shared/src/commonMain/composeResources/values-nl/strings.xml @@ -203,6 +203,10 @@ Ontvangen Gestopt Berekenen… + Opschonen… + Voltooi actieve overdrachten voordat je ruimte vrijmaakt + %1$s vrijgemaakt + Ruimte vrijmaken Alle overdrachten verwijderen Hiermee worden alle records van verzonden en ontvangen overdrachten uit uw geschiedenis gewist. Uw ontvangen bestanden worden niet verwijderd. Gedeelde inhoud in de cache die niet meer nodig is, wordt automatisch opgeruimd; dit kan enige tijd duren. Dit kan niet ongedaan worden gemaakt. Appgegevens diff --git a/shared/src/commonMain/composeResources/values-pl/strings.xml b/shared/src/commonMain/composeResources/values-pl/strings.xml index e19c520..9de29f4 100644 --- a/shared/src/commonMain/composeResources/values-pl/strings.xml +++ b/shared/src/commonMain/composeResources/values-pl/strings.xml @@ -203,6 +203,10 @@ Odbieranie Zatrzymany Obliczanie… + Czyszczenie… + Zakończ aktywne transfery przed zwolnieniem miejsca + Zwolniono %1$s + Zwolnij miejsce Usuń wszystkie transfery Spowoduje to usunięcie z historii wszystkich rekordów wysłanych i odebranych transferów. Odebrane pliki nie zostaną usunięte. Niepotrzebna już zawartość udostępniona w pamięci podręcznej jest odzyskiwana automatycznie, co może chwilę potrwać. Tej operacji nie można cofnąć. Dane aplikacji diff --git a/shared/src/commonMain/composeResources/values-pt/strings.xml b/shared/src/commonMain/composeResources/values-pt/strings.xml index 1233976..b6d0c35 100644 --- a/shared/src/commonMain/composeResources/values-pt/strings.xml +++ b/shared/src/commonMain/composeResources/values-pt/strings.xml @@ -203,6 +203,10 @@ A receber Parada A calcular… + A limpar… + Conclui as transferências ativas antes de libertar espaço + Libertado %1$s + Libertar espaço Eliminar todas as transferências Isto elimina do histórico todos os registos de transferências enviadas e recebidas. Os ficheiros recebidos não são eliminados. O conteúdo partilhado em cache que já não é necessário é recuperado automaticamente, o que pode demorar algum tempo. Esta ação não pode ser anulada. Dados da aplicação diff --git a/shared/src/commonMain/composeResources/values-ru/strings.xml b/shared/src/commonMain/composeResources/values-ru/strings.xml index 513bb0c..f8894e5 100644 --- a/shared/src/commonMain/composeResources/values-ru/strings.xml +++ b/shared/src/commonMain/composeResources/values-ru/strings.xml @@ -203,6 +203,10 @@ Получение Остановлено Вычисление… + Очистка… + Завершите активные передачи перед освобождением места + Освобождено %1$s + Освободить место Удалить все передачи Это удалит из истории все записи об отправленных и полученных передачах. Полученные файлы не удаляются. Кэшированное общее содержимое, которое больше не требуется, освобождается автоматически; это может занять некоторое время. Это действие нельзя отменить. Данные приложения diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index b4da6ac..4daed58 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -203,6 +203,10 @@ Receiving Stopped Calculating… + Cleaning up… + Finish active transfers before freeing up space + Freed %1$s + Free up space Delete all transfers This clears all sent and received transfer records from your history. Your received files are not deleted. Cached shared content that is no longer needed is reclaimed automatically, which may take a little time. This can’t be undone. App data