Other Settings

Mac 폴더에서 우클릭으로 커스텀 버튼 만들기

Bull_ 2026. 8. 9. 13:42

왜 Finder Sync Extension인가

Automator Quick Action이나 Shortcuts로는 빈 공간 우클릭 메뉴를 만들 수 없다. Finder의 컨텍스트 메뉴는 선택된 항목의 타입과 매칭되는 서비스만 표시하는데, 빈 공간은 선택 항목이 없으므로 후보에서 아예 제외된다. 입력을 files and folders로 바꾸면 메뉴가 뜨지만, 그건 파일을 선택했을 때만이다.

빈 공간 컨텍스트 메뉴에 항목을 추가하는 공개 API는 FIFinderSync의 .contextualMenuForContainer 하나뿐이다.

만들기

Xcode → Create New Project

Team: 서명 인증서 선택 (개발자 계정 필요)

상단 메뉴 File → New → Target

macOS → Finder Sync Extension

Product Name: NewNoteExtension

코드

NewNoteFinderApp.swift

//
//  NewNoteFinderApp.swift
//  NewNoteFinder
//
//  Created by Goose on 8/9/26.
//
import SwiftUI
@main
struct NewNoteFinderApp: App {
    var body: some Scene {
        WindowGroup {
            VStack(spacing: 12) {
                Text("NewNoteFinder")
                    .font(.title2)
                Text("Enable the Finder extension in\nSystem Settings > General > Login Items & Extensions.")
                    .multilineTextAlignment(.center)
                    .foregroundStyle(.secondary)
            }
            .padding(40)
            .frame(width: 420)
        }
    }
}

FinderSync.swift

// FinderSync.swift
import Cocoa
import FinderSync
import os

private let log = Logger(subsystem: "bu11.NewNoteFinder", category: "ext")

class FinderSync: FIFinderSync {

    override init() {
        super.init()
        // 여기 등록된 경로 하위에서만 메뉴가 뜬다
        FIFinderSyncController.default().directoryURLs = [
            URL(fileURLWithPath: NSHomeDirectory()),
            URL(fileURLWithPath: "/Volumes"),
            URL(fileURLWithPath: "/")
        ]
    }

    override func menu(for menuKind: FIMenuKind) -> NSMenu? {
        // 빈 공간 우클릭 = contextualMenuForContainer
        guard menuKind == .contextualMenuForContainer else { return nil }

        let menu = NSMenu(title: "")
        let item = menu.addItem(withTitle: "New note",
                                action: #selector(newNote(_:)),
                                keyEquivalent: "")
        item.target = self
        return menu
    }

    @objc func newNote(_ sender: AnyObject?) {
        guard let folder = FIFinderSyncController.default().targetedURL() else {
            log.error("newNote: targetedURL() returned nil")
            return
        }

        var url = folder.appendingPathComponent("untitled note.md")
        var i = 1
        while FileManager.default.fileExists(atPath: url.path) {
            url = folder.appendingPathComponent("untitled note \(i).md")
            i += 1
        }

        do {
            try Data().write(to: url)
        } catch {
            log.error("newNote: create failed - \(error.localizedDescription, privacy: .public)")
            return
        }

        // 생성한 파일을 선택해 둔다. 이름 변경은 사용자가 Return 키로.
        NSWorkspace.shared.activateFileViewerSelecting([url])
    }
}

NewNoteExtension/NewNoteExtension.entitlements

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>com.apple.security.app-sandbox</key>
    <true/>
    <key>com.apple.security.files.user-selected.read-write</key>
    <true/>
    <key>com.apple.security.temporary-exception.files.absolute-path.read-write</key>
    <array>
        <string>/</string>
    </array>
    <key>com.apple.security.temporary-exception.files.home-relative-path.read-write</key>
    <array>
        <string>/</string>
    </array>
</dict>
</plist>

빌드

빌드 설정

NewNoteExtension 타겟 → Build Settings → 검색창에 sandbox, entitlements. Debug/Release 양쪽 모두 아래 값으로.

ENABLE_APP_SANDBOX = YES
CODE_SIGN_ENTITLEMENTS = NewNoteExtension/NewNoteExtension.entitlements

entitlements 파일은 Xcode가 만들어주지 않으므로 위 경로에 직접 생성한 뒤 CODE_SIGN_ENTITLEMENTS로 연결한다. 호스트 앱 타겟은 ENABLE_APP_SANDBOX = YES만 두고 entitlements 파일은 두지 않는다.

