티스토리 뷰

macOS/이것저것

[macOS] FinderSync

은조공주 2021. 9. 29. 15:45
반응형

OS X 에서는, FinderSync 익스텐션을 사용해서 깔끔하고 안전하게 파인더의 UI 를 변경해 파일의 동기화 상태와 컨트롤을 나타낼 수 있습니다.

다른 익스텐션들과는 달리 Finder Sync 는 앱에 기능을 더해주진 않습니다. 대신, 파인더 자체의 동작을 변경할 수 있게 해줍니다.

 

[ FinderSync Extension ]

FinderSync 익스텐션을 사용해서, 시스템이 모니터링할 폴더를 하나 이상 등록합니다. 그러면 FinderSync 익스텐션이 폴더의 아이템들에 대한 뱃지, 레이블 등 상황에 맞는 메뉴를 설정합니다.

익스텐션의 API 를 사용해서 파인더 윈도우에 툴바 버튼을 추가하거나, 폴더의 사이드바 아이콘을 추가할 수도 있습니다.

FinderSync 익스텐션을 사용해 파인더에서 아이템의 모양을 변경할 수 있습니다. 파일 동기화는 지원되지 않습니다. 앱의 고유한 동기화 컴포넌트를 만들어야 합니다.

 

FinderSync 익스텐션은 :

  • 모니터링할 폴더들을 등록합니다.
  • 유저가 해당 폴더의 내용 탐색을 시작하거나 중지할 때 알림을 수신합니다. 예를 들어, 유저가 파인더 혹은 열기/저장 창에서 해당 폴더를 열 때 알림을 수신합니다.
  • 모니터링 되는 폴더의 아이템들에 뱃지와 레이블들을 추가하고, 제거하고, 업데이트합니다.
  • 유저가 폴더 내의 아이템을 ctrl-클릭할 때 상황에 맞는 메뉴가 노출되도록 합니다.
  • 파인더의 툴바에 커스텀 버튼을 추가합니다. 뱃지나 우클릭 메뉴 항목과는 달리, 이 버튼은 유저가 모니터링되는 폴더를 탐색하고 있지 않은 경우에도 항상 사용할 수 있습니다.

FinderSync 익스텐션이, 앱에서 제공하려는 기능에 적합한지 확인하세요. FinderSync 익스텐션은 로컬 폴더의 내용을 원격의 데이터소스와 동기화하는 앱을 지원하는 데에 최적화 되어있습니다.

FinderSync 는 그저 파인더의 UI 를 바꾸기 위한 일반적인 수단은 아닙니다.

 

원문 -

https://developer.apple.com/library/archive/documentation/General/Conceptual/ExtensibilityPG/Finder.html

 

App Extension Programming Guide: Finder Sync

App Extension Programming Guide

developer.apple.com

 

 

-

 

FinderSync 테스트 프로젝트 개발기를 적어보려고 합니다.

너무 많은 시행착오를 겪고 헤맸기 때문에,,, 튜토리얼 느낌 말고 그냥 시도한 모든 것들을 적을 예정입니다,,,

프로젝트를 몇번이나 새로 만들었는지 모르겠네요......

 

1. Extension 추가

먼저 프로젝트를 생성합니다. 프로젝트를 생성하는 과정은 본 포스팅에서는 생략하겠습니다.

프로젝트를 생성하고, File > New > Target 메뉴를 통해 FinderSync Extension 을 추가합니다.

 

처음에는 Extension 이름을 FinderSync 라고 지어주었는데...

import FinderSync <- 이것때문에 계속 컴파일 에러가 나서 Extension 이름은 FinderTest-FinderSync 로 지어주었습니다.

이 과정을 거치면 프로젝트에 FinderSync 클래스 파일 등등이 추가되는 것을 확인하실 수 있습니다.

 

2. Target 세팅

