React의 동작과 Fiber에 대하여 간략하게 알아봅니다.
WKWebView의 웹 페이지에서 콘텍스트 메뉴의 이미지 다운로드 기능 구현하기(MacOS)
2024-04-18
Explanation
오늘은 WKWebView의 웹 페이지의 이미지의 콘텍스트 메뉴에서 이미지 다운로드 기능을 구현하는 방법에 대하여 적어보려합니다!
바로 요부분 입니다.
생각보다 WKWebView의에서 많은 기능들을 기본적으로 지원해주고 있는데요. 종종 직접 구현해야하는 기능들이 있는데, 정말 레퍼런스 찾기가 쉽지 않더라고요.. 이것도 구현하는데 하루가 넘게 걸렸답니다..
간단한 기능 구현에 하루가 넘게 걸린 것이 조금 민망하지만..
그래도 요리조리 온몸 비틀어서 구현한 거 자랑도 하고 (이게 99%),
분명! 같은 구현으로 고생하고 있는 분들도 있을 거 같아서 포스팅으로 남깁니다!
아직 공부를 한지 몇개월 되지 않아서 글에 잘못된 부분이 있을 수 있습니다!
그리고 작성된 모든 코드는 https://github.com/falsy/blog-post-example/tree/master/macOS-project/WebViewImgDownload에서 확인하실 수 있습니다.
더 좋은 방법이 있다면, 댓글로 남겨주시면 감사할 것 같습니다!
우선, 프로젝트를 생성한 후에!
WKWebView에서 웹 페이지를 불러와야 하니까, ‘TARGETS’ > ‘Signing & Capabilities’ > ‘App Sandbox’ 에서 ‘Network’의 ‘Server’, ‘Client’ 모두 체크해 줍니다. 그리고 파일도 로컬에 저장해야 하기 때문에 ‘File Aceess’의 ‘User Selected File’ 타입을 ‘Read/Write’ 로 변경해 줍니다.
그리고 SwiftUI를 베이스로 할 것이기 때문에 WKWebView를 NSViewRepresentable로 선언해 줍니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
import SwiftUI import WebKit struct ExWebview: NSViewRepresentable { func makeCoordinator() -> Coordinator { Coordinator(self) } class Coordinator: NSObject, WKUIDelegate { var parent: ExWebview init(_ parent: ExWebview) { self.parent = parent } } func makeNSView(context: Context) -> WKWebView { let config = WKWebViewConfiguration() let prefs = WKWebpagePreferences() prefs.allowsContentJavaScript = true config.defaultWebpagePreferences = prefs let webview = WKWebView(frame: .zero, configuration: config) webview.uiDelegate = context.coordinator webview.load(URLRequest(url: URL(string: "https://falsy.me")!)) return webview } func updateNSView(_ webView: WKWebView, context: Context) { } } |
다음으로 콘텍스트 메뉴의 설정을 수정하기 위해서 커스텀 WKWebView를 만들어줍니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
import SwiftUI import WebKit enum ContextualMenuAction { case downloadImage } class ExWKWebView: WKWebView { var contextualMenuAction: ContextualMenuAction? var openImageNewWindowMenuItem: NSMenuItem? override func willOpenMenu(_ menu: NSMenu, with event: NSEvent) { super.willOpenMenu(menu, with: event) if let openNewWindowImage = menu.items.first(where: { $0.identifier?.rawValue ?? "" == "WKMenuItemIdentifierOpenImageInNewWindow" }) { self.openImageNewWindowMenuItem = openNewWindowImage } for menuItem in menu.items { if menuItem.identifier?.rawValue ?? "" == "WKMenuItemIdentifierDownloadImage" { menuItem.action = #selector(self.menuClick(_:)) menuItem.target = self } if menuItem.identifier?.rawValue ?? "" == "WKMenuItemIdentifierDownloadLinkedFile" { menuItem.isHidden = true } } } override func didCloseMenu(_ menu: NSMenu, with event: NSEvent?) { super.didCloseMenu(menu, with: event) DispatchQueue.main.asyncAfter(deadline: .now() + 1) { self.contextualMenuAction = nil self.openImageNewWindowMenuItem = nil } } @objc func menuClick(_ menuItem: NSMenuItem) { self.contextualMenuAction = .downloadImage if let openImageNewWindowMenuItem = self.openImageNewWindowMenuItem, let action = openImageNewWindowMenuItem.action { NSApp.sendAction(action, to: openImageNewWindowMenuItem.target, from: openImageNewWindowMenuItem) } } } |
WKWebView 에서는 콘텍스트가 열릴때 호출되는 willOpenMenu 메서드와 닫힐때 호출되는 didCloseMenu가 있답니다.
여기서 메뉴의 “WKMenuItemIdentifierDownloadLinkedFile”(다른 이름으로 링크 저장)을 저는 isHidden로 숨겼고요, “WKMenuItemIdentifierDownloadImage”(이미지 다운로드) 메뉴에, 추가한 menuClick 메서드로 action을 등록해 주었습니다.
이렇게 하면, ‘이미지 다운로드’ 메뉴를 선택했을때 ‘menuClick’ 메서드가 실행되니까 ‘menuClick’ 메서드 안에서 파일을 저장하는 패널(NSSavePanel)을 호출해서 저장하면 되겠다 생각했는데, 맙소사.. NSMenuItem 에서는 해당 이미지의 url 정보를 알 수가 없는 것 같더라고요.
아무리 검색을 해도 마땅한 레퍼런스가 없고, 우리의 친구 ChatGPT에게 조언을 구했더니.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
var imageSrcURL: URL? override func willOpenMenu(_ menu: NSMenu, with event: NSEvent) { super.willOpenMenu(menu, with: event) let js = """ (function() { var x = \(event.locationInWindow.x), y = \(event.locationInWindow.y); var element = document.elementFromPoint(x, y); while (element && element.tagName !== 'IMG') { element = element.parentElement; } return element ? element.src : ''; })() """ self.evaluateJavaScript(js) { (result, error) in if let src = result as? String, let url = URL(string: src) { self.imageSrcURL = url for menuItem in menu.items { if menuItem.identifier?.rawValue == "WKMenuItemIdentifierDownloadImage" { menuItem.action = #selector(self.menuClick(sender:)) menuItem.target = self } } } } } |
이렇게 evaluateJavaScript을 써서 위치값으로 해당 이미지의 src 값을 구하라고 하더라고요.
근데 이건 딱 봐도 너무 별로더라고요, 동작도 너무 제한적이고.
그래서 고민고민하다 생각해낸 방법이 바로!
콘텍스트 메뉴중에 ‘WKMenuItemIdentifierOpenImageInNewWindow'(새 탭에서 이미지 열기) 메뉴는 선택하면 새로운 탭으로 해당 이미지의 URL로 호출하더라고요.
1 2 3 4 5 6 7 8 9 10 11 12 |
... class ExWKWebView: WKWebView { var contextualMenuAction: ContextualMenuAction? var openImageNewWindowMenuItem: NSMenuItem? override func willOpenMenu(_ menu: NSMenu, with event: NSEvent) { super.willOpenMenu(menu, with: event) if let openNewWindowImage = menu.items.first(where: { $0.identifier?.rawValue ?? "" == "WKMenuItemIdentifierOpenImageInNewWindow" }) { self.openImageNewWindowMenuItem = openNewWindowImage } ... |
그래서 위 코드처럼, ‘willOpenMenu’가 호출될때 ‘WKMenuItemIdentifierOpenImageInNewWindow'(새 탭에서 이미지 열기) 메뉴를 캐시해주었어요.
1 2 3 4 5 6 7 8 |
... @objc func menuClick(_ menuItem: NSMenuItem) { self.contextualMenuAction = .downloadImage if let openImageNewWindowMenuItem = self.openImageNewWindowMenuItem, let action = openImageNewWindowMenuItem.action { NSApp.sendAction(action, to: openImageNewWindowMenuItem.target, from: openImageNewWindowMenuItem) } } ... |
그리고 위 코드처럼, ‘이미지 다운로드’ 메뉴를 눌렀을때를 구분하기 위해 ‘contextualMenuAction’ 라는 enum을 추가해주고, ‘이미지 다운로드’ 메뉴가 선택되면 contextualMenuAction에 ‘.downloadImage’ 값을 주고, 아까 캐시했던 ‘WKMenuItemIdentifierOpenImageInNewWindow'(새 탭에서 이미지 열기) 액션을 수행하도록 해주었답니다!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
... func makeNSView(context: Context) -> WKWebView { let config = WKWebViewConfiguration() let prefs = WKWebpagePreferences() prefs.allowsContentJavaScript = true config.defaultWebpagePreferences = prefs let webview = ExWKWebView(frame: .zero, configuration: config) webview.uiDelegate = context.coordinator webview.load(URLRequest(url: URL(string: "https://falsy.me")!)) return webview } func updateNSView(_ webView: WKWebView, context: Context) { } } |
‘makeNSView’에서 ‘let webview = WKWebView(frame: .zero, configuration: config)’ 였던 부분을,
새로 만든 ‘let webview = ExWKWebView(frame: .zero, configuration: config)’ 로 바꿔주고.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 |
... class Coordinator: NSObject, WKUIDelegate { var parent: ExWebview init(_ parent: ExWebview) { self.parent = parent } private func downloadImage(from url: URL, completion: @escaping (Data?, Error?) -> Void) { let task = URLSession.shared.dataTask(with: url) { data, response, error in completion(data, error) } task.resume() } func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? { if let customAction = (webView as? ExWKWebView)?.contextualMenuAction, let requestURL = navigationAction.request.url { if customAction == .downloadImage { downloadImage(from: requestURL) { data, error in guard let data = data, error == nil else { return } DispatchQueue.main.async { let savePanel = NSSavePanel() savePanel.allowedContentTypes = [.png, .jpeg, .bmp, .gif] savePanel.canCreateDirectories = true savePanel.isExtensionHidden = false savePanel.title = "Save As" savePanel.nameFieldLabel = NSLocalizedString("Save As:", comment: "") if requestURL.lastPathComponent != "" { savePanel.nameFieldStringValue = requestURL.lastPathComponent } if savePanel.runModal() == .OK, let url = savePanel.url { do { try data.write(to: url) print("Image saved to \(url)") } catch { print("Failed to save image: \(error)") } } } } return nil } } return nil } } ... |
‘WKUIDelegate’에서 ‘webView(_:createWebViewWith:for:windowFeatures:)’ 메서드는 새로운 웹뷰가 호출될 때 실행되는 메서드인데요. 아까 ExWKWebView에서 ‘새 탭으로 이미지 열기’가 이름 그대로 새 탭에서 호출을 시도하는 액션이기 때문에, 위 메서드가 호출이 되는데요, 여기서 조건문으로(customAction == .downloadImage) ‘이미지 다운로드’ 메뉴가 선택되어 호출되었을 때를 확인하여 ‘NSSavePanel’를 호출해서 이미지를 저장하도록 하였답니다.
짜잔!
이렇게 패널이 뜨고, 이미지가 지정한 경로에 저장되는 것을 확인할 수 있습니다!
참고.
* https://icab.de/blog/2022/06/12/customize-the-contextual-menu-of-wkwebview-on-macos/
* https://stackoverflow.com/questions/37858337/how-do-i-intercept-downloads-from-a-wkwebview
* https://swdevnotes.com/swift/2022/save-an-image-to-macos-file-system-with-swiftui/