import Foundation import Combine /// Settings sections, ported from `feature/settings/SettingsViewModel.kt`. enum SettingsSection: Hashable { case overview case preferences case appearance case notifications case storage case about case bugReport var titleKey: String { switch self { case .overview: return "settings_title" case .preferences: return "preferences_title" case .appearance: return "appearance_title" case .notifications: return "notifications_title" case .storage: return "storage_title" case .about: return "about_title" case .bugReport: return "about_bug_report" } } } /// On-disk usage breakdown for the Storage screen. struct StorageBreakdown: Equatable { var receivedFiles: UInt64 = 0 var transferData: UInt64 = 0 var temporary: UInt64 = 0 var total: UInt64 { receivedFiles + transferData + 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 notificationsEnabled = false var notificationPermission: NotificationPermission = .notDetermined var diagnosticsEnabled = false 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 isDeletingTransfers = 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.notificationsEnabled == rhs.notificationsEnabled && lhs.notificationPermission == rhs.notificationPermission && lhs.diagnosticsEnabled == rhs.diagnosticsEnabled && lhs.appVersion == rhs.appVersion && 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.isDeletingTransfers == rhs.isDeletingTransfers && 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: CoreRepository private let preferences: AppPreferencesRepository private let notifications: LocalNotificationService private let messages: UiMessageController private let bugReports: BugReportService private let diagnosticsIncluded: Bool private var enableNotificationsAfterSettings = false private var usernamePersistTask: Task? private var hasLocalUsernameDraft = false private var cancellables = Set() init( environment: PlatformEnvironment, deviceInfoProvider: DeviceInfoProvider, fileSystemService: FileSystemService, repository: CoreRepository, preferences: AppPreferencesRepository, notifications: LocalNotificationService, messages: UiMessageController, bugReports: BugReportService, diagnosticsIncluded: Bool = DiagnosticsBuildConfig.included ) { 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.diagnosticsIncluded = diagnosticsIncluded 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 self.state.notificationsEnabled = prefs.notificationsEnabled self.state.diagnosticsEnabled = prefs.diagnosticsEnabled if folder != previousFolder { Task { await self.validateFolder(folder) } } } .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.message(reason)) } func resetReceiveFolder() { preferences.resetReceiveFolder() } func setNotificationsEnabled(_ enabled: Bool) { Task { if !enabled { preferences.setNotificationsEnabled(false) notifications.cancelAll() return } let permission = await notifications.requestPermission() state.notificationPermission = permission if permission == .granted { await enableNotifications() } else { preferences.setNotificationsEnabled(false) let key = permission == .unsupported ? "notifications_unsupported" : "notifications_permission_denied" messages.show(UiMessage( text: .resource(key), tone: .warning, actionLabel: permission == .denied ? .resource("button_open_settings") : nil, onAction: permission == .denied ? { self.openNotificationSettings() } : nil )) } } } func setDiagnosticsEnabled(_ enabled: Bool) { if !diagnosticsIncluded { return } Task { preferences.setDiagnosticsEnabled(enabled) messages.show(UiMessage( text: .resource(enabled ? "diagnostics_enabled_message" : "diagnostics_disabled_message"), tone: .success )) } } 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("bug_report_missing_what"), tone: .warning)) return } if expected.isEmpty { messages.show(UiMessage(text: .resource("bug_report_missing_expected"), 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("bug_report_submitted"), tone: .success)) onSuccess() case .failure: state.isSubmittingBugReport = false messages.show(UiMessage(text: .resource("bug_report_submit_failed"), tone: .error)) } } } func openNotificationSettings() { Task { enableNotificationsAfterSettings = true let result = await notifications.openSettings() if case .failure = result { enableNotificationsAfterSettings = false messages.show(UiMessage(text: .resource("notifications_settings_open_failed"), tone: .error)) } } } func refreshNotificationPermission() { Task { let permission = await notifications.refreshPermission() state.notificationPermission = permission if enableNotificationsAfterSettings { enableNotificationsAfterSettings = false if permission == .granted { await enableNotifications() } } else if permission != .granted && state.notificationsEnabled { preferences.setNotificationsEnabled(false) notifications.cancelAll() } } } private func enableNotifications() async { preferences.setNotificationsEnabled(true) messages.show(UiMessage(text: .resource("notifications_enabled_message"), tone: .success)) } // MARK: - Storage /// Recomputes the on-disk usage breakdown off the main actor. func loadStorageUsage() { if state.isCalculatingStorage { return } state.isCalculatingStorage = true let coreDir = environment.defaultCoreDataDir let receiveDir = state.receiveFolder?.isFileSystemPath == true ? state.receiveFolder?.value : nil let tempDir = NSTemporaryDirectory() Task.detached { let breakdown = StorageBreakdown( receivedFiles: receiveDir.map { SettingsModel.directorySize($0) } ?? 0, transferData: SettingsModel.directorySize(coreDir), temporary: SettingsModel.directorySize(tempDir) ) await MainActor.run { [weak self] in self?.state.storage = breakdown self?.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 { for id in repository.state.transfers.map(\.transferId) { _ = await repository.delete(transferId: id) } _ = await repository.refresh() state.isDeletingTransfers = false loadStorageUsage() messages.show(UiMessage(text: .resource("storage_transfers_deleted"), tone: .success)) } } /// 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 } }