import AppKit import Foundation /// macOS 파일 패널 + 네이티브 다운로드 헬퍼. AppKit(NSOpenPanel/NSSavePanel) 의존이라 AppFeature /// (맥OS UI 계층)에 둔다 — DSKit 은 크로스플랫폼 유지(향후 iOS/watchOS). 모두 @MainActor. @MainActor enum FilePanels { /// 업로드할 파일 1개 선택. 취소 시 nil. static func pickFileToUpload() -> URL? { let panel = NSOpenPanel() panel.allowsMultipleSelection = false panel.canChooseDirectories = false panel.canChooseFiles = true panel.message = "업로드할 문서를 선택하세요" panel.prompt = "업로드" return panel.runModal() == .OK ? panel.url : nil } /// 저장 위치 선택. 취소 시 nil. 사용자가 고른 위치 = 샌드박스 쓰기 권한 부여(files.user-selected). static func pickSaveDestination(suggestedName: String) -> URL? { let panel = NSSavePanel() panel.nameFieldStringValue = suggestedName panel.message = "원본 파일을 저장할 위치" panel.prompt = "저장" return panel.runModal() == .OK ? panel.url : nil } } /// 원본 파일 네이티브 다운로드. 인증은 URL 쿼리의 ?token= 으로만 이뤄지므로(헤더 아님), 토큰이 든 /// URL 은 절대 로깅/에러 메시지에 노출하지 않는다. 저장 위치는 사용자가 NSSavePanel 로 선택. @MainActor enum FileDownloader { enum Outcome: Equatable { case saved(URL) case cancelled case failed(String) } /// `url` = DSDownload.fileURL 로 만든 ?token= 인증 URL. `suggestedName` = 원본 파일명. static func download(from url: URL, suggestedName: String) async -> Outcome { guard let dest = FilePanels.pickSaveDestination(suggestedName: suggestedName) else { return .cancelled } do { let (temp, response) = try await URLSession.shared.download(from: url) // 다운로드된 임시 파일은 호출자 책임(async download 변형은 자동삭제 안 함) — 모든 종료 // 경로에서 정리. 성공 시 move 가 temp 를 옮긴 뒤라 removeItem 은 무해한 no-op. defer { try? FileManager.default.removeItem(at: temp) } if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { // 상태 코드만 노출 — URL/토큰은 절대 포함하지 않는다. return .failed("다운로드 실패 (HTTP \(http.statusCode))") } if FileManager.default.fileExists(atPath: dest.path) { try FileManager.default.removeItem(at: dest) } try FileManager.default.moveItem(at: temp, to: dest) return .saved(dest) } catch { // URLError/파일 오류의 localizedDescription 엔 URL 이 포함되지 않는다. return .failed("저장 실패: \((error as NSError).localizedDescription)") } } }