ごんれのラボ

iOS、Android、Adobe系ソフトの自動化スクリプトのことを書き連ねています。

起動中のアプリケーションを切り替えるmacOSアプリケーションを作った

概要

Macにインストール済の特定のアプリケーションを一覧表示して、任意のバージョンを起動できるLauncherアプリケーションを作る その1(以下、前回記事)の続きを実装するにあたって、「メタ情報の取得をもっとスマートにできないか」「アプリケーションの起動状態をどう取得するか」「メタ情報をUIにどう結びつけるか」などの調査が必要になったので、 別のプロジェクトを作ることにした。
その結果が、本記事で紹介する 起動中のアプリケーションを切り替えるmacOSアプリケーション となる。

作ったもの

f:id:macneko-ayu:20180506151217p:plain

  • 起動中のアプリケーションを一覧表示する
    • 名前順でソートして表示
    • 名前はFinderで表示されている名前を表示
  • アプリケーションを起動すると一覧に追加する
  • アプリケーションを終了すると一覧から削除する
  • セルをクリックすると、そのアプリケーションを最前面に表示する

Githubで公開済。 github.com この記事はv1.0.1時点のもの。

メタ情報の取得をもっとスマートにできないか

前回記事ではinfo.plistからメタ情報を取得していたが、もっといい方法があるはずと思ってさらに調べたところ、標準APIであるBundleから取得する方法があった。

参考:Bundle - Foundation | Apple Developer Documentation

いままでBundleはアプリケーション自身のメタ情報なりを取得するためのものと思っていたけど、init(identifier:)init(url:) で任意のアプリケーションのメタ情報を取得することができたのね。

例として、FilePathからXcodeのBundleIdentifierを取得する方法を比較してみる。

info.plistを生成して取得(前回記事より一部抜粋)

// エンコーディングされているのでデコード
guard let filePath = fileUrl.path.removingPercentEncoding else {
    return nil
}
// 情報を取得するためにplistを取得
let plistPath = filePath + "/Contents/Info.plist"
if !FileManager.default.fileExists(atPath: plistPath) {
    return nil
}
guard let infoDictionary = NSDictionary(contentsOfFile: plistPath) else {
    return nil
}
// bundleIdentifierを取得
self.bundleIdentifier = infoDictionary["CFBundleIdentifier"] as? String ?? ""

Bundleを生成して取得

let url = URL(fileURLWithPath: "/Applications/Xcode.app")
// Bundleを生成
guard let bundle = Bundle(url: url) else {
    return nil
}
// bundleIdentifierを取得
let bundleIdentifier = bundle.bundleIdentifier ?? ""

すっきり取得できるようになった。

アプリケーションの起動状態をどう取得するか

Launcherアプリケーションというからには、該当アプリケーションの起動状態を管理する必要がある。 これについては前回記事の段階で調査をしていて、NSWorkspace のAPIを用いれば実現できそうだとあたりをつけていた。
参考:NSWorkspace - AppKit | Apple Developer Documentation

今回改めて調査したところ、そのものズバリなプロパティはないようで、ちょっと工夫しないと取得できないことがわかった。

例として、Xcodeの起動状態を取得するコードを紹介する。

Xcodeの起動状態を取得する

// 起動しているアプリケーションのリストを取得する(NSApplication.ActivationPolicy.regularはGUIアプリケーション)
let runningApps = NSWorkspace.shared.runningApplications.filter { $0.activationPolicy == NSApplication.ActivationPolicy.regular }
// 取得したリストからpathでfilterをかけて、ヒットすればXcodeは起動中ということになる
let isRunning = runningApps.compactMap { $0.bundleURL }.filter { $0.path == "/Applications/Xcode.app" }.count > 0
// 結果: Xcodeが起動中=true, 未起動=false
print(isRunning)

つまり、起動しているアプリケーションのリストから、pathが一致すれば起動中、一致しなければ未起動ということになる。

メタ情報をUIにどう結びつけるか

メタ情報を取得することができたので、それをUIに反映する方法を調べる。
当初はRxSwiftを使ってバインディングしようと思っていたんだけど、調べているうちにCocoa Bindingという方法があることを知ったので、試しに採用してみることに。

参考:
What Are Cocoa Bindings?
Cocoa Bindings on macOS

