この記事はChatGPT-6 Atlasにより作成されました。記事をまるっとLLMに渡すと実装してくれると思います。
PreviewでPDFを開いているとき、あるいはMicrosoft Wordで文書を開いているとき、そのファイルの絶対パスが欲しくなることがあります。
この記事では、前面アプリに開かれている文書のパスをメニューバーからコピーする小さなmacOSアプリを、SwiftとAppKitだけで作ります。対応対象は次の3つです。
- Previewの前面文書
- Microsoft Wordのactive document
- Finderで1件だけ選択している項目
外部ライブラリ、Xcodeプロジェクト、配布ZIPは使いません。この記事に載せたファイルを作れば、そのままテストとビルドができます。
先に重要な点
このアプリは対象アプリへApple Eventsを送るため、初回実行時にmacOSのオートメーション権限が必要になることがあります。必要なのは対象アプリへのオートメーション権限だけです。アクセシビリティ、画面収録、フルディスクアクセスは使いません。
また、この記事で実施した確認範囲は次のとおりです。
- コアロジックのテスト7件: 成功
- Universal Binaryのビルド: 成功
- ad-hoc署名: 成功
-
Info.plist検証: 成功 - Preview、Word、Finder向けAppleScriptの構文コンパイル: 成功
- 実際にアプリを起動し、PreviewやWordへApple Eventsを送る動作確認: 未実施
最後の項目は、検証中に予期しないオートメーション権限ダイアログを出さないためです。したがって、この記事では実アプリ連携まで実証済みとは主張しません。
仕組み
処理は次の順番です。
- メニューバーアイコンが左クリックされた時点で、
NSWorkspace.shared.frontmostApplicationから前面アプリを取得する - アプリのBundle IDに応じたAppleScriptを
NSAppleScriptで実行する - 対象アプリが返した値をローカル絶対パスへ正規化する
- 成功した場合だけクリップボードへ書き込む
メニューバーアイコンをクリックしてからメニュー項目を選ぶ方式にすると、その間に自分のアプリが前面扱いになったり、対象文書が変わったりする余地が増えます。この実装では左クリックを直接コピー操作に割り当て、UIのフィードバックを出す前に前面アプリを確保します。
パスの取得にウィンドウタイトルは使いません。同名ファイル、未保存文書、クラウド文書などを区別できず、誤ったパスを作る危険があるからです。ディスク全体の検索も行いません。
アダプタが使うAppleScriptは次の3つです。
-- Preview
tell application id "com.apple.Preview" to return (path of front document) as text
-- Microsoft Word
tell application id "com.microsoft.Word" to return full name of active document as text
-- Finder
tell application id "com.apple.finder" to if (count of selection) is 1 then return (item 1 of selection) as alias as text
ローカルのMicrosoft Wordのスクリプティング辞書では、documentのfull nameはHFS形式のフルパスとして定義されています。その一方で、クラウド文書ではWeb URLが返る可能性もあります。そこで、POSIXパス、file: URL、HFS形式を明示的に扱い、https:などのリモートURLはローカルパスに見せかけずエラーにします。
必要な環境
- macOS 13 Ventura以降
- Xcode本体、または対応するSwiftツールチェーンとmacOS SDK
- Microsoft Word対応を使う場合はMicrosoft Word
外部パッケージはありません。
この記事の検証環境は次のとおりです。
macOS 27.0.1 (Build 26A434)
Xcode 27.2 (Build 27B52)
Apple Swift 6.4.2
arm64 Mac
ビルドスクリプトはarm64とx86_64を含むUniversal Binaryを生成します。
プロジェクト構成
任意の作業場所で、次の構成を作ります。以下では~/OpenFilePathを使います。
OpenFilePath/
├── App/
│ └── Info.plist
├── Sources/
│ ├── OpenFilePathApp/
│ │ └── main.swift
│ └── OpenFilePathCore/
│ └── PathResolver.swift
├── Tests/
│ └── OpenFilePathCoreTests/
│ └── PathResolverTests.swift
├── Package.swift
└── build.sh
最初にディレクトリを作ります。
mkdir -p ~/OpenFilePath/{App,Sources/OpenFilePathApp,Sources/OpenFilePathCore,Tests/OpenFilePathCoreTests}
cd ~/OpenFilePath
1. Package.swift
Package.swiftを作成します。
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "OpenFilePath",
platforms: [.macOS(.v13)],
products: [
.library(name: "OpenFilePathCore", targets: ["OpenFilePathCore"]),
.executable(name: "OpenFilePath", targets: ["OpenFilePathApp"]),
],
targets: [
.target(name: "OpenFilePathCore"),
.executableTarget(name: "OpenFilePathApp", dependencies: ["OpenFilePathCore"]),
.testTarget(name: "OpenFilePathCoreTests", dependencies: ["OpenFilePathCore"]),
]
)
パス正規化をUIから分離したOpenFilePathCoreと、AppKitアプリ本体のOpenFilePathAppに分けています。これにより、実際のクリップボードやApple Eventsを使わずコアだけをテストできます。
2. Sources/OpenFilePathCore/PathResolver.swift
Sources/OpenFilePathCore/PathResolver.swiftを作成します。
import Foundation
public enum PathResolutionError: Error, Equatable, LocalizedError {
case unsupportedApplication(String)
case noDocument
case unsavedDocument
case remoteURL(URL)
case invalidValue(String)
case automationDenied
case applicationError(String)
public var errorDescription: String? {
switch self {
case .unsupportedApplication(let name): return "\(name) はまだ対応していません"
case .noDocument: return "前面に開いている文書がありません"
case .unsavedDocument: return "未保存の文書には絶対パスがありません"
case .remoteURL(let url): return "ローカルパスではなくURLです: \(url.absoluteString)"
case .invalidValue: return "アプリから有効なパスを取得できませんでした"
case .automationDenied: return "このアプリの操作が許可されていません"
case .applicationError(let message): return message
}
}
}
public enum PathNormalizer {
public static func localPath(from rawValue: String) throws -> String {
let raw = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard !raw.isEmpty else { throw PathResolutionError.unsavedDocument }
if (raw.hasPrefix("file:") || raw.contains("://")),
let url = URL(string: raw), let scheme = url.scheme {
guard scheme.lowercased() == "file" else { throw PathResolutionError.remoteURL(url) }
guard url.isFileURL else { throw PathResolutionError.invalidValue(raw) }
return url.standardizedFileURL.path
}
if raw.hasPrefix("/") {
return URL(fileURLWithPath: raw).standardizedFileURL.path
}
// Word's documented `full name` is an HFS path. Convert its volume and
// components directly, never by guessing from the document title.
if raw.contains(":") {
let parts = raw.split(separator: ":", omittingEmptySubsequences: false).map(String.init)
guard parts.count > 1, !parts[0].isEmpty else { throw PathResolutionError.invalidValue(raw) }
let rootVolume = try? URL(fileURLWithPath: "/").resourceValues(forKeys: [.volumeNameKey]).volumeName
let base = parts[0] == rootVolume ? "/" : "/Volumes/\(parts[0])"
return URL(fileURLWithPath: base).appending(path: parts.dropFirst().joined(separator: "/")).standardizedFileURL.path
}
throw PathResolutionError.invalidValue(raw)
}
}
public protocol DocumentValueProviding {
func documentValue(bundleIdentifier: String) throws -> String
}
public struct FrontDocumentResolver {
public let provider: any DocumentValueProviding
public init(provider: any DocumentValueProviding) { self.provider = provider }
public func resolve(bundleIdentifier: String, applicationName: String) throws -> String {
let supported = ["com.apple.Preview", "com.microsoft.Word", "com.apple.finder"]
guard supported.contains(bundleIdentifier) else {
throw PathResolutionError.unsupportedApplication(applicationName)
}
return try PathNormalizer.localPath(from: provider.documentValue(bundleIdentifier: bundleIdentifier))
}
}
正規化では次を区別しています。
-
/Users/...のようなPOSIX絶対パス -
file:///Users/...のようなfile URL -
Archive:Reports:sample.docxのようなHFS形式 -
https://...のようなリモートURL - 空文字列など、未保存文書を示す値
URLを使うことで、file URLのパーセントエンコード、空白、日本語なども正しく戻せます。HFS形式では先頭要素をボリューム名として扱い、起動ボリュームなら/、それ以外なら/Volumes/<ボリューム名>へ変換します。
3. Sources/OpenFilePathApp/main.swift
Sources/OpenFilePathApp/main.swiftを作成します。
import AppKit
import Foundation
import OpenFilePathCore
final class AppleScriptDocumentProvider: DocumentValueProviding {
func documentValue(bundleIdentifier: String) throws -> String {
let source: String
switch bundleIdentifier {
case "com.apple.Preview":
source = #"tell application id "com.apple.Preview" to return (path of front document) as text"#
case "com.microsoft.Word":
source = #"tell application id "com.microsoft.Word" to return full name of active document as text"#
case "com.apple.finder":
source = #"tell application id "com.apple.finder" to if (count of selection) is 1 then return (item 1 of selection) as alias as text"#
default:
throw PathResolutionError.unsupportedApplication(bundleIdentifier)
}
var errorInfo: NSDictionary?
guard let script = NSAppleScript(source: source),
let value = script.executeAndReturnError(&errorInfo).stringValue else {
let number = errorInfo?[NSAppleScript.errorNumber] as? Int
if number == -1743 { throw PathResolutionError.automationDenied }
if number == -1728 { throw PathResolutionError.noDocument }
let message = errorInfo?[NSAppleScript.errorMessage] as? String ?? "文書パスを取得できませんでした"
throw PathResolutionError.applicationError(message)
}
return value
}
}
@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate {
private enum StatusIcon {
static let idle = "doc.on.clipboard"
static let success = "checkmark.circle"
static let error = "exclamationmark.triangle"
}
private var statusItem: NSStatusItem!
private let resolver = FrontDocumentResolver(provider: AppleScriptDocumentProvider())
private var resetWorkItem: DispatchWorkItem?
func applicationDidFinishLaunching(_ notification: Notification) {
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
guard let button = statusItem.button else { return }
setStatusIcon(StatusIcon.idle)
button.toolTip = "左クリック: 前面の文書パスをコピー / 右クリック: メニュー"
button.target = self
button.action = #selector(statusItemClicked(_:))
button.sendAction(on: [.leftMouseUp, .rightMouseUp])
}
@objc private func statusItemClicked(_ sender: NSStatusBarButton) {
if NSApp.currentEvent?.type == .rightMouseUp {
let menu = NSMenu()
menu.addItem(withTitle: "終了", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")
statusItem.menu = menu
sender.performClick(nil)
statusItem.menu = nil
return
}
// A status item click does not activate this accessory app. Capture the
// target before any UI feedback, then resolve immediately.
guard let app = NSWorkspace.shared.frontmostApplication,
app.bundleIdentifier != Bundle.main.bundleIdentifier else {
show("対象なし", isError: true)
return
}
do {
let path = try resolver.resolve(
bundleIdentifier: app.bundleIdentifier ?? "",
applicationName: app.localizedName ?? "このアプリ"
)
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(path, forType: .string)
show("✓ コピー済み", isError: false)
} catch {
show(error.localizedDescription, isError: true)
presentError(error, applicationName: app.localizedName ?? "このアプリ")
}
}
private func show(_ text: String, isError: Bool) {
resetWorkItem?.cancel()
setStatusIcon(isError ? StatusIcon.error : StatusIcon.success)
statusItem.button?.toolTip = text
let work = DispatchWorkItem { [weak self] in
self?.setStatusIcon(StatusIcon.idle)
self?.statusItem.button?.toolTip = "左クリック: 前面の文書パスをコピー / 右クリック: メニュー"
}
resetWorkItem = work
DispatchQueue.main.asyncAfter(deadline: .now() + 3, execute: work)
}
private func setStatusIcon(_ symbolName: String) {
let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: "文書パスをコピー")
image?.isTemplate = true
statusItem.button?.image = image
statusItem.button?.imagePosition = .imageOnly
statusItem.button?.title = ""
}
private func presentError(_ error: Error, applicationName: String) {
let alert = NSAlert()
alert.alertStyle = .warning
alert.messageText = "パスをコピーできませんでした"
alert.informativeText = errorGuidance(error, applicationName: applicationName)
alert.addButton(withTitle: "OK")
if case PathResolutionError.automationDenied = error {
alert.addButton(withTitle: "オートメーション設定を開く")
}
NSApp.activate(ignoringOtherApps: true)
let response = alert.runModal()
if response == .alertSecondButtonReturn,
let settingsURL = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Automation") {
NSWorkspace.shared.open(settingsURL)
}
}
private func errorGuidance(_ error: Error, applicationName: String) -> String {
switch error {
case PathResolutionError.unsupportedApplication:
return "\(applicationName) は現在の対応対象ではありません。PreviewまたはMicrosoft Wordで保存済み文書を前面にするか、Finderで項目を1件選択してから、もう一度クリックしてください。"
case PathResolutionError.automationDenied:
return "\(applicationName) の操作が許可されていません。システム設定の「プライバシーとセキュリティ > オートメーション」でOpenFilePathを許可してください。"
case PathResolutionError.noDocument:
return "\(applicationName) に開いている文書がないか、Finderで選択している項目が1件ではありません。対象を開くか1件だけ選択してから、もう一度クリックしてください。"
case PathResolutionError.unsavedDocument:
return "前面の文書はまだ保存されていません。文書を保存してから、もう一度クリックしてください。"
default:
return "対象: \(applicationName)\n\(error.localizedDescription)"
}
}
}
let app = NSApplication.shared
let delegate = AppDelegate()
app.delegate = delegate
app.setActivationPolicy(.accessory)
app.run()
LSUIElementを有効にしたアクセサリアプリなので、通常のアプリウィンドウやDockアイコンは表示しません。状態はSF Symbolsのアイコンで示し、エラー時は理由と対処をダイアログに表示します。
クリップボードを書き換えるのは左クリックが成功した場合だけです。バックグラウンドでクリップボードを監視したり、文書を監視したりはしません。
4. Tests/OpenFilePathCoreTests/PathResolverTests.swift
Tests/OpenFilePathCoreTests/PathResolverTests.swiftを作成します。
import Foundation
import Testing
@testable import OpenFilePathCore
private struct FakeProvider: DocumentValueProviding {
let value: String
func documentValue(bundleIdentifier: String) throws -> String { value }
}
@Test func decodesFileURLWithSpacesAndUnicode() throws {
let path = try PathNormalizer.localPath(from: "file:///tmp/%E6%97%A5%E6%9C%AC%20file.pdf")
#expect(path == "/tmp/日本 file.pdf")
}
@Test func preservesPOSIXUnicodePath() throws {
#expect(try PathNormalizer.localPath(from: "/tmp/資料 1.docx") == "/tmp/資料 1.docx")
}
@Test func convertsWordHFSPathOnExternalVolume() throws {
#expect(try PathNormalizer.localPath(from: "Archive:Reports:資料 1.docx") == "/Volumes/Archive/Reports/資料 1.docx")
}
@Test func rejectsRemoteURLInsteadOfInventingLocalPath() {
#expect(throws: PathResolutionError.remoteURL(URL(string: "https://example.sharepoint.com/a.docx")!)) {
try PathNormalizer.localPath(from: "https://example.sharepoint.com/a.docx")
}
}
@Test func rejectsUnsavedDocument() {
#expect(throws: PathResolutionError.unsavedDocument) { try PathNormalizer.localPath(from: " ") }
}
@Test func resolverUsesAdapterValueNotWindowTitle() throws {
let resolver = FrontDocumentResolver(provider: FakeProvider(value: "/tmp/actual.pdf"))
#expect(try resolver.resolve(bundleIdentifier: "com.apple.Preview", applicationName: "Preview") == "/tmp/actual.pdf")
}
@Test func rejectsUnsupportedApplication() {
let resolver = FrontDocumentResolver(provider: FakeProvider(value: "/tmp/x"))
#expect(throws: PathResolutionError.unsupportedApplication("TextEdit")) {
try resolver.resolve(bundleIdentifier: "com.apple.TextEdit", applicationName: "TextEdit")
}
}
このテストはfake providerを使うため、PreviewやWordを起動せず、クリップボードも変更せずに実行できます。
5. App/Info.plist
App/Info.plistを作成します。
<?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>CFBundleExecutable</key><string>OpenFilePath</string>
<key>CFBundleIdentifier</key><string>local.example.OpenFilePath</string>
<key>CFBundleName</key><string>OpenFilePath</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleShortVersionString</key><string>0.2.0</string>
<key>CFBundleVersion</key><string>1</string>
<key>LSUIElement</key><true/>
<key>NSAppleEventsUsageDescription</key><string>前面で開いている文書の保存先パスを取得するため、選択したアプリに問い合わせます。</string>
<key>NSPrincipalClass</key><string>NSApplication</string>
</dict></plist>
LSUIElementはアプリをDockに表示しないための設定です。NSAppleEventsUsageDescriptionには、なぜApple Eventsを使うのかをユーザー向けに記述します。
CFBundleIdentifierは自分の識別子へ変更して構いません。Apple Developer IDによる配布署名を行う場合は、その署名設定と一致する識別子を使ってください。
この構成ではentitlementsファイルは不要です。App Sandboxを有効にして配布する設計へ変更する場合は、Apple Events関連のsandbox要件を別途検討する必要があります。
6. build.sh
build.shを作成します。
#!/bin/zsh
set -euo pipefail
project_dir=${0:A:h}
build_dir="$project_dir/build"
app_dir="$build_dir/OpenFilePath.app"
scratch_dir="$build_dir/SwiftPM"
if [[ -d /Applications/Xcode.app/Contents/Developer ]]; then
export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
fi
export CLANG_MODULE_CACHE_PATH="$scratch_dir/ModuleCache"
export SWIFTPM_MODULECACHE_OVERRIDE="$scratch_dir/ModuleCache"
/usr/bin/xcrun swift build \
--disable-sandbox \
--package-path "$project_dir" \
--scratch-path "$scratch_dir" \
-c release \
--arch arm64 \
--arch x86_64 \
--product OpenFilePath
binary_path=$(/usr/bin/xcrun swift build \
--package-path "$project_dir" \
--scratch-path "$scratch_dir" \
-c release \
--arch arm64 \
--arch x86_64 \
--show-bin-path)
/bin/rm -rf "$app_dir"
/bin/mkdir -p "$app_dir/Contents/MacOS"
/bin/cp "$project_dir/App/Info.plist" "$app_dir/Contents/Info.plist"
/bin/cp "$binary_path/OpenFilePath" "$app_dir/Contents/MacOS/OpenFilePath"
/usr/bin/codesign --force --sign - "$app_dir"
echo "$app_dir"
実行権限を付けます。
chmod +x build.sh
このスクリプトはSwiftPMでUniversal Binaryを作り、最小構成の.appバンドルへ配置した後、ローカル実行用のad-hoc署名を行います。Developer ID署名や公証は行いません。
--disable-sandboxはSwiftPM自身のビルド用sandboxを無効にするオプションであり、macOSのSIPやGatekeeper、アプリのプライバシー保護を無効にするものではありません。
テスト
プロジェクトルートで実行します。
cd ~/OpenFilePath
swift test --disable-sandbox
成功時はSwift Testingの結果に7 tests passedと表示されます。
ビルド
cd ~/OpenFilePath
./build.sh
成功すると次の場所にアプリが生成されます。
~/OpenFilePath/build/OpenFilePath.app
バンドルと署名を追加確認する場合は次を実行します。
plutil -lint build/OpenFilePath.app/Contents/Info.plist
codesign --verify --deep --strict build/OpenFilePath.app
file build/OpenFilePath.app/Contents/MacOS/OpenFilePath
fileの結果にarm64とx86_64が含まれていればUniversal Binaryです。
起動と使い方
実際に対象アプリへ問い合わせるとオートメーション権限ダイアログが表示される可能性があります。内容を確認してから起動してください。
open build/OpenFilePath.app
- PreviewまたはWordで保存済み文書を前面にします。Finderの場合は項目を1件だけ選択します。
- メニューバーの書類アイコンを左クリックします。
- チェックマークが表示されたら、絶対パスがクリップボードに入っています。
右クリックすると「終了」メニューが表示されます。
初回の問い合わせ時にmacOSがオートメーション権限を求めた場合は、対象と理由を確認して許可します。後から変更する場合は「システム設定 > プライバシーとセキュリティ > オートメーション」でOpenFilePathの対象アプリへの許可を変更できます。
エラー時の確認
「このアプリの操作が許可されていません」
「システム設定 > プライバシーとセキュリティ > オートメーション」で、OpenFilePathから対象アプリへの許可を確認します。アクセシビリティやフルディスクアクセスを追加する必要はありません。
「前面に開いている文書がありません」
PreviewまたはWordで文書が開かれているか確認します。Finderの場合は、選択が0件または複数件だと取得しません。1件だけ選択してください。
未保存の文書
未保存文書にはローカル絶対パスがありません。先に保存してから再実行します。
OneDriveやSharePointの文書
Wordが同期済みローカルファイルのパスを返す場合はコピーできます。Wordがhttps: URLだけを返す場合、この実装はローカル絶対パスを推測しません。URLとローカル同期先の対応を誤ると、存在しないパスや別ファイルのパスを返す危険があるためです。
開発元を確認できないという警告
この手順の署名はad-hoc署名で、Developer ID署名やAppleの公証ではありません。自分のMacでビルドして試す用途を想定しています。第三者へ配布する場合はDeveloper ID署名と公証を行ってください。SIPやGatekeeperを無効化する方法は推奨しません。
終了と削除
終了するには、メニューバーアイコンを右クリックして「終了」を選びます。
ログイン項目は登録していません。不要になったらアプリを終了し、作成した~/OpenFilePathディレクトリを削除すれば完了です。すでに付与したオートメーション権限が気になる場合は、システム設定のオートメーション画面で許可状態を確認してください。
現在の制限
- 対応対象はPreview、Microsoft Word、Finderのみ
- Finderは1項目選択のみ
- 未保存文書は非対応
- リモートURLからローカル同期パスを推測しない
- グローバルショートカットやログイン時起動は実装していない
- AppleScriptアダプタは構文コンパイルまで確認済みだが、この記事の検証では実アプリへのApple Events送信を実行していない
- Developer ID署名、公証、Mac App Store向けsandbox構成は含まない
セキュリティ面で行っていないこと
このアプリは次の処理を行いません。
- ディスク全体の検索
- ファイル内容の読み取り
- 常時のウィンドウ監視
- クリップボードの監視
- 自動的なクリップボード書き換え
- ネットワーク通信やテレメトリ
- アクセシビリティ権限、画面収録権限、フルディスクアクセスの要求
パス取得とクリップボード書き込みは、メニューバーアイコンを明示的に左クリックしたときだけ発生します。
参考資料
- NSWorkspace.frontmostApplication — Apple Developer Documentation
- NSAppleScript — Apple Developer Documentation
- LSUIElement — Apple Developer Documentation
まとめ
前面文書の絶対パス取得で難しいのは、文字列をクリップボードへ入れる部分ではなく、「どの文書か」と「返された値が本当にローカルパスか」を誤認しないことです。
この実装では、クリック直後の前面アプリをBundle IDで判定し、アプリ自身が返した文書情報だけを使います。さらに、POSIXパス、file URL、WordのHFS形式、リモートURL、未保存文書を分けて扱います。対応アプリを追加する場合も、DocumentValueProvidingへアダプタを追加し、正規化とUIを分離したまま拡張できます。