빌드하기

상단 메뉴 Product → Build

빌드 파일 위치

빌드된 .app은 프로젝트 폴더가 아니라 ~/Library/Developer/Xcode/DerivedData/ 아래에 생긴다. Xcode 네비게이터의 Products 그룹에서 NewNoteFinder.app 우클릭 → Show in Finder로도 열 수 있다.

설치는 아래 "재빌드 쉽게 하기"의 스크립트를 쓴다.

최종본은 Release로 빌드하는 편이 낫다. Debug 산출물에는 디버거 부착을 허용하는 com.apple.security.get-task-allow entitlement가 자동으로 들어간다.

권한 설정

확장 시스템 권한

System → General → Login Items & Extensions → By App

재빌드 쉽게 하기

LSR=/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister
DD=$(ls -d ~/Library/Developer/Xcode/DerivedData/NewNoteFinder-*/Build/Products/Debug/NewNoteFinder.app)

rm -rf /Applications/NewNoteFinder.app
cp -R "$DD" /Applications/

# DerivedData 쪽 등록을 걷어내고 /Applications 것으로 다시 잡는다
pluginkit -r "$DD/Contents/PlugIns/NewNoteExtension.appex"
"$LSR" -u "$DD"
"$LSR" -f -R -trusted /Applications/NewNoteFinder.app
pluginkit -a /Applications/NewNoteFinder.app/Contents/PlugIns/NewNoteExtension.appex

killall Finder

Xcode로 빌드하면 LaunchServices가 DerivedData 안의 .app을 등록해버려서, 확장이 /Applications 대신 그쪽 경로로 잡히는 일이 반복된다. 위 스크립트의 lsregister -u 두 줄이 그걸 막는다.

확인과 디버깅

확장이 등록·활성 상태인지 본다. 맨 앞이 +면 활성, -면 비활성, 목록에 없으면 로드 자체가 안 된 것이다.

pluginkit -m -p com.apple.FinderSync -v

동작 중 로그를 본다. 코드의 Logger(subsystem:) 값과 맞춰야 한다.

log stream --predicate 'subsystem == "bu11.NewNoteFinder"' --style compact

서명에 들어간 entitlement 확인.

codesign -d --entitlements - /Applications/NewNoteFinder.app/Contents/PlugIns/NewNoteExtension.appex

함정

샌드박스는 끄면 안 된다. 파일 쓰기가 막힌다고 ENABLE_APP_SANDBOX = NO로 바꾸면 확장이 아예 로드되지 않는다. pluginkit 목록에서 사라지고 메뉴도 같이 사라진다. Google Drive와 Dropbox의 Finder Sync 확장도 모두 샌드박스를 켠 채로 동작한다. 파일 쓰기는 샌드박스를 켠 상태에서 temporary-exception.files.* entitlement로 푼다.

Xcode 16부터 App Sandbox는 빌드 설정이다. 프로젝트에 .entitlements 파일이 없다고 해서 샌드박스가 꺼진 것이 아니다. ENABLE_APP_SANDBOX 빌드 설정에서 entitlements가 생성된다. 실제 상태는 빌드 산출물에 codesign -d --entitlements -를 걸어 확인한다.

확장은 TCC 동의 창을 띄우지 못한다. LSUIElement = true인 백그라운드 프로세스라 권한 요청 다이얼로그가 뜨지 않는다. AppleScript로 System Events를 호출하는 코드는 자동화 권한 동의를 받을 방법이 없어 -1743 Not authorized to send Apple events로 영구 실패한다. NSAppleEventsUsageDescription을 Info.plist에 넣어도 마찬가지다. Apple event가 필요 없는 API로 우회해야 한다. 파일 선택은 NSWorkspace.activateFileViewerSelecting(_:)으로 충분하다.

메뉴 위치는 지정할 수 없다. Finder가 내장 항목을 먼저 배치하고 그 아래에 확장 메뉴를 붙인다. New Folder 위로 올리는 API는 없다. 반환하는 NSMenu 내부의 항목 순서만 정할 수 있다.

전체 디스크 접근 권한은 필요 없다. 파일 쓰기 실패의 원인은 샌드박스이지 TCC가 아니다. entitlements로 해결되므로 Full Disk Access는 주지 않는다.

결과