( https://blog.codecentric.de/en/2018/09/finder-sync-extension/ 참고 )

좌측 프로젝트 내비게이터에서 프로젝트 파일을 선택하고,

FinderSync 타겟을 선택해 상단 메뉴에서 Signing&Capabilities 를 띄워줍니다.

 

상단의 + 버튼을 눌러 App Sandbox, App Groups 를 추가해줍니다.

(main app - 포스팅 기준 FinderTest target - 에도 App Groups 를 추가해줍니다.)

두 타겟 모두에 App Group 을 하나 추가하고, 동일한 identifier 를 넣습니다. 저는 [ group.com.eunjo.FinderTest ] 를 사용하였습니다.

FinderSync 타겟에 추가한 Sandbox 에서는, File Access > User Selected File > Permission & Access 를 None 으로 바꿔줍니다.

 

 

3. FinderSync 기본 세팅 & 빌드 확인

이 단계에서 거의 삼일은 버린것같네요,,

FinderSync Extension 자체를 어떻게 확인해야 하는지 잘 몰라서 며칠 내내 구글링하고 여러 가지 방법들을 시도해보았습니다......

 

(1) 먼저 Extension 이 활성화되어있는지 체크하는 코드를 넣어주었습니다.

private func checkIfFinderSyncEnabled() {
    DispatchQueue.main.async {
        if !FIFinderSyncController.isExtensionEnabled {
            // FinderSync Extension이 현재 활성화되어 있지 않은 경우
            let alert = NSAlert()
            alert.alertStyle = .warning
            alert.informativeText = "[Error]"
            alert.messageText = "FinderSync Extension Not Enabled"
            alert.addButton(withTitle: "open")
            alert.addButton(withTitle: "cancel")
            if alert.runModal() == .alertFirstButtonReturn {
                FIFinderSyncController.showExtensionManagementInterface()
                NSApplication.shared.terminate(self)
            }
        }
    }
}

AppDelegate 에 위 함수를 추가하고,

func applicationDidFinishLaunching(_ aNotification: Notification) {
    // Insert code here to initialize your application
    checkIfFinderSyncEnabled()
}

앱 런칭 직후 해당 함수를 호출하도록 하였습니다.

mac 의 시스템 환경설정 > 확장 프로그램 > Finder 확장 프로그램 을 확인해 프로젝트의 FinderSync 가 체크되어 있는지 확인합니다.

( 그리고 다른 FinderSync Extension 들과 충돌이 일어날 수 있다고 해서, 다른 앱들의 FinderSync Extension 은 체크 해제해주었습니다. 동시에 모두 켜져 있으면 여러 앱이 한 파일에 접근해 서로 파일 badge 를 바꾸려고 하게 되겠죠? 이 문단을 적으면서 시스템 설정에서 다시 다른 앱들도 체크해주고 FinderSync 를 빌드해보니 역시 잘 뜨던 badge 가 뜨지 않게 되네요.. 테스트를 위해서는 잠시 다른 앱들은 체크 해제해주는 것이 좋겠습니다. )

 

(2) 만약 FinderSync Extension 을 Swift 로 추가했다면,

@objc(FinderSync)
class FinderSync: FIFinderSync {

@objc attribute 를 추가해야 합니다.

 

(3) myFolderURL 을 적절한 위치로 설정해줍니다.

저는 테스트를 위해 일단 바탕화면 경로를 지정했습니다.

var myFolderURL = URL(fileURLWithPath: "/Users/eunjo/Desktop")

 

이 단계에서 한참을 헤맨 이유는.... 아무리 이 방법 저 방법 다 시도해봐도

- FinderSync 클래스가 init() 될 때 찍는 NSLog 문들이 콘솔에 하나도 찍히지 않았고

- 아무리 빌드해봐도 Finder 에서 시각적으로 달라진 점을 찾을 수 없었으며

- 터미널에서 [ $ tail -f /var/log/system.log ] 을 실행하고 익스텐션을 빌드해보면 "Service exited due to SIGKILL ~~" 라는 로그만 찍혔기

때문입니다....

 

혹시 몰라 objc 프로젝트를 새로 만들고 다시 세팅해서 빌드해보았는데요.. 파인더 툴바에 해당 프로젝트의 익스텐션이 뜨는 것을 확인하고 툴바 우클릭으로 도구모음 사용자화 를 들어가보니 테스트 프로젝트 익스텐션이 있었습니다 ㅠㅠㅠㅠ (이미지는 추후 변경한 후 캡쳐한 화면입니다. )

 

얼른 도구 막대로 드래그해주고,,,, 위에서 언급한 "다른 앱들 extension 꺼주기" 를 하니 지정한 경로인 desktop 에서 파일들에 붙어 있는 뱃지를 확인할 수 있었습니다.

 

(4) Extension 을 Debugger 에 추가해주기

FinderSync 클래스에서 찍는 모든 로그들이 콘솔에 찍히지 않는 이유와 관련이 있는지는 모르겠지만.. (<- 이유를 찾은 이후에 본 포스팅에 업데이트할 예정입니다...)

기본적으로는 Xcode 에서 FinderSync Extension 을 디버깅할 수 없는 상태입니다. 파인더에서 디렉토리 여기저기를 들어가봐도 FinderSync 에 걸린 breakpoint 는 하나도 멈추지 않습니다,,,

FinderSync Extension Target 을 빌드해놓은 상태에서 메뉴 Debug > Attach to Process > FinderSync Extension Target 을 선택합니다. 그 후 파인더에서 (옵저빙하는 경로로 이동해서 테스트해야합니다!!!!!) 아무 디렉토리나 들어가보면

 

걸어놓은 breakpoint 에서 잘 멈추는 것을 확인할 수 있습니다. 

( console 에서 NSLog 들을 확인할 수 있는 방법은 아무리 구글링을 해도 찾을 수 없는데,,, 혹시 아시는 분이 계신다면 댓글 부탁드립니다. ㅠ.ㅠ)

 

이 상태까지 잘 오셨다면,,

 

이런 화면을 보실 수 있을거에요. (툴바 이미지는 직접 지정한 이미지입니다. 아마 기본 이미지는 노란색 느낌표 이미지일 것입니다..)

 

4. Main App 과 communication 할 수 있도록 하기

일단 메인앱 ( FinderTest ) 과 FinderSync Extension 은 파일들이 서로 다른 Target Membership 에 속해있기 때문에 코드상에서 서로 접근할 수가 없습니다. 저는 Distribution Notification 을 사용해 통신을 할 수 있도록 해보겠습니다.

( https://developer.apple.com/documentation/foundation/distributednotificationcenter )

( https://blog.codecentric.de/en/2018/09/finder-sync-extension/ 참고 )

 

먼저, 메인앱에는 MainAppChannel, Extension 에는 FinderSyncChannel 이라는 클래스를 각각 만들어 보았습니다.

Combine 을 사용해서, 어떠한 UI 이벤트가 발생했을 때

ViewController ---- (input) ----> MainAppChannel ---- (noti) ----> FinderSyncChannel ---- (output) ----> FinderSync

의 구조로 통신할 수 있도록 구현해보려고 합니다.

( 이 구조로 구현하지 않으셔도 무방합니다. 그저 저에게 편한 구조일 뿐입니다..... )

 

(1) 옵저빙할 경로를 변경해보기

MainAppChannel 의 init() 부를 통해 다음 코드를 호출할 수 있도록 구현하였습니다.

userInfo 에는 원래 ["path": url] 의 형태로 URL 객체를 그대로 넣어주었는데.... 분명 noti 를 post 하는 코드까지는 확실히 타는데 FinderSync 에서 받지를 못해서ㅠ 또 오랫동안 구글링하며 찾아본 결과... 객체를 그대로 넣으면 안된다고 하네요.... 그래서 url.absoluteString 프로퍼티를 대신 전달해주었습니다. ( String 타입 )

class MainAppChannel: MainAppChannelType, MainAppChannelInput {
    
    // MARK: - Input
    
    var input: MainAppChannelInput { return self }
    
    // Input
    var observingPathSelected: PassthroughSubject<URL?, Never> = PassthroughSubject()

    // MARK: - Properties
    
    var subscriptions = Set<AnyCancellable>()
    private var notificationCenter = DistributedNotificationCenter.default()

    // MARK: - Init
    
    init() {
        
        input.observingPathSelected
            .sink(receiveValue: { [weak self] url in
                
                guard let `self` = self else { return }
                guard let url = url else { return }

                self.notificationCenter.postNotificationName(NSNotification.Name.init("ObservingPathSetNotification"),
                                                        object: Bundle.main.bundleIdentifier,
                                                        userInfo: ["path": url.absoluteString],
                                                        deliverImmediately: true)
            }).store(in: &subscriptions)
    }
}

ViewController 에서 이벤트 발생 시 ( 저는 경로를 지정하고 설정 버튼을 누르면 노티를 발송하도록 구현했는데, viewDidLoad 에서 바로 호출해도 되고 원하시는대로 구현하심 됩니다. ) MainAppChannel 에 해당 이벤트를 흘려보내줄 수 있도록 합니다.

( ViewController 가

var channel = MainAppChannel()

의 형태로 채널을 들고 있도록 구현했습니다.)

@IBAction func pathControlSetButtonTapped(_ sender: Any) {
    guard let path = pathControl.url else { return }

    NSWorkspace.shared.open(path) // 지정한 경로 열어주기
    channel.input.observingPathSelected.send(path)
}

FinderSyncChannel 에서는 noti 를 옵저빙할 수 있도록 해줍니다.

class FinderSyncChannel: FinderSyncChannelType, FinderSyncChannelOutput {

    // MARK: - Input
    
    var output: FinderSyncChannelOutput { return self }
    
    // Output
    var observingPathPublisher: AnyPublisher<URL?, Never> {
        return observingPathSubject.eraseToAnyPublisher()
    }
    
    // MARK: - Properties
    
    var subscriptions = Set<AnyCancellable>()
    private var notificationCenter = DistributedNotificationCenter.default()
    
    // MARK: - Subjects
    
    private var observingPathSubject = PassthroughSubject<URL?, Never>()
    
    // MARK: - Init
    
    init() {
        registerObserver()
    }
    
    private var mainAppBundleID: String {
        guard var bundleID = Bundle.main.bundleIdentifier else { return String() }
        var bundleComponents = bundleID.components(separatedBy: ".")
        bundleComponents.removeLast()
        bundleID = bundleComponents.joined(separator: ".")
        return bundleID
    }
    
    private func registerObserver() {
        let observedObject = self.mainAppBundleID
        notificationCenter.addObserver(self,
                                       selector: #selector(observingPathSet(noti:)),
                                       name: NSNotification.Name.init("ObservingPathSetNotification"),
                                       object: observedObject)
    }
    
    @objc private func observingPathSet(noti: NSNotification) {
        
        guard let path = noti.userInfo?["path"] as? String else { return }
        observingPathSubject.send(URL(fileURLWithPath: path))
    }
}

이런식으로 구현을 해주고... FinderTest, FinderTest-FinderSync 두 타겟을 모두 빌드해주면

 

경로를 지정하고 설정 버튼을 눌렀을 때 해당 디렉토리가 열리고, 설정한 경로를 FinderSync 가 옵저빙하고 있는 것을 알 수 있습니다.

파일들에 붙어있는 badge들은 FinderSync Extension 을 추가하면 생기는 FinderSync 클래스에 기본으로 구현되어 있는 내용입니다.

 

(2) 옵저빙을 해제해보기

(1) 의 내용과 동일한 방식으로,  [해제] 버튼을 눌렀을 때 옵저빙을 중단하도록 구현했습니다.

간단하게 directoryURLs 를 비워주기만 했습니다.

channel.output.observingPathResetPublisher
        .sink(receiveValue: {

            FIFinderSyncController.default().directoryURLs = []

        }).store(in: &subscriptions)

설정 버튼을 눌렀을 때!!!!!!!!!
해제 버튼을 눌렀을 때!!!!!!!!!!1

(3) 확장자별로 뱃지를 붙여보기

MainAppChannel, FinderSyncChannel, ViewController 에서는 기본적으로 위와 유사한 방식으로 구현해 주었습니다.

1. ViewController

(hexString 은 별도로 NSColor 에 구현한 Extension 입니다. Distribution notification 의 userInfo 에 들어갈 값을 String 타입으로 통일해주기 위해 따로 구현하였습니다.)

  // MARK: - Color Well

  @IBAction func colorSetJpgButtonTapped(_ sender: Any) {
      channel.input.setBadge.send(("jpg", colorWellJpg.color.hexString))
  }

  @IBAction func colorSetPngButtonTapped(_ sender: Any) {
      channel.input.setBadge.send(("png", colorWellPng.color.hexString))
  }

  @IBAction func colorSetDirButtonTapped(_ sender: Any) {
      channel.input.setBadge.send(("dir", colorWellDir.color.hexString))
  }

  @IBAction func colorSetPdfButtonTapped(_ sender: Any) {
      channel.input.setBadge.send(("pdf", colorWellPdf.color.hexString))
  }

2. MainAppChannel

확장자와 컬러값을 실어 notification 을 발송해줍니다.

input.setBadge
    .sink(receiveValue: { [weak self] (ext, colorString) in

        guard let `self` = self else { return }

        self.notificationCenter.postNotificationName(NSNotification.Name.init("SetBadgeColorNotification"),
                                                object: Bundle.main.bundleIdentifier,
                                                userInfo: ["extension": ext,
                                                           "color": colorString],
                                                deliverImmediately: true)
    }).store(in: &subscriptions)

3. FinderSyncChannel

옵저버를 등록해주고...

notificationCenter.addObserver(self,
                               selector: #selector(setBadge(noti:)),
                               name: NSNotification.Name.init("SetBadgeColorNotification"),
                               object: observedObject)

color string 기준으로 NSImage 를 생성하고 corner를 깎아주었습니다. 둥글게 보이도록...

@objc private func setBadge(noti: NSNotification) {
        
    guard let extensionString = noti.userInfo?["extension"] as? String else { return }
    guard let colorString = noti.userInfo?["color"] as? String else { return }

    setBadgeSubject.send((extensionString, NSImage(color: NSColor(hex: colorString, alpha: 1)).roundCorners(), currentObservingPath))
}

4. FinderSync

FinderSync 클래스의 init 메소드를 보시면, 기본적으로 아래 코드가 있어요. 요 두줄을 일단 주석처리 해줍니다.

// Set up images for our badge identifiers. For demonstration purposes, this uses off-the-shelf images.
FIFinderSyncController.default().setBadgeImage(NSImage(named: NSImage.colorPanelName)!, label: "Status One" , forBadgeIdentifier: "One")
FIFinderSyncController.default().setBadgeImage(NSImage(named: NSImage.cautionName)!, label: "Status Two", forBadgeIdentifier: "Two")

그리고 초기값으로 지정할,, 대충 아무 이미지들이나 지정하는,,, 다음 코드를 넣었습니다.

FIFinderSyncController.default().setBadgeImage(NSImage(named: NSImage.colorPanelName)!, label: "jpg file" , forBadgeIdentifier: "jpg")
FIFinderSyncController.default().setBadgeImage(NSImage(named: NSImage.bonjourName)!, label: "png file" , forBadgeIdentifier: "png")
FIFinderSyncController.default().setBadgeImage(NSImage(named: NSImage.iChatTheaterTemplateName)!, label: "directory" , forBadgeIdentifier: "dir")
FIFinderSyncController.default().setBadgeImage(NSImage(named: NSImage.folderSmartName)!, label: "pdf file" , forBadgeIdentifier: "pdf")

아래 메소드에서도 기존에 들어있던 코드는 주석처리 해주고, 확장자별로 badge 를 가질 수 있도록 구현했습니다.

override func requestBadgeIdentifier(for url: URL) {
    NSLog("requestBadgeIdentifierForURL: %@", url.path as NSString)

    // For demonstration purposes, this picks one of our two badges, or no badge at all, based on the filename.
//        let whichBadge = abs(url.path.hash) % 3
//        let badgeIdentifier = ["", "One", "Two"][whichBadge]
//        FIFinderSyncController.default().setBadgeIdentifier(badgeIdentifier, for: url)

    let ext = url.pathExtension
    switch ext {
    case "jpg":
        FIFinderSyncController.default().setBadgeIdentifier("jpg", for: url)
    case "png":
        FIFinderSyncController.default().setBadgeIdentifier("png", for: url)
    case "pdf":
        FIFinderSyncController.default().setBadgeIdentifier("pdf", for: url)
    case "":
        FIFinderSyncController.default().setBadgeIdentifier("dir", for: url)
    default:
        break
    }
}

앱과 익스텐션을 다시 빌드하고 테스트 경로를 설정하면, 위와 같이 나타나는 것을 볼 수 있습니다 ㅎㅎ

FinderSync init 메소드 하단에 다음 코드를 추가하였습니다. 컬러피커 우측의 설정 버튼을 누르면 실행되겠죠?

channel.output.setBadgePublisher
    .sink(receiveValue: { [weak self] (ext, colorImage, currentPath) in

        guard let `self` = self else { return }
        self.setBadgeImage(ext: ext, image: colorImage, path: currentPath)

    }).store(in: &subscriptions)
private func setBadgeImage(ext: String, image: NSImage, path: String) {
    let label = ext == "dir" ? "\(ext) file" : "directory"
    FIFinderSyncController.default().setBadgeImage(image, label: label , forBadgeIdentifier: ext)
}

다시 빌드를 하고.... 테스트 경로 지정, 색상 설정, 파인더 윈도우 닫았다 열기 를 하면

 

갹 성공입니다~~~!!! ^ㅇ^

 

(4) Custom Context Menu 구현해보기

FinderSync 클래스에 기본적으로 구현되어있는 다음 변수들과 메소드 구현부를 수정해주었습니다.

아이맥 아이콘이 뭔가 간지나서 NSImage.computerName 을 사용하였습니다.

// MARK: - Menu and toolbar item support

override var toolbarItemName: String {
    return "FinderTest-FinderSync"
}

override var toolbarItemToolTip: String {
    return "FinderTest-FinderSync: Click the toolbar item for a menu."
}

override var toolbarItemImage: NSImage {
    return NSImage(named: NSImage.computerName)!
}

override func menu(for menuKind: FIMenuKind) -> NSMenu {
    // Produce a menu for the extension.
    let menu = NSMenu(title: "[Eunjo]")
    menu.addItem(withTitle: "[Eunjo] Test Menu 1", action: #selector(testAction1(_:)), keyEquivalent: "")
    menu.addItem(withTitle: "[Eunjo] Test Menu 2", action: #selector(testAction2(_:)), keyEquivalent: "")
    menu.addItem(withTitle: "[Eunjo] Test Menu 3", action: #selector(testAction3(_:)), keyEquivalent: "")
    return menu
}

testAction 에는 모두 모달을 띄우도록 구현해주었습니다.

 

이 메뉴들은 각 파일 우클릭을 통해서도 접근 가능합니다. 캡쳐는 생략했지만, 모달도 잘 노출되네용.

 

 

지금까지 저의 FinderSync 테스트 프로젝트 개발기를 보셨는데요.. 이 글을 읽으신 분들께 조금이라도 도움이 되셨다면 좋겠네요.

( 사이드바에 즐겨찾기 추가 -> 는 초반에 계획한 기능이어서 UI 는 추가해놨으나 구현하려고 찾아보니 LSSharedFileListFavoriteItems 가 macOS 10.11 에 deprecate 되었다고 하네요... UI 없애기 귀찮아서 그냥 두었습니다.... )

반응형
댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/05   »
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
글 보관함