mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-07 19:29:57 +02:00
Device list, detail, and block list under Settings, plus the two sheets: an incoming offer and a device asking to be remembered. Both are answer-only, since swiping away would leave the sender waiting. Accepting an offer routes the released ticket into the ordinary receive path, so the platform still chooses the destination. The prompt does not ask a second time; it only falls back to the review sheet when the destination is unusable.
593 lines
21 KiB
Swift
593 lines
21 KiB
Swift
import Foundation
|
|
import Combine
|
|
|
|
/// Settings sections, ported from `feature/settings/SettingsViewModel.kt`.
|
|
enum SettingsSection: Hashable {
|
|
case overview
|
|
case preferences
|
|
case appearance
|
|
case notifications
|
|
case network
|
|
case contacts
|
|
case storage
|
|
case about
|
|
case bugReport
|
|
|
|
var titleKey: String.LocalizationValue {
|
|
switch self {
|
|
case .overview: return L10n.Settings.title
|
|
case .preferences: return L10n.Preferences.title
|
|
case .appearance: return L10n.Appearance.title
|
|
case .notifications: return L10n.Notifications.title
|
|
case .network: return L10n.Settings.networkTitle
|
|
case .contacts: return L10n.Contacts.title
|
|
case .storage: return L10n.Storage.title
|
|
case .about: return L10n.About.title
|
|
case .bugReport: return L10n.About.bugReport
|
|
}
|
|
}
|
|
}
|
|
|
|
/// On-disk usage breakdown for the Storage screen.
|
|
struct StorageBreakdown: Equatable {
|
|
var receivedFiles: UInt64 = 0
|
|
var transferCache: UInt64 = 0
|
|
var appData: UInt64 = 0
|
|
var temporary: UInt64 = 0
|
|
var total: UInt64 { receivedFiles + transferCache + appData + temporary }
|
|
}
|
|
|
|
struct SettingsState: Equatable {
|
|
var selectedSection: SettingsSection = .overview
|
|
var username = ""
|
|
var receiveFolder: ReceiveFolder?
|
|
var folderAccessStatus: FolderAccessStatus = .unavailable
|
|
var isValidatingFolder = false
|
|
var supportsCustomReceiveFolders = true
|
|
var themeMode: ThemeMode = .system
|
|
var notificationPermission: NotificationPermission = .notDetermined
|
|
var relayMode: RelayPreferenceMode = .automatic
|
|
var relayURLs: [String] = []
|
|
var relayValidationError: RelayConfigurationValidationError?
|
|
var relayConfigurationIsDirty = false
|
|
var isApplyingRelayConfiguration = false
|
|
var hasActiveNetworkWork = false
|
|
var endpointId: String?
|
|
var relayApplyErrorKey: String.LocalizationValue?
|
|
var deviceInfo: DeviceInfo?
|
|
var appVersion = ""
|
|
var isLoadingDeviceInfo = false
|
|
var bugWhatHappened = ""
|
|
var bugExpected = ""
|
|
var bugSteps = ""
|
|
var bugContact = ""
|
|
var bugIncludeLogs = true
|
|
var isSubmittingBugReport = false
|
|
var bugLogPreviewBytes = 0
|
|
var storage: StorageBreakdown?
|
|
var isCalculatingStorage = false
|
|
var storageLoadFailed = false
|
|
var isDeletingTransfers = false
|
|
var isCleaningStorage = false
|
|
|
|
static func == (lhs: SettingsState, rhs: SettingsState) -> Bool {
|
|
lhs.selectedSection == rhs.selectedSection && lhs.username == rhs.username
|
|
&& lhs.receiveFolder == rhs.receiveFolder && lhs.folderAccessStatus == rhs.folderAccessStatus
|
|
&& lhs.isValidatingFolder == rhs.isValidatingFolder
|
|
&& lhs.supportsCustomReceiveFolders == rhs.supportsCustomReceiveFolders
|
|
&& lhs.themeMode == rhs.themeMode
|
|
&& lhs.notificationPermission == rhs.notificationPermission
|
|
&& lhs.appVersion == rhs.appVersion
|
|
&& lhs.relayMode == rhs.relayMode && lhs.relayURLs == rhs.relayURLs
|
|
&& lhs.relayValidationError == rhs.relayValidationError
|
|
&& lhs.relayConfigurationIsDirty == rhs.relayConfigurationIsDirty
|
|
&& lhs.isApplyingRelayConfiguration == rhs.isApplyingRelayConfiguration
|
|
&& lhs.hasActiveNetworkWork == rhs.hasActiveNetworkWork
|
|
&& lhs.endpointId == rhs.endpointId
|
|
&& lhs.relayApplyErrorKey == rhs.relayApplyErrorKey
|
|
&& lhs.isLoadingDeviceInfo == rhs.isLoadingDeviceInfo
|
|
&& lhs.bugWhatHappened == rhs.bugWhatHappened && lhs.bugExpected == rhs.bugExpected
|
|
&& lhs.bugSteps == rhs.bugSteps && lhs.bugContact == rhs.bugContact
|
|
&& lhs.bugIncludeLogs == rhs.bugIncludeLogs && lhs.isSubmittingBugReport == rhs.isSubmittingBugReport
|
|
&& lhs.bugLogPreviewBytes == rhs.bugLogPreviewBytes
|
|
&& lhs.storage == rhs.storage && lhs.isCalculatingStorage == rhs.isCalculatingStorage
|
|
&& lhs.storageLoadFailed == rhs.storageLoadFailed
|
|
&& lhs.isDeletingTransfers == rhs.isDeletingTransfers
|
|
&& lhs.isCleaningStorage == rhs.isCleaningStorage
|
|
&& lhs.deviceInfo?.operatingSystem == rhs.deviceInfo?.operatingSystem
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
final class SettingsModel: ObservableObject {
|
|
@Published private(set) var state: SettingsState
|
|
/// Set by the view to request the receive-folder picker (macOS).
|
|
@Published var pendingReceiveFolderPick = false
|
|
|
|
private let environment: PlatformEnvironment
|
|
private let deviceInfoProvider: DeviceInfoProvider
|
|
private let fileSystemService: FileSystemService
|
|
private let repository: CoreGateway
|
|
private let preferences: AppPreferencesRepository
|
|
private let notifications: LocalNotificationService
|
|
private let messages: UiMessageController
|
|
private let bugReports: BugReportService
|
|
|
|
private var usernamePersistTask: Task<Void, Never>?
|
|
private var hasLocalUsernameDraft = false
|
|
private var hasRelayConfigurationDraft = false
|
|
private var cancellables = Set<AnyCancellable>()
|
|
|
|
init(
|
|
environment: PlatformEnvironment,
|
|
deviceInfoProvider: DeviceInfoProvider,
|
|
fileSystemService: FileSystemService,
|
|
repository: CoreGateway,
|
|
preferences: AppPreferencesRepository,
|
|
notifications: LocalNotificationService,
|
|
messages: UiMessageController,
|
|
bugReports: BugReportService
|
|
) {
|
|
self.environment = environment
|
|
self.deviceInfoProvider = deviceInfoProvider
|
|
self.fileSystemService = fileSystemService
|
|
self.repository = repository
|
|
self.preferences = preferences
|
|
self.notifications = notifications
|
|
self.messages = messages
|
|
self.bugReports = bugReports
|
|
self.state = SettingsState(
|
|
supportsCustomReceiveFolders: fileSystemService.supportsCustomReceiveFolders,
|
|
appVersion: environment.appVersion
|
|
)
|
|
|
|
preferences.$preferences
|
|
.sink { [weak self] prefs in
|
|
guard let self else { return }
|
|
let previousFolder = self.state.receiveFolder
|
|
let folder = self.fileSystemService.effectiveReceiveFolder(prefs.receiveFolder)
|
|
self.state.username = self.hasLocalUsernameDraft ? self.state.username : prefs.username
|
|
self.state.receiveFolder = folder
|
|
self.state.themeMode = prefs.themeMode
|
|
if !self.hasRelayConfigurationDraft {
|
|
self.state.relayMode = prefs.relayConfiguration.mode
|
|
self.state.relayURLs = prefs.relayConfiguration.relayURLs
|
|
self.state.relayConfigurationIsDirty = false
|
|
}
|
|
if folder != previousFolder { Task { await self.validateFolder(folder) } }
|
|
}
|
|
.store(in: &cancellables)
|
|
|
|
repository.statePublisher
|
|
.sink { [weak self] coreState in
|
|
guard let self else { return }
|
|
let hasActiveWork = (coreState.status?.activeTransfers ?? 0) > 0
|
|
|| (coreState.status?.activeShares ?? 0) > 0
|
|
|| coreState.transfers.contains(where: { $0.status.isActiveTransfer })
|
|
self.state.hasActiveNetworkWork = hasActiveWork
|
|
self.state.endpointId = coreState.status?.endpointId
|
|
if !hasActiveWork && self.state.relayApplyErrorKey == L10n.Relay.applyActiveTransfers {
|
|
self.state.relayApplyErrorKey = nil
|
|
}
|
|
}
|
|
.store(in: &cancellables)
|
|
|
|
refreshNotificationPermission()
|
|
loadDeviceInfo()
|
|
}
|
|
|
|
func selectSection(_ section: SettingsSection) {
|
|
state.selectedSection = section
|
|
if section == .about || section == .bugReport {
|
|
loadDeviceInfo()
|
|
if section == .bugReport { refreshBugLogPreview() }
|
|
}
|
|
}
|
|
|
|
func setUsername(_ value: String) {
|
|
hasLocalUsernameDraft = true
|
|
state.username = value
|
|
usernamePersistTask?.cancel()
|
|
usernamePersistTask = Task {
|
|
try? await Task.sleep(nanoseconds: 350_000_000)
|
|
if Task.isCancelled { return }
|
|
preferences.setUsername(value)
|
|
}
|
|
}
|
|
|
|
func setThemeMode(_ mode: ThemeMode) { preferences.setThemeMode(mode) }
|
|
|
|
func chooseReceiveFolder() {
|
|
if !fileSystemService.supportsCustomReceiveFolders { return }
|
|
pendingReceiveFolderPick = true
|
|
}
|
|
|
|
func onReceiveFolderPicked(_ folder: ReceiveFolder) { preferences.setReceiveFolder(folder) }
|
|
func onReceiveFolderPickFailed(_ reason: String) { messages.error(InvitationError.raw(reason)) }
|
|
func resetReceiveFolder() { preferences.resetReceiveFolder() }
|
|
|
|
/// Whether the current receive folder is the platform default (so the reset
|
|
/// action can be hidden when it would be a no-op). Compared by location, not
|
|
/// display name, which can differ once resolved.
|
|
var isUsingDefaultReceiveFolder: Bool {
|
|
guard let folder = state.receiveFolder else { return true }
|
|
let fallback = fileSystemService.defaultReceiveFolder()
|
|
return folder.kind == fallback.kind && folder.value == fallback.value
|
|
}
|
|
|
|
/// Ask the OS for notification permission. This is the only time the app can
|
|
/// grant it; disabling or fine-tuning afterwards happens in the Settings app.
|
|
func requestNotifications() {
|
|
Task {
|
|
let permission = await notifications.requestPermission()
|
|
state.notificationPermission = permission
|
|
if permission == .unsupported {
|
|
messages.show(UiMessage(text: .resource(L10n.Notifications.unsupported), tone: .warning))
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Network
|
|
|
|
func setRelayMode(_ mode: RelayPreferenceMode) {
|
|
hasRelayConfigurationDraft = true
|
|
state.relayMode = mode
|
|
if mode.usesCustomRelayURLs && state.relayURLs.isEmpty { state.relayURLs = [""] }
|
|
updateRelayConfigurationDraft()
|
|
}
|
|
|
|
func setRelayURL(_ value: String, at index: Int) {
|
|
guard state.relayURLs.indices.contains(index) else { return }
|
|
hasRelayConfigurationDraft = true
|
|
state.relayURLs[index] = value
|
|
updateRelayConfigurationDraft()
|
|
}
|
|
|
|
func addRelayURL() {
|
|
guard state.relayURLs.count < RelayConfigurationValidator.maximumRelayCount else { return }
|
|
hasRelayConfigurationDraft = true
|
|
state.relayURLs.append("")
|
|
updateRelayConfigurationDraft()
|
|
}
|
|
|
|
func removeRelayURL(at index: Int) {
|
|
guard state.relayURLs.indices.contains(index) else { return }
|
|
hasRelayConfigurationDraft = true
|
|
state.relayURLs.remove(at: index)
|
|
if state.relayURLs.isEmpty { state.relayURLs = [""] }
|
|
updateRelayConfigurationDraft()
|
|
}
|
|
|
|
func applyRelayConfiguration() {
|
|
guard !state.isApplyingRelayConfiguration, state.relayConfigurationIsDirty else { return }
|
|
|
|
let configuration: RelayConfiguration
|
|
do {
|
|
configuration = try RelayConfigurationValidator.validate(
|
|
mode: state.relayMode,
|
|
relayURLs: state.relayURLs,
|
|
retainedRelayURLs: preferences.preferences.relayConfiguration.relayURLs
|
|
)
|
|
} catch let error as RelayConfigurationValidationError {
|
|
state.relayValidationError = error
|
|
state.relayApplyErrorKey = nil
|
|
return
|
|
} catch {
|
|
return
|
|
}
|
|
|
|
let coreState = repository.state
|
|
let hasActiveWork = (coreState.status?.activeTransfers ?? 0) > 0
|
|
|| (coreState.status?.activeShares ?? 0) > 0
|
|
|| coreState.transfers.contains(where: { $0.status.isActiveTransfer })
|
|
guard !hasActiveWork else {
|
|
state.hasActiveNetworkWork = true
|
|
state.relayApplyErrorKey = L10n.Relay.applyActiveTransfers
|
|
messages.show(UiMessage(text: .resource(L10n.Relay.applyActiveTransfers), tone: .warning))
|
|
return
|
|
}
|
|
|
|
let previousConfiguration = preferences.preferences.relayConfiguration
|
|
state.relayValidationError = nil
|
|
state.relayApplyErrorKey = nil
|
|
state.isApplyingRelayConfiguration = true
|
|
Task {
|
|
let applyResult = await repository.initialize(
|
|
appDataDir: environment.defaultCoreDataDir,
|
|
networkConfiguration: configuration
|
|
)
|
|
switch applyResult {
|
|
case .success:
|
|
hasRelayConfigurationDraft = false
|
|
preferences.setRelayConfiguration(configuration)
|
|
state.isApplyingRelayConfiguration = false
|
|
state.relayConfigurationIsDirty = false
|
|
messages.show(UiMessage(text: .resource(L10n.Relay.settingsApplied), tone: .success))
|
|
case .failure(let error):
|
|
if let lifecycleError = error as? CoreNetworkLifecycleError {
|
|
state.isApplyingRelayConfiguration = false
|
|
switch lifecycleError {
|
|
case .activeNetworkWork:
|
|
state.hasActiveNetworkWork = true
|
|
state.relayApplyErrorKey = L10n.Relay.applyActiveTransfers
|
|
case .transitionInProgress:
|
|
state.relayApplyErrorKey = L10n.Relay.applyFailed
|
|
}
|
|
return
|
|
}
|
|
let rollbackResult = await repository.initialize(
|
|
appDataDir: environment.defaultCoreDataDir,
|
|
networkConfiguration: previousConfiguration
|
|
)
|
|
state.isApplyingRelayConfiguration = false
|
|
if case .success = rollbackResult {
|
|
state.relayApplyErrorKey = L10n.Relay.applyFailed
|
|
messages.show(UiMessage(text: .resource(L10n.Relay.applyFailed), tone: .error))
|
|
} else {
|
|
state.relayApplyErrorKey = L10n.Relay.restoreFailed
|
|
messages.show(UiMessage(text: .resource(L10n.Relay.restoreFailed), tone: .error))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func updateRelayConfigurationDraft() {
|
|
state.relayValidationError = nil
|
|
state.relayApplyErrorKey = nil
|
|
let saved = preferences.preferences.relayConfiguration
|
|
let draftURLs = state.relayMode.usesCustomRelayURLs ? state.relayURLs : saved.relayURLs
|
|
state.relayConfigurationIsDirty = saved != RelayConfiguration(mode: state.relayMode, relayURLs: draftURLs)
|
|
hasRelayConfigurationDraft = state.relayConfigurationIsDirty
|
|
}
|
|
|
|
func setBugWhatHappened(_ value: String) { state.bugWhatHappened = value }
|
|
func setBugExpected(_ value: String) { state.bugExpected = value }
|
|
func setBugSteps(_ value: String) { state.bugSteps = value }
|
|
func setBugContact(_ value: String) { state.bugContact = value }
|
|
func setBugIncludeLogs(_ value: Bool) { state.bugIncludeLogs = value }
|
|
|
|
func submitBugReport(onSuccess: @escaping () -> Void = {}) {
|
|
if state.isSubmittingBugReport { return }
|
|
Task {
|
|
let snapshot = state
|
|
let what = snapshot.bugWhatHappened.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let expected = snapshot.bugExpected.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if what.isEmpty {
|
|
messages.show(UiMessage(text: .resource(L10n.Bug.reportMissingWhat), tone: .warning))
|
|
return
|
|
}
|
|
if expected.isEmpty {
|
|
messages.show(UiMessage(text: .resource(L10n.Bug.reportMissingExpected), tone: .warning))
|
|
return
|
|
}
|
|
state.isSubmittingBugReport = true
|
|
let result = await bugReports.submit(
|
|
BugReportDraft(
|
|
whatHappened: what, expected: expected, steps: snapshot.bugSteps,
|
|
contact: snapshot.bugContact, includeLogs: snapshot.bugIncludeLogs
|
|
),
|
|
deviceInfo: snapshot.deviceInfo
|
|
)
|
|
switch result {
|
|
case .success:
|
|
state.isSubmittingBugReport = false
|
|
state.bugWhatHappened = ""
|
|
state.bugExpected = ""
|
|
state.bugSteps = ""
|
|
state.bugContact = ""
|
|
state.bugIncludeLogs = true
|
|
messages.show(UiMessage(text: .resource(L10n.Bug.reportSubmitted), tone: .success))
|
|
onSuccess()
|
|
case .failure:
|
|
state.isSubmittingBugReport = false
|
|
messages.show(UiMessage(text: .resource(L10n.Bug.reportSubmitFailed), tone: .error))
|
|
}
|
|
}
|
|
}
|
|
|
|
func openNotificationSettings() {
|
|
Task {
|
|
if case .failure = await notifications.openSettings() {
|
|
messages.show(UiMessage(text: .resource(L10n.Notifications.settingsOpenFailed), tone: .error))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Re-read the OS permission (called on appear and when returning to the
|
|
/// foreground, e.g. after a trip to Settings) so the toggle stays in sync.
|
|
func refreshNotificationPermission() {
|
|
Task {
|
|
state.notificationPermission = await notifications.refreshPermission()
|
|
}
|
|
}
|
|
|
|
// MARK: - Storage
|
|
|
|
/// Recomputes the on-disk usage breakdown off the main actor. Safe to call
|
|
/// before the core is ready: it keeps the spinner up and waits for the core to
|
|
/// finish initializing (it starts asynchronously at launch) rather than bailing.
|
|
func loadStorageUsage() {
|
|
if state.isCalculatingStorage { return }
|
|
state.isCalculatingStorage = true
|
|
state.storageLoadFailed = false
|
|
let tempDir = NSTemporaryDirectory()
|
|
Task {
|
|
// The core initializes asynchronously at launch; poll briefly so opening
|
|
// Storage early doesn't leave the summary stuck.
|
|
var attempts = 0
|
|
while !repository.state.isInitialized && attempts < 100 {
|
|
try? await Task.sleep(nanoseconds: 100_000_000)
|
|
attempts += 1
|
|
}
|
|
let coreResult = await repository.storageUsage()
|
|
let artifactsResult = await repository.receivedArtifacts()
|
|
guard case .success(let core) = coreResult,
|
|
case .success(let artifacts) = artifactsResult else {
|
|
state.isCalculatingStorage = false
|
|
state.storageLoadFailed = true
|
|
return
|
|
}
|
|
let diskSizes = await Task.detached {
|
|
let received = artifacts.reduce(UInt64(0)) { total, artifact in
|
|
total + SettingsModel.fileSize(artifact.locator)
|
|
}
|
|
return (received, SettingsModel.directorySize(tempDir))
|
|
}.value
|
|
let breakdown = StorageBreakdown(
|
|
receivedFiles: diskSizes.0,
|
|
transferCache: core.blobStoreBytes,
|
|
appData: core.appDataBytes,
|
|
temporary: diskSizes.1
|
|
)
|
|
state.storage = breakdown
|
|
state.isCalculatingStorage = false
|
|
}
|
|
}
|
|
|
|
/// Deletes every send/receive transfer via the core, freeing the imported
|
|
/// shared-file content and clearing history. Node identity and received files
|
|
/// are left untouched.
|
|
func deleteAllTransfers() {
|
|
if state.isDeletingTransfers { return }
|
|
state.isDeletingTransfers = true
|
|
Task {
|
|
var failures = 0
|
|
for id in repository.state.transfers.map(\.transferId) {
|
|
if case .failure = await repository.delete(transferId: id) { failures += 1 }
|
|
}
|
|
_ = await repository.refresh()
|
|
state.isDeletingTransfers = false
|
|
if failures == 0 {
|
|
loadStorageUsage()
|
|
messages.show(UiMessage(text: .resource(L10n.Storage.transfersDeleted), tone: .success))
|
|
} else {
|
|
messages.error(InvitationError.deleteRecordsFailed)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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]
|
|
)
|
|
guard values?.isRegularFile == true else { return 0 }
|
|
return UInt64(values?.totalFileAllocatedSize ?? values?.fileSize ?? 0)
|
|
}
|
|
|
|
/// Recursive size of every regular file under `path` (0 if missing).
|
|
nonisolated static func directorySize(_ path: String) -> UInt64 {
|
|
let url = URL(fileURLWithPath: path)
|
|
guard let enumerator = FileManager.default.enumerator(
|
|
at: url, includingPropertiesForKeys: [.isRegularFileKey, .totalFileAllocatedSizeKey, .fileSizeKey]
|
|
) else { return 0 }
|
|
var total: UInt64 = 0
|
|
for case let fileURL as URL in enumerator {
|
|
let values = try? fileURL.resourceValues(forKeys: [.isRegularFileKey, .totalFileAllocatedSizeKey, .fileSizeKey])
|
|
guard values?.isRegularFile == true else { continue }
|
|
total += UInt64(values?.totalFileAllocatedSize ?? values?.fileSize ?? 0)
|
|
}
|
|
return total
|
|
}
|
|
|
|
private func loadDeviceInfo() {
|
|
if state.isLoadingDeviceInfo { return }
|
|
state.isLoadingDeviceInfo = true
|
|
Task {
|
|
let info = await deviceInfoProvider.load()
|
|
state.deviceInfo = info
|
|
state.isLoadingDeviceInfo = false
|
|
}
|
|
}
|
|
|
|
private func refreshBugLogPreview() {
|
|
Task {
|
|
let bytes = await bugReports.previewLogBytes()
|
|
state.bugLogPreviewBytes = bytes
|
|
}
|
|
}
|
|
|
|
private func validateFolder(_ folder: ReceiveFolder) async {
|
|
state.isValidatingFolder = true
|
|
let status = await fileSystemService.validateReceiveFolder(folder)
|
|
state.folderAccessStatus = status
|
|
state.isValidatingFolder = false
|
|
}
|
|
}
|