Cocoa Bindingを使うには、バインディングするプロパティに @objc dynamic を付加する必要があり、そのためにモデルもStructからClassに変える必要があった。
また、NSArrayControllerをStoryboardで設定していて、後日コードでも設定できると教えてもらったんだけど、情報が出てこなくて諦めた…。
次のバージョンでは勉強の意味も含めてReactiveSwiftやRxSwiftに置き換えるようと検討中。

話を戻して。
UIに反映するタイミングは、RunningAppsが起動したとき、その他のアプリケーションが起動したとき、その他のアプリケーションが終了した場合の3パターンあるが、どのパターンもNSWorkspaceのNotificationを検知することで実現可能。
検知するNotificationは下記の2つ。

  • NSWorkspace.didLaunchApplicationNotification
    • アプリケーションが起動したタイミングで通知される
  • NSWorkspace.didTerminateApplicationNotification
    • アプリケーションが終了したタイミングで通知される

今回はViewModelでNotificationをaddObserverし、通知があったらデリゲート経由でViewControllerのメソッドを叩き、バインディングしている配列の更新を行うようにした。

一部抜粋

/// ViewModelでデリゲートを定義
protocol UpdatedRunningAppsStateDelegate {
    func refresh()
}

/// ViewControllerでデリゲートメソッドを定義
extension RunningAppsViewController: UpdatedRunningAppsStateDelegate {
    
    /// リストを更新
    func refresh() {
        DispatchQueue.main.async {
            self.runningAppsArrayController.rearrangeObjects()
        }
    }
}

ソースコード

すべて記載すると長くなるので、ViewModelとViewControllerのコードを記載。

RunningAppsViewModel

//
//  RunningAppsViewModel.swift
//  RunningApps
//

import Cocoa

protocol UpdatedRunningAppsStateDelegate {
    func refresh()
}

class RunningAppsViewModel: NSObject {
    @objc dynamic var metaDatas = [ApplicationMetaData]()
    var delegate: UpdatedRunningAppsStateDelegate?
    
    override init() {
        super.init()
        self.setupObserver()
    }
    
    deinit {
        NSWorkspace.shared.notificationCenter.removeObserver(self)
    }
    
    // MARK: Public Methods

    /// 保持しているMetaDataを更新
    ///
    /// - Parameter bundleIdentifiers: 更新対象のBundleIndetifier
    public func updateMetaDatas(bundleIdentifiers: [String]) {
        bundleIdentifiers.forEach { (identifier: String) in
            let metaDatas = makeMetaDatas(bundleIndentifier: identifier)
            let filteredMetaDatas = metaDatas.filter { $0.isRunning && $0.identifier != Bundle.main.bundleIdentifier }
            let sortedMetaDatas = filteredMetaDatas.sorted(by: { $0.name < $1.name })
            sortedMetaDatas.forEach { self.metaDatas.append($0) }
        }
    }
    
    // MARK: Private Methods

    /// MetaDataを作成
    ///
    /// - Parameter bundleIndentifier: 作成対象のBundleIndetifier
    /// - Returns: 作成したMetaDataの配列
    private func makeMetaDatas(bundleIndentifier: String) -> [ApplicationMetaData] {
        // BundleIdentifierにマッチするApplicationのファイルUrlを取得
        // 同じBundleIdentifierのApplicationがインストールされている場合もあり得るので配列となる
        guard let appUrls = LSCopyApplicationURLsForBundleIdentifier(bundleIndentifier as CFString, nil)?.takeUnretainedValue() as? [URL] else {
            return []
        }
        let appMetaDatas = appUrls.compactMap { (appUrl: URL) -> ApplicationMetaData? in
            guard let bundle = Bundle(url: appUrl), let identifier = bundle.bundleIdentifier, let infoDictionary = bundle.infoDictionary else {
                return nil
            }
            // 重複を防ぐため、登録済かチェックする
            if let _ = self.metaDatas.index(where: { $0.identifier == identifier && $0.url.path == appUrl.path }) {
                return nil
            }

            let path = appUrl.path
            let name = self.extractName(path: path)
            let icon = NSWorkspace.shared.icon(forFile: path)
            let version = infoDictionary["CFBundleVersion"] as? String ?? "unknown"
            let versionDesctiption = "version \(version)"
            
            let runningApps = NSWorkspace.shared.runningApplications.filter { $0.activationPolicy == NSApplication.ActivationPolicy.regular }
            let runningState = runningApps.compactMap { $0.bundleURL }.filter { $0.path == appUrl.path }.count > 0
            return ApplicationMetaData(name: name, url: appUrl, identifier: identifier, version: version, versionDesctiption: versionDesctiption, icon: icon, isRunning: runningState)
        }
        return appMetaDatas
    }
    
    /// ファイルパスからファイル名を抽出
    ///
    /// - Parameter path: ファイルパス
    /// - Returns: ファイル名
    private func extractName(path: String) -> String {
        var components = FileManager.default.displayName(atPath: path).split(separator: ".")
        
        components.removeLast()
        return components.joined(separator: ".") as String
    }
    
    /// Observerを設定
    private func setupObserver() {
        NSWorkspace.shared.notificationCenter.addObserver(self, selector: #selector(appDidLaunch(notification:)), name: NSWorkspace.didLaunchApplicationNotification, object: nil)
        NSWorkspace.shared.notificationCenter.addObserver(self, selector: #selector(appDidTerminate(notification:)), name: NSWorkspace.didTerminateApplicationNotification, object: nil)
    }
    
    // MARK: Notification Methods
    
    /// アプリ起動時にリストを更新(自身は含めない)
    ///
    /// - Parameter notification: Notification
    @objc private func appDidLaunch(notification: Notification) {
        guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication,
            let identifier = app.bundleIdentifier else { return }
        if identifier == Bundle.main.bundleIdentifier {
            return
        }
        updateMetaDatas(bundleIdentifiers: [identifier])
        delegate?.refresh()
    }
    
    /// アプリ終了時にリストを更新
    ///
    /// - Parameter notification: Notification
    @objc private func appDidTerminate(notification: Notification) {
        guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication,
            let identifier = app.bundleIdentifier,
            let appUrl = app.bundleURL,
            let index = metaDatas.index(where: { $0.identifier == identifier && $0.url.path == appUrl.path }) else { return }
        metaDatas.remove(at: index)
        delegate?.refresh()
    }
}

RunningAppsViewController

//
//  RunningAppsViewController.swift
//  RunningApps
//

import Cocoa

class RunningAppsViewController: NSViewController {

    @IBOutlet weak var tableView: NSTableView!
    @IBOutlet var runningAppsArrayController: NSArrayController!
    @objc let viewModel = RunningAppsViewModel()

    // MARK: Initialization
    
    deinit {
    }
    
    override var representedObject: Any? {
        didSet {
            // Update the view, if already loaded.
        }
    }

    // MARK: LifeSycle

    override func viewDidLoad() {
        super.viewDidLoad()
        let sortDescriptor = NSSortDescriptor(key: "name", ascending: true, selector: #selector(NSString.caseInsensitiveCompare(_:)))
        runningAppsArrayController.sortDescriptors = [sortDescriptor]
        tableView.selectionHighlightStyle = .regular
        loadItems()
    }

    // MARK: Private Methods
    
    /// 起動時に起動中のアプリケーション一覧を取得してArrayControllerに反映(自身は含めない)
    private func loadItems() {
        let runningApps = NSWorkspace.shared.runningApplications.filter { $0.activationPolicy == NSApplication.ActivationPolicy.regular }
        let bundleIdentifiers = runningApps.compactMap { $0.bundleIdentifier }
        viewModel.updateMetaDatas(bundleIdentifiers: bundleIdentifiers)
    }
}

extension RunningAppsViewController: NSTableViewDelegate {
    func tableViewSelectionDidChange(_ notification: Notification) {
        guard let arrangeObjects = runningAppsArrayController.arrangedObjects as? [ApplicationMetaData] else { return }
        let index = tableView.selectedRow
        let rowView = tableView.rowView(atRow: index, makeIfNecessary: false)
        rowView?.isEmphasized = true
        let item = arrangeObjects[index]
        guard let app = NSWorkspace.shared.runningApplications
            .filter ({ (app: NSRunningApplication) in app.activationPolicy == NSApplication.ActivationPolicy.regular })
            .filter ({ (app: NSRunningApplication) in app.bundleIdentifier == item.identifier && app.bundleURL?.path == item.url.path }).first
            else { return }
        app.activate(options: [])
        tableView.deselectRow(index)
    }
}

extension RunningAppsViewController: UpdatedRunningAppsStateDelegate {
    /// リストを更新
    func refresh() {
        DispatchQueue.main.async {
            self.runningAppsArrayController.rearrangeObjects()
        }
    }
}

次の予定

XMPをパースするためにどうするかの調査と実装をする。