mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 01:39:46 +00:00
339 lines
14 KiB
Swift
339 lines
14 KiB
Swift
//
|
|
// StorageDiagnostics.swift
|
|
// Yattee
|
|
//
|
|
// Storage diagnostics and utilities for downloads.
|
|
//
|
|
|
|
import Foundation
|
|
|
|
// MARK: - Storage Diagnostics
|
|
|
|
/// Represents storage usage for a specific directory or category.
|
|
struct StorageUsageItem: Identifiable {
|
|
let id = UUID()
|
|
let name: String
|
|
let path: String
|
|
let size: Int64
|
|
let fileCount: Int
|
|
}
|
|
|
|
/// Comprehensive storage diagnostics for debugging app storage usage.
|
|
struct StorageDiagnostics {
|
|
let items: [StorageUsageItem]
|
|
let totalSize: Int64
|
|
let documentsSize: Int64
|
|
let cachesSize: Int64
|
|
let appSupportSize: Int64
|
|
let tempSize: Int64
|
|
let otherSize: Int64
|
|
|
|
var formattedTotal: String { formatBytes(totalSize) }
|
|
var formattedDocuments: String { formatBytes(documentsSize) }
|
|
var formattedCaches: String { formatBytes(cachesSize) }
|
|
var formattedAppSupport: String { formatBytes(appSupportSize) }
|
|
var formattedTemp: String { formatBytes(tempSize) }
|
|
|
|
private func formatBytes(_ bytes: Int64) -> String {
|
|
let formatter = ByteCountFormatter()
|
|
formatter.countStyle = .file
|
|
return formatter.string(fromByteCount: bytes)
|
|
}
|
|
|
|
@MainActor
|
|
func logDiagnostics() {
|
|
LoggingService.shared.logDownload(
|
|
"=== COMPREHENSIVE STORAGE DIAGNOSTICS ===",
|
|
details: """
|
|
Total app storage: \(formattedTotal)
|
|
Documents: \(formattedDocuments)
|
|
Caches: \(formattedCaches)
|
|
Application Support: \(formattedAppSupport)
|
|
Temp: \(formattedTemp)
|
|
"""
|
|
)
|
|
|
|
LoggingService.shared.logDownload("=== STORAGE BREAKDOWN ===")
|
|
for item in items.sorted(by: { $0.size > $1.size }) {
|
|
let formatted = formatBytes(item.size)
|
|
LoggingService.shared.logDownload(
|
|
"\(item.name): \(formatted)",
|
|
details: "Files: \(item.fileCount), Path: \(item.path)"
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Helper to format bytes (standalone function for use in scanAppStorage)
|
|
private func formatBytesStatic(_ bytes: Int64) -> String {
|
|
let formatter = ByteCountFormatter()
|
|
formatter.countStyle = .file
|
|
return formatter.string(fromByteCount: bytes)
|
|
}
|
|
|
|
/// Scans all app directories and returns comprehensive storage diagnostics.
|
|
@MainActor
|
|
func scanAppStorage() -> StorageDiagnostics {
|
|
let fileManager = FileManager.default
|
|
var items: [StorageUsageItem] = []
|
|
|
|
// Helper to calculate directory size (including hidden files)
|
|
func directorySize(at url: URL, includeHidden: Bool = false) -> (size: Int64, count: Int) {
|
|
var totalSize: Int64 = 0
|
|
var fileCount = 0
|
|
|
|
let options: FileManager.DirectoryEnumerationOptions = includeHidden ? [] : [.skipsHiddenFiles]
|
|
guard let enumerator = fileManager.enumerator(
|
|
at: url,
|
|
includingPropertiesForKeys: [.fileSizeKey, .isDirectoryKey, .totalFileAllocatedSizeKey],
|
|
options: options
|
|
) else {
|
|
return (0, 0)
|
|
}
|
|
|
|
for case let fileURL as URL in enumerator {
|
|
guard let resourceValues = try? fileURL.resourceValues(forKeys: [.fileSizeKey, .isDirectoryKey, .totalFileAllocatedSizeKey]),
|
|
resourceValues.isDirectory != true else {
|
|
continue
|
|
}
|
|
// Use allocated size if available (accounts for sparse files and actual disk usage)
|
|
let size = Int64(resourceValues.totalFileAllocatedSize ?? resourceValues.fileSize ?? 0)
|
|
totalSize += size
|
|
fileCount += 1
|
|
}
|
|
|
|
return (totalSize, fileCount)
|
|
}
|
|
|
|
// Documents directory
|
|
var documentsTotal: Int64 = 0
|
|
if let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first {
|
|
// Downloads subfolder
|
|
let downloadsURL = documentsURL.appendingPathComponent("Downloads")
|
|
if fileManager.fileExists(atPath: downloadsURL.path) {
|
|
let (size, count) = directorySize(at: downloadsURL)
|
|
items.append(StorageUsageItem(name: "Downloads", path: downloadsURL.path, size: size, fileCount: count))
|
|
documentsTotal += size
|
|
}
|
|
|
|
// Check for other items in Documents
|
|
if let contents = try? fileManager.contentsOfDirectory(at: documentsURL, includingPropertiesForKeys: nil) {
|
|
for item in contents where item.lastPathComponent != "Downloads" {
|
|
let (size, count) = directorySize(at: item)
|
|
if size > 0 {
|
|
items.append(StorageUsageItem(name: "Documents/\(item.lastPathComponent)", path: item.path, size: size, fileCount: count))
|
|
documentsTotal += size
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Caches directory
|
|
var cachesTotal: Int64 = 0
|
|
if let cachesURL = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first {
|
|
// Image cache
|
|
let imageCacheURL = cachesURL.appendingPathComponent("ImageCache")
|
|
if fileManager.fileExists(atPath: imageCacheURL.path) {
|
|
let (size, count) = directorySize(at: imageCacheURL)
|
|
items.append(StorageUsageItem(name: "Image Cache", path: imageCacheURL.path, size: size, fileCount: count))
|
|
cachesTotal += size
|
|
}
|
|
|
|
// Feed cache
|
|
let feedCacheURL = cachesURL.appendingPathComponent("FeedCache")
|
|
if fileManager.fileExists(atPath: feedCacheURL.path) {
|
|
let (size, count) = directorySize(at: feedCacheURL)
|
|
items.append(StorageUsageItem(name: "Feed Cache", path: feedCacheURL.path, size: size, fileCount: count))
|
|
cachesTotal += size
|
|
}
|
|
|
|
// URLSession cache (com.apple.nsurlsessiond)
|
|
if let contents = try? fileManager.contentsOfDirectory(at: cachesURL, includingPropertiesForKeys: nil) {
|
|
for item in contents {
|
|
let name = item.lastPathComponent
|
|
if name == "ImageCache" || name == "FeedCache" { continue }
|
|
|
|
let (size, count) = directorySize(at: item)
|
|
if size > 1024 { // Only show if > 1KB
|
|
items.append(StorageUsageItem(name: "Caches/\(name)", path: item.path, size: size, fileCount: count))
|
|
cachesTotal += size
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Application Support directory
|
|
var appSupportTotal: Int64 = 0
|
|
if let appSupportURL = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first {
|
|
if let contents = try? fileManager.contentsOfDirectory(at: appSupportURL, includingPropertiesForKeys: nil) {
|
|
for item in contents {
|
|
let (size, count) = directorySize(at: item)
|
|
if size > 1024 { // Only show if > 1KB
|
|
items.append(StorageUsageItem(name: "AppSupport/\(item.lastPathComponent)", path: item.path, size: size, fileCount: count))
|
|
appSupportTotal += size
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Temp directory
|
|
var tempTotal: Int64 = 0
|
|
let tempURL = fileManager.temporaryDirectory
|
|
if let contents = try? fileManager.contentsOfDirectory(at: tempURL, includingPropertiesForKeys: nil) {
|
|
for item in contents {
|
|
let (size, count) = directorySize(at: item)
|
|
if size > 1024 { // Only show if > 1KB
|
|
items.append(StorageUsageItem(name: "Temp/\(item.lastPathComponent)", path: item.path, size: size, fileCount: count))
|
|
tempTotal += size
|
|
}
|
|
}
|
|
}
|
|
|
|
// Library directory - scan ALL subdirectories to find hidden storage
|
|
var otherTotal: Int64 = 0
|
|
if let libraryURL = fileManager.urls(for: .libraryDirectory, in: .userDomainMask).first {
|
|
// Get all items in Library (including hidden)
|
|
if let contents = try? fileManager.contentsOfDirectory(at: libraryURL, includingPropertiesForKeys: nil, options: []) {
|
|
for item in contents {
|
|
let name = item.lastPathComponent
|
|
// Skip Caches (already scanned) and Application Support (already scanned)
|
|
if name == "Caches" || name == "Application Support" { continue }
|
|
|
|
let (size, count) = directorySize(at: item, includeHidden: true)
|
|
if size > 1024 { // Only show if > 1KB
|
|
items.append(StorageUsageItem(name: "Library/\(name)", path: item.path, size: size, fileCount: count))
|
|
otherTotal += size
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check the app container root for anything we might have missed
|
|
// Go up from Documents to the container root
|
|
if let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first {
|
|
let containerURL = documentsURL.deletingLastPathComponent()
|
|
|
|
// Scan for any top-level directories we haven't covered
|
|
if let contents = try? fileManager.contentsOfDirectory(at: containerURL, includingPropertiesForKeys: nil, options: []) {
|
|
for item in contents {
|
|
let name = item.lastPathComponent
|
|
// Skip directories we've already scanned
|
|
if name == "Documents" || name == "Library" || name == "tmp" || name == "SystemData" { continue }
|
|
|
|
let (size, count) = directorySize(at: item, includeHidden: true)
|
|
if size > 1024 {
|
|
items.append(StorageUsageItem(name: "Container/\(name)", path: item.path, size: size, fileCount: count))
|
|
otherTotal += size
|
|
}
|
|
}
|
|
}
|
|
|
|
// Log the container path for reference
|
|
LoggingService.shared.logDownload("App Container", details: containerURL.path)
|
|
|
|
// Scan ENTIRE container recursively to find all storage
|
|
let (totalContainerSize, totalContainerFiles) = directorySize(at: containerURL, includeHidden: true)
|
|
LoggingService.shared.logDownload(
|
|
"TOTAL Data Container: \(formatBytesStatic(totalContainerSize))",
|
|
details: "\(totalContainerFiles) files at \(containerURL.path)"
|
|
)
|
|
|
|
// Scan ALL top-level directories in container to find where storage is hiding
|
|
LoggingService.shared.logDownload("=== CONTAINER BREAKDOWN ===")
|
|
if let allContents = try? fileManager.contentsOfDirectory(at: containerURL, includingPropertiesForKeys: nil, options: []) {
|
|
for item in allContents {
|
|
let (itemSize, itemCount) = directorySize(at: item, includeHidden: true)
|
|
LoggingService.shared.logDownload(
|
|
" \(item.lastPathComponent): \(formatBytesStatic(itemSize))",
|
|
details: "\(itemCount) files"
|
|
)
|
|
|
|
// If this is a large directory, scan its subdirectories too
|
|
if itemSize > 100 * 1024 * 1024 { // > 100 MB
|
|
if let subContents = try? fileManager.contentsOfDirectory(at: item, includingPropertiesForKeys: nil, options: []) {
|
|
for subItem in subContents {
|
|
let (subSize, subCount) = directorySize(at: subItem, includeHidden: true)
|
|
if subSize > 10 * 1024 * 1024 { // > 10 MB
|
|
LoggingService.shared.logDownload(
|
|
" \(subItem.lastPathComponent): \(formatBytesStatic(subSize))",
|
|
details: "\(subCount) files"
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check the app bundle size (this is read-only, can't be cleared)
|
|
var bundleSize: Int64 = 0
|
|
if let bundleURL = Bundle.main.bundleURL as URL? {
|
|
let (size, count) = directorySize(at: bundleURL, includeHidden: true)
|
|
bundleSize = size
|
|
items.append(StorageUsageItem(name: "App Bundle (read-only)", path: bundleURL.path, size: size, fileCount: count))
|
|
LoggingService.shared.logDownload("App Bundle", details: bundleURL.path)
|
|
|
|
// Log contents of bundle for debugging
|
|
if let contents = try? fileManager.contentsOfDirectory(at: bundleURL, includingPropertiesForKeys: nil, options: []) {
|
|
for item in contents {
|
|
let (itemSize, itemCount) = directorySize(at: item, includeHidden: true)
|
|
if itemSize > 1024 * 1024 { // Only log items > 1MB
|
|
LoggingService.shared.logDownload(" Bundle/\(item.lastPathComponent): \(formatBytesStatic(itemSize))", details: "\(itemCount) files")
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check parent of bundle for other app-related directories
|
|
let bundleParent = bundleURL.deletingLastPathComponent()
|
|
if let parentContents = try? fileManager.contentsOfDirectory(at: bundleParent, includingPropertiesForKeys: nil, options: []) {
|
|
for item in parentContents where item.lastPathComponent != bundleURL.lastPathComponent {
|
|
let (itemSize, itemCount) = directorySize(at: item, includeHidden: true)
|
|
if itemSize > 1024 * 1024 { // Only log items > 1MB
|
|
items.append(StorageUsageItem(name: "BundleContainer/\(item.lastPathComponent)", path: item.path, size: itemSize, fileCount: itemCount))
|
|
bundleSize += itemSize
|
|
LoggingService.shared.logDownload("BundleContainer/\(item.lastPathComponent): \(formatBytesStatic(itemSize))", details: "\(itemCount) files")
|
|
}
|
|
}
|
|
}
|
|
LoggingService.shared.logDownload("Bundle container", details: bundleParent.path)
|
|
}
|
|
|
|
let totalSize = documentsTotal + cachesTotal + appSupportTotal + tempTotal + otherTotal + bundleSize
|
|
|
|
return StorageDiagnostics(
|
|
items: items,
|
|
totalSize: totalSize,
|
|
documentsSize: documentsTotal,
|
|
cachesSize: cachesTotal,
|
|
appSupportSize: appSupportTotal,
|
|
tempSize: tempTotal,
|
|
otherSize: otherTotal
|
|
)
|
|
}
|
|
|
|
// MARK: - Thread-Safe Storage
|
|
|
|
/// A thread-safe wrapper for mutable values using NSLock.
|
|
/// Conforms to Sendable since all access is synchronized.
|
|
final class LockedStorage<Value>: @unchecked Sendable {
|
|
private var value: Value
|
|
private let lock = NSLock()
|
|
|
|
init(_ value: Value) {
|
|
self.value = value
|
|
}
|
|
|
|
func read<T>(_ block: (Value) -> T) -> T {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
return block(value)
|
|
}
|
|
|
|
func write(_ block: (inout Value) -> Void) {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
block(&value)
|
|
}
|
|
}
|