Add full container file breakdown to storage settings export

Users reported total app storage significantly higher than what the
storage settings view shows. To aid debugging, the export now includes
a "Full Container File Breakdown" section that enumerates every regular
file in the app sandbox and shared app group container, sorted by size
descending so orphaned/unexpectedly large files are easy to spot.

Changes:
- StorageStatsManager: add ContainerFileEntry and containerFileBreakdown()
- StorageStatsViewHelper: add formatContainerFileBreakdown() helper and
  include it at the end of formatStorageStatsAsText output

Closes: https://github.com/damus-io/damus/issues/3677
Changelog-Added: Full container file breakdown in storage settings export
This commit is contained in:
Daniel D’Aquino
2026-03-13 15:09:54 -07:00
parent 1768432a1e
commit 0a801b01cb
2 changed files with 103 additions and 0 deletions
@@ -151,4 +151,72 @@ struct StorageStatsManager {
formatter.isAdaptive = true formatter.isAdaptive = true
return formatter.string(fromByteCount: Int64(bytes)) return formatter.string(fromByteCount: Int64(bytes))
} }
/// A single file entry produced by container enumeration
struct ContainerFileEntry {
/// Human-readable label for the container root (e.g. "Documents")
let containerLabel: String
/// File path relative to the container root URL
let relativePath: String
/// File size in bytes
let size: UInt64
}
/// Enumerate every file in the app sandbox and the shared app group container.
///
/// Results are sorted by size descending so the largest files appear first,
/// making it easy to spot unexpectedly large or orphaned items.
///
/// - Returns: Array of `ContainerFileEntry` values, one per regular file found.
func containerFileBreakdown() -> [ContainerFileEntry] {
let fm = FileManager.default
// Collect (label, root URL) pairs to walk
var roots: [(label: String, url: URL)] = []
// Primary sandbox container
if let home = fm.urls(for: .documentDirectory, in: .userDomainMask).first?.deletingLastPathComponent() {
roots.append((label: "Sandbox", url: home))
}
// Shared app group container (legacy nostrdb + snapshot)
if let groupURL = fm.containerURL(forSecurityApplicationGroupIdentifier: "group.com.damus") {
roots.append((label: "AppGroup", url: groupURL))
}
var entries: [ContainerFileEntry] = []
for root in roots {
guard let enumerator = fm.enumerator(
at: root.url,
includingPropertiesForKeys: [.fileSizeKey, .isRegularFileKey],
options: [.skipsHiddenFiles]
) else { continue }
for case let fileURL as URL in enumerator {
guard let resourceValues = try? fileURL.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]),
resourceValues.isRegularFile == true,
let size = resourceValues.fileSize else { continue }
// Build a path relative to the container root
let relativePath: String
if fileURL.path.hasPrefix(root.url.path) {
relativePath = String(fileURL.path.dropFirst(root.url.path.count))
.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
} else {
relativePath = fileURL.path
}
entries.append(ContainerFileEntry(
containerLabel: root.label,
relativePath: relativePath,
size: UInt64(max(0, size))
))
}
}
// Largest files first
entries.sort { $0.size > $1.size }
return entries
}
} }
@@ -110,6 +110,9 @@ enum StorageStatsViewHelper {
text += await formatNostrDBDetails(details: details) text += await formatNostrDBDetails(details: details)
} }
// Full container file listing for debugging orphaned files
text += await formatContainerFileBreakdown()
return text return text
} }
@@ -166,4 +169,36 @@ enum StorageStatsViewHelper {
return text return text
} }
/// Enumerate all files in the app sandbox and shared app group container and format them as text.
///
/// Files are sorted by size descending so the largest contributors appear first,
/// making orphaned or unexpectedly large files easy to identify.
///
/// - Returns: Formatted text listing every file with its size and relative path.
@concurrent
private static func formatContainerFileBreakdown() async -> String {
let entries = StorageStatsManager.shared.containerFileBreakdown()
var text = String(repeating: "=", count: 50) + "\n\n"
text += "Full Container File Breakdown:\n"
text += String(repeating: "-", count: 50) + "\n"
if entries.isEmpty {
text += "(no files found)\n"
} else {
var totalSize: UInt64 = 0
for entry in entries {
let sizePadded = StorageStatsManager.formatBytes(entry.size).padding(toLength: 12, withPad: " ", startingAt: 0)
text += "[\(entry.containerLabel)] \(sizePadded) \(entry.relativePath)\n"
totalSize += entry.size
}
text += String(repeating: "-", count: 50) + "\n"
let totalTitlePadded = "Total (all files)".padding(toLength: 25, withPad: " ", startingAt: 0)
let totalSizePadded = StorageStatsManager.formatBytes(totalSize).padding(toLength: 12, withPad: " ", startingAt: 0)
text += "\(totalTitlePadded) \(totalSizePadded)\n"
}
return text
}
} }