From fc82d5fe054b1c3a712e1c6223bb7075266a9b76 Mon Sep 17 00:00:00 2001 From: delli Date: Thu, 4 Dec 2025 21:03:12 +0100 Subject: [PATCH] implement download feature --- DynamicIsland.xcodeproj/project.pbxproj | 2 + DynamicIsland/ContentView.swift | 4 + DynamicIsland/DynamicIslandApp.swift | 1 + .../DynamicIslandViewCoordinator.swift | 13 +- DynamicIsland/Localizable.xcstrings | 19 +- .../Downloads/DownloadLiveActivity.swift | 103 +++++++ .../SpinningCircleDownloadView.swift | 28 ++ .../components/Settings/SettingsView.swift | 171 ++++++----- DynamicIsland/enums/generic.swift | 1 + DynamicIsland/managers/DownloadManager.swift | 281 ++++++++++++++++++ 10 files changed, 539 insertions(+), 84 deletions(-) create mode 100644 DynamicIsland/components/Downloads/DownloadLiveActivity.swift create mode 100644 DynamicIsland/components/Downloads/SpinningCircleDownloadView.swift create mode 100644 DynamicIsland/managers/DownloadManager.swift diff --git a/DynamicIsland.xcodeproj/project.pbxproj b/DynamicIsland.xcodeproj/project.pbxproj index a2ce360..133d5a4 100644 --- a/DynamicIsland.xcodeproj/project.pbxproj +++ b/DynamicIsland.xcodeproj/project.pbxproj @@ -419,6 +419,7 @@ INFOPLIST_KEY_NSCameraUsageDescription = "We need your camera for Mirror mode"; INFOPLIST_KEY_NSDesktopFolderUsageDescription = "Atoll needs access to save screenshots to your desktop."; INFOPLIST_KEY_NSDocumentsFolderUsageDescription = "Atoll needs access to save and read AI assistant files."; + INFOPLIST_KEY_NSDownloadsFolderUsageDescription = "Atoll needs access to read files in the downloads folder."; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSLocationUsageDescription = "Atoll uses your location to display accurate lock screen weather."; INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Atoll uses your location to display accurate lock screen weather."; @@ -488,6 +489,7 @@ INFOPLIST_KEY_NSCameraUsageDescription = "We need your camera for Mirror mode"; INFOPLIST_KEY_NSDesktopFolderUsageDescription = "Atoll needs access to save screenshots to your desktop."; INFOPLIST_KEY_NSDocumentsFolderUsageDescription = "Atoll needs access to save and read AI assistant files."; + INFOPLIST_KEY_NSDownloadsFolderUsageDescription = "Atoll needs access to read files in the downloads folder."; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSLocationUsageDescription = "Atoll uses your location to display accurate lock screen weather."; INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Atoll uses your location to display accurate lock screen weather."; diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index a700f27..20ea4c4 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -28,6 +28,7 @@ struct ContentView: View { @ObservedObject var privacyManager = PrivacyIndicatorManager.shared @ObservedObject var doNotDisturbManager = DoNotDisturbManager.shared @ObservedObject var lockScreenManager = LockScreenManager.shared + @ObservedObject var downloadManager = DownloadManager.shared @Default(.enableStatsFeature) var enableStatsFeature @Default(.showCpuGraph) var showCpuGraph @@ -465,6 +466,9 @@ struct ContentView: View { ReminderLiveActivity() } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .recording) && vm.notchState == .closed && (recordingManager.isRecording || !recordingManager.isRecorderIdle) && Defaults[.enableScreenRecordingDetection] && !vm.hideOnClosed { RecordingLiveActivity() + } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .download) && vm.notchState == .closed && downloadManager.isDownloading && Defaults[.enableDownloadListener] && !vm.hideOnClosed { + DownloadLiveActivity() + .transition(.blurReplace.animation(.interactiveSpring(dampingFraction: 1.2))) } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .doNotDisturb) && vm.notchState == .closed && doNotDisturbManager.isDoNotDisturbActive && Defaults[.enableDoNotDisturbDetection] && Defaults[.showDoNotDisturbIndicator] && !vm.hideOnClosed && !lockScreenManager.isLocked { DoNotDisturbLiveActivity() } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .lockScreen) && vm.notchState == .closed && (lockScreenManager.isLocked || !lockScreenManager.isLockIdle) && Defaults[.enableLockScreenLiveActivity] && !vm.hideOnClosed { diff --git a/DynamicIsland/DynamicIslandApp.swift b/DynamicIsland/DynamicIslandApp.swift index 1413148..28bee6c 100644 --- a/DynamicIsland/DynamicIslandApp.swift +++ b/DynamicIsland/DynamicIslandApp.swift @@ -99,6 +99,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { let dndManager = DoNotDisturbManager.shared // NEW: DND detection let bluetoothAudioManager = BluetoothAudioManager.shared // NEW: Bluetooth audio detection let idleAnimationManager = IdleAnimationManager.shared // NEW: Custom idle animations + let downloadManager = DownloadManager.shared // NEW: Chromium downloads detection let lockScreenPanelManager = LockScreenPanelManager.shared // NEW: Lock screen music panel let systemTimerBridge = SystemTimerBridge.shared var closeNotchWorkItem: DispatchWorkItem? diff --git a/DynamicIsland/DynamicIslandViewCoordinator.swift b/DynamicIsland/DynamicIslandViewCoordinator.swift index f4da796..e4028c8 100644 --- a/DynamicIsland/DynamicIslandViewCoordinator.swift +++ b/DynamicIsland/DynamicIslandViewCoordinator.swift @@ -201,11 +201,14 @@ class DynamicIslandViewCoordinator: ObservableObject { didSet { if expandingView.show { expandingViewTask?.cancel() - let duration: TimeInterval = (expandingView.type == .download ? 2 : 3) - expandingViewTask = Task { [weak self] in - try? await Task.sleep(for: .seconds(duration)) - guard let self = self, !Task.isCancelled else { return } - self.toggleExpandingView(status: false, type: .battery) + // Only auto-hide for battery, not for downloads (DownloadManager handles that) + if expandingView.type != .download { + let duration: TimeInterval = 3 + expandingViewTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(duration)) + guard let self = self, !Task.isCancelled else { return } + self.toggleExpandingView(status: false, type: .battery) + } } } else { expandingViewTask?.cancel() diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 90e667c..e8c5244 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -4093,6 +4093,7 @@ } }, "Both" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -7158,7 +7159,12 @@ } } }, + "Download Detection" : { + "comment" : "A section header in the Downloads settings view.", + "isCommentAutoGenerated" : true + }, "Download icon style" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -7191,6 +7197,7 @@ } }, "Download indicators" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -8461,6 +8468,7 @@ "isCommentAutoGenerated" : true }, "Exclude apps" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -12855,6 +12863,10 @@ }, "Monitor clipboard changes and keep a history of recent copies. Use Cmd+Shift+V to quickly access clipboard history." : { + }, + "Monitor your Downloads folder for Chromium-style downloads (.crdownload files) and show a live activity in the Dynamic Island while downloads are in progress." : { + "comment" : "A footer description for the \"Downloads\" section of the settings view.", + "isCommentAutoGenerated" : true }, "Monitoring Behavior" : { @@ -13882,9 +13894,6 @@ } } } - }, - "No excluded apps" : { - }, "No excludes" : { "extractionState" : "stale", @@ -14768,6 +14777,7 @@ } }, "Only app icon" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -14778,6 +14788,7 @@ } }, "Only download icon" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -14840,6 +14851,7 @@ }, "Percentage" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -15044,6 +15056,7 @@ "isCommentAutoGenerated" : true }, "Progress bar" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { diff --git a/DynamicIsland/components/Downloads/DownloadLiveActivity.swift b/DynamicIsland/components/Downloads/DownloadLiveActivity.swift new file mode 100644 index 0000000..415e4e0 --- /dev/null +++ b/DynamicIsland/components/Downloads/DownloadLiveActivity.swift @@ -0,0 +1,103 @@ +// +// DownloadLiveActivity.swift +// DynamicIsland +// +// Shows a download indicator when Chromium-style downloads +// are detected in the user's Downloads folder. +// + +import SwiftUI +import Defaults + +struct DownloadLiveActivity: View { + @EnvironmentObject var vm: DynamicIslandViewModel + @ObservedObject var downloadManager = DownloadManager.shared + + @State private var isHovering: Bool = false + @State private var gestureProgress: CGFloat = 0 + @State private var isExpanded: Bool = false + + private var tint: Color { + .accentColor + } + + var body: some View { + HStack(spacing: 0) { + // Left side: download icon capsule + Color.clear + .background { + if isExpanded { + HStack { + ZStack { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(tint.opacity(0.14)) + + Image(systemName: "arrow.down.circle.fill") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(tint) + } + .frame( + width: vm.effectiveClosedNotchHeight - 12, + height: vm.effectiveClosedNotchHeight - 12 + ) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + } + } + .frame( + width: isExpanded ? max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2) : 0, + height: vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + ) + + // Center: closed notch body (slightly wider during downloads) + Rectangle() + .fill(.black) + .frame( + width: vm.closedNotchSize.width + + (isHovering ? 8 : 0) + + (downloadManager.isDownloading ? 40 : 0) + ) + + // Right side: indeterminate-style progress bar + Color.clear + .background { + if isExpanded { + HStack { + if downloadManager.isDownloadCompleted { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + .font(.system(size: 16, weight: .semibold)) + .padding(.trailing, 6) + } else if Defaults[.selectedDownloadIndicatorStyle] == .circle { + SpinningCircleDownloadView() + .padding(.trailing, 6) + } else { + ProgressView() + .progressViewStyle(.linear) + .tint(.accentColor) + .frame(width: 40) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing) + } + } + .frame( + width: isExpanded ? max(60, vm.effectiveClosedNotchHeight) : 0, + height: vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + ) + } + .frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0)) + .onAppear { + withAnimation(.smooth(duration: 0.35)) { + isExpanded = true + } + } + .onChange(of: downloadManager.isDownloading) { _, newValue in + withAnimation(.smooth(duration: 0.35)) { + isExpanded = newValue + } + } + } +} + + diff --git a/DynamicIsland/components/Downloads/SpinningCircleDownloadView.swift b/DynamicIsland/components/Downloads/SpinningCircleDownloadView.swift new file mode 100644 index 0000000..1198c38 --- /dev/null +++ b/DynamicIsland/components/Downloads/SpinningCircleDownloadView.swift @@ -0,0 +1,28 @@ +import SwiftUI + +struct SpinningCircleDownloadView: View { + @State private var isRotating = false + + var body: some View { + ZStack { + // Gray track + Circle() + .stroke(Color.gray.opacity(0.3), lineWidth: 3.5) + + // Blue spinning segment + Circle() + .trim(from: 0, to: 0.25) + .stroke( + Color.accentColor, + style: StrokeStyle(lineWidth: 3.5, lineCap: .round) + ) + .rotationEffect(Angle(degrees: isRotating ? 360 : 0)) + .onAppear { + withAnimation(.linear(duration: 1).repeatForever(autoreverses: false)) { + isRotating = true + } + } + } + .frame(width: 16, height: 16) + } +} diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index cbbb5f8..903afa1 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -255,13 +255,10 @@ struct SettingsView: View { .padding(.horizontal, 12) List(filteredTabs, selection: selectionBinding) { tab in - let isComingSoon = tab == .downloads NavigationLink(value: tab) { HStack(spacing: 10) { sidebarIcon(for: tab) - .opacity(isComingSoon ? 0.45 : 1) Text(tab.title) - .foregroundStyle(isComingSoon ? Color.secondary : Color.primary) if tab == .osd { Spacer() Text("ALPHA") @@ -273,16 +270,10 @@ struct SettingsView: View { Capsule() .fill(Color.orange) ) - } else if isComingSoon { - Spacer() - comingSoonTag() } } .padding(.vertical, 4) } - .tag(tab) - .disabled(isComingSoon) - .listRowInsets(EdgeInsets(top: 4, leading: 12, bottom: 4, trailing: 10)) } .listStyle(SidebarListStyle()) .frame(minWidth: 200) @@ -309,7 +300,7 @@ struct SettingsView: View { .frame(width: 700) .onChange(of: searchText) { _, newValue in let matches = tabsMatchingSearch(newValue) - guard let firstMatch = matches.first(where: { $0 != .downloads }) else { return } + guard let firstMatch = matches.first else { return } if !matches.contains(resolvedSelection) { selectedTab = firstMatch } @@ -348,7 +339,6 @@ struct SettingsView: View { Binding( get: { resolvedSelection }, set: { newValue in - guard newValue != .downloads else { return } selectedTab = newValue } ) @@ -1071,78 +1061,107 @@ struct Downloads: View { var body: some View { Form { - warningBadge("We don't support downloads yet", "It will be supported later on.") Section { - Defaults.Toggle("Show download progress", key: .enableDownloadListener) - .settingsHighlight(id: highlightID("Show download progress")) - .disabled(true) - Defaults.Toggle("Enable Safari Downloads", key: .enableSafariDownloads) - .settingsHighlight(id: highlightID("Enable Safari Downloads")) - .disabled(!Defaults[.enableDownloadListener]) - Picker("Download indicator style", selection: $selectedDownloadIndicatorStyle) { - Text("Progress bar") - .tag(DownloadIndicatorStyle.progress) - Text("Percentage") - .tag(DownloadIndicatorStyle.percentage) + Defaults.Toggle("Enable download detection", key: .enableDownloadListener) + .settingsHighlight(id: highlightID("Enable download detection")) + VStack(alignment: .leading, spacing: 12) { + Text("Download indicator style") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.white) + + HStack(spacing: 16) { + DownloadStyleButton( + style: .progress, + isSelected: selectedDownloadIndicatorStyle == .progress, + disabled: !Defaults[.enableDownloadListener] + ) { + selectedDownloadIndicatorStyle = .progress + } + + DownloadStyleButton( + style: .circle, + isSelected: selectedDownloadIndicatorStyle == .circle, + disabled: !Defaults[.enableDownloadListener] + ) { + selectedDownloadIndicatorStyle = .circle + } + } } .settingsHighlight(id: highlightID("Download indicator style")) - Picker("Download icon style", selection: $selectedDownloadIconStyle) { - Text("Only app icon") - .tag(DownloadIconStyle.onlyAppIcon) - Text("Only download icon") - .tag(DownloadIconStyle.onlyIcon) - Text("Both") - .tag(DownloadIconStyle.iconAndAppIcon) - } - .settingsHighlight(id: highlightID("Download icon style")) - } header: { - HStack { - Text("Download indicators") - comingSoonTag() - } - } - Section { - List { - ForEach([].indices, id: \.self) { index in - Text("\(index)") - } - } - .frame(minHeight: 96) - .overlay { - if true { - Text("No excluded apps") - .foregroundStyle(Color(.secondaryLabelColor)) - } - } - .actionBar(padding: 0) { - Group { - Button {} label: { - Image(systemName: "plus") - .frame(width: 25, height: 16, alignment: .center) - .contentShape(Rectangle()) - .foregroundStyle(.secondary) - } - - Divider() - Button {} label: { - Image(systemName: "minus") - .frame(width: 20, height: 16, alignment: .center) - .contentShape(Rectangle()) - .foregroundStyle(.secondary) - } - } - } - } header: { - HStack(spacing: 4) { - Text("Exclude apps") - comingSoonTag() - } + Text("Download Detection") + } footer: { + Text("Monitor your Downloads folder for Chromium-style downloads (.crdownload files) and show a live activity in the Dynamic Island while downloads are in progress.") } } .navigationTitle("Downloads") - .disabled(true) - .opacity(0.6) + } + + struct DownloadStyleButton: View { + let style: DownloadIndicatorStyle + let isSelected: Bool + let disabled: Bool + let action: () -> Void + + @State private var isHovering = false + + var body: some View { + VStack(spacing: 8) { + ZStack { + RoundedRectangle(cornerRadius: 12) + .fill(backgroundColor) + .overlay( + RoundedRectangle(cornerRadius: 12) + .strokeBorder(borderColor, lineWidth: isSelected ? 2 : 1) + ) + + if style == .progress { + ProgressView() + .progressViewStyle(.linear) + .tint(.accentColor) + .frame(width: 40) + } else { + SpinningCircleDownloadView() + } + } + .frame(width: 80, height: 60) + .onHover { hovering in + if !disabled { + withAnimation(.easeInOut(duration: 0.2)) { + isHovering = hovering + } + } + } + + Text(style.rawValue) + .font(.caption) + .fontWeight(.medium) + .lineLimit(2) + .multilineTextAlignment(.center) + .frame(width: 100) + .foregroundStyle(disabled ? .secondary : .primary) + } + .contentShape(Rectangle()) + .onTapGesture { + if !disabled { + action() + } + } + .opacity(disabled ? 0.5 : 1.0) + } + + private var backgroundColor: Color { + if disabled { return Color(nsColor: .controlBackgroundColor) } + if isSelected { return Color.accentColor.opacity(0.1) } + if isHovering { return Color.primary.opacity(0.05) } + return Color(nsColor: .controlBackgroundColor) + } + + private var borderColor: Color { + if isSelected { return Color.accentColor } + if isHovering { return Color.primary.opacity(0.1) } + return Color.clear + } } } diff --git a/DynamicIsland/enums/generic.swift b/DynamicIsland/enums/generic.swift index a4b5844..cbcaa27 100644 --- a/DynamicIsland/enums/generic.swift +++ b/DynamicIsland/enums/generic.swift @@ -46,6 +46,7 @@ enum SettingsEnum { enum DownloadIndicatorStyle: String, Defaults.Serializable { case progress = "Progress" case percentage = "Percentage" + case circle = "Circle" } enum DownloadIconStyle: String, Defaults.Serializable { diff --git a/DynamicIsland/managers/DownloadManager.swift b/DynamicIsland/managers/DownloadManager.swift new file mode 100644 index 0000000..4dc1172 --- /dev/null +++ b/DynamicIsland/managers/DownloadManager.swift @@ -0,0 +1,281 @@ +import Foundation +import Defaults +import SwiftUI + +@MainActor +class DownloadManager: ObservableObject { + static let shared = DownloadManager() + + @Published private(set) var isDownloading: Bool = false + @Published private(set) var isDownloadCompleted: Bool = false + + private let coordinator = DynamicIslandViewCoordinator.shared + private var source: DispatchSourceFileSystemObject? + private let queue = DispatchQueue(label: "com.dynamicisland.downloads.monitor", qos: .utility) + private var completionTimer: Timer? + private var hasPerformedInitialScan: Bool = false + private var initialCrDownloadFiles: Set = [] + private var previousAllFiles: Set = [] + + private var ignoredFiles: Set = [] + + private var downloadsDirectory: URL? { + FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first + } + + private init() { + requestDownloadsPermissionIfNeeded() + startMonitoringIfNeeded() + + Defaults.publisher(.enableDownloadListener) + .sink { [weak self] change in + guard let self else { return } + Task { @MainActor in + self.startMonitoringIfNeeded() + } + } + } + + private func startMonitoringIfNeeded() { + if Defaults[.enableDownloadListener] { + startMonitoring() + } else { + stopMonitoring() + updateDownloadingState(isActive: false) + } + } + + private func startMonitoring() { + guard source == nil, let downloadsDirectory else { return } + + hasPerformedInitialScan = false + initialCrDownloadFiles.removeAll() + previousAllFiles.removeAll() + ignoredFiles.removeAll() + isDownloading = false + + let path = downloadsDirectory.path + let fd = open(path, O_EVTONLY) + guard fd >= 0 else { + print("DownloadManager: Failed to open Downloads directory at \(path)") + return + } + + let src = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: fd, + eventMask: [.write, .rename, .delete, .attrib], + queue: queue + ) + + src.setEventHandler { [weak self] in + self?.scanDownloadsDirectory() + } + + src.setCancelHandler { + close(fd) + } + + source = src + src.resume() + + // Initial scan + scanDownloadsDirectory() + + print("DownloadManager: Started monitoring Downloads folder at \(path)") + } + + private func stopMonitoring() { + source?.cancel() + source = nil + hasPerformedInitialScan = false + initialCrDownloadFiles.removeAll() + previousAllFiles.removeAll() + ignoredFiles.removeAll() + isDownloading = false + print("DownloadManager: Stopped monitoring Downloads folder") + } + + private func scanDownloadsDirectory() { + guard let downloadsDirectory else { return } + + let crDownloadFiles: Set + let allFiles: Set + do { + let contents = try FileManager.default.contentsOfDirectory( + at: downloadsDirectory, + includingPropertiesForKeys: [.creationDateKey], + options: [.skipsHiddenFiles] + ) + crDownloadFiles = Set(contents + .filter { + let ext = $0.pathExtension.lowercased() + return ext == "crdownload" || ext == "download" + } + .map { $0.lastPathComponent } + ) + allFiles = Set(contents.map { $0.lastPathComponent }) + } catch { + print("DownloadManager: Failed to read Downloads directory: \(error)") + crDownloadFiles = [] + allFiles = [] + } + + Task { @MainActor in + self.processDownloadFiles(crDownloadFiles, allFiles: allFiles) + } + } + + private func processDownloadFiles(_ crDownloadFiles: Set, allFiles: Set) { + // Initial scan - track and ignore existing files + if !hasPerformedInitialScan { + hasPerformedInitialScan = true + initialCrDownloadFiles = crDownloadFiles + previousAllFiles = allFiles + ignoredFiles = crDownloadFiles + isDownloading = false + print("DownloadManager: initial scan - found \(crDownloadFiles.count) existing files, ignoring") + return + } + + // Calculate differences + let newFiles = crDownloadFiles.subtracting(initialCrDownloadFiles) + let disappearedFiles = initialCrDownloadFiles.subtracting(crDownloadFiles) + let newRegularFiles = allFiles.subtracting(previousAllFiles).subtracting(crDownloadFiles) + + // Log disappeared files + if !disappearedFiles.isEmpty { + print("DownloadManager: files disappeared: \(disappearedFiles.joined(separator: ", "))") + ignoredFiles.subtract(disappearedFiles) + } + + // Log new regular files (potential completed downloads) + if !newRegularFiles.isEmpty { + print("DownloadManager: new regular files appeared: \(newRegularFiles.joined(separator: ", "))") + } + + // Update tracked files BEFORE calculating active files + initialCrDownloadFiles = crDownloadFiles + previousAllFiles = allFiles + + // Calculate active downloads + let activeFiles = crDownloadFiles.subtracting(ignoredFiles) + let hasActiveDownloads = !activeFiles.isEmpty + + + // Handle new downloads + if !newFiles.isEmpty { + let newActiveFiles = newFiles.subtracting(ignoredFiles) + if !newActiveFiles.isEmpty { + print("DownloadManager: new download detected: \(newActiveFiles.joined(separator: ", "))") + + if !isDownloading { + updateDownloadingState(isActive: true) + } + } + } + + // Handle state changes based on active downloads + if isDownloading { + if !hasActiveDownloads { + // No more active downloads + // Check if new regular files appeared (indicating successful completion) + if !newRegularFiles.isEmpty || disappearedFiles.isEmpty { + // Download completed successfully - show completion state + if !isDownloadCompleted { + print("DownloadManager: download completed successfully - showing completion") + updateDownloadingState(isActive: false) + } + } else { + // Download files disappeared but no new regular files appeared - cancelled + print("DownloadManager: download cancelled - closing immediately") + closeDownloadViewImmediately() + } + } else if !disappearedFiles.isEmpty { + // Some downloads finished but others remain + print("DownloadManager: some finished, \(activeFiles.count) still active") + } + } else if hasActiveDownloads { + // We're not showing UI but we have active downloads - this shouldn't happen + // but let's handle it gracefully + print("DownloadManager: active downloads found but UI closed - reopening") + updateDownloadingState(isActive: true) + } + } + + private func requestDownloadsPermissionIfNeeded() { + guard let downloadsDirectory else { return } + + _ = try? FileManager.default.contentsOfDirectory( + at: downloadsDirectory, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) + } + + private func updateDownloadingState(isActive: Bool) { + completionTimer?.invalidate() + completionTimer = nil + + if isActive { + isDownloadCompleted = false + + if !isDownloading { + withAnimation(.smooth) { + isDownloading = true + } + print("DownloadManager: download started") + coordinator.toggleExpandingView( + status: true, + type: .download, + value: 0, + browser: .chromium + ) + } + } else { + if isDownloading { + print("DownloadManager: download finished - showing completion") + withAnimation(.smooth) { + isDownloadCompleted = true + } + + // Close after 2 seconds + completionTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in + Task { @MainActor in + self?.closeDownloadView() + } + } + } + } + } + + private func closeDownloadView() { + withAnimation(.smooth) { + isDownloading = false + isDownloadCompleted = false + } + print("DownloadManager: closing download view") + coordinator.toggleExpandingView( + status: false, + type: .download, + value: 0, + browser: .chromium + ) + } + + private func closeDownloadViewImmediately() { + completionTimer?.invalidate() + completionTimer = nil + + withAnimation(.smooth) { + isDownloading = false + isDownloadCompleted = false + } + print("DownloadManager: closing download view immediately (cancelled)") + coordinator.toggleExpandingView( + status: false, + type: .download, + value: 0, + browser: .chromium + ) + } +}