URLをコピーしたらQRコードに自動変換して表示するMacアプリをSwiftで作った


まずはこちらをご覧ください。

![87444e922bb80f77905d005f4879bf95.gif](https://qiita-image-store.s3.amazonaws.com/0/2152/ee412067-c8a5-124a-9f75-9b9c92823526.gif "87444e922bb80f77905d005f4879bf95.gif")

URLをコピーすると自動でQRコードに自動変換して表示してくれてますね。動作としてはURLと思われるテキストをコピーした後、`Cmd + Ctrl + C`のショートカットでQRコードを表示するようにしています。つまり `Cmd + C` → `Cmd + Ctrl + C` のキーボード操作だけでいけます。便利ですね。

名前は LightningQR⚡️ です。ライトニングさんは関係ありません。

![LightningQR-128.png](https://qiita-image-store.s3.amazonaws.com/0/2152/516b8fc3-c15c-f6e4-cc9f-acd5cb7ae526.png "LightningQR-128.png")


今回はこのMacのステータスバーに常駐するアプリをSwift3で作ったので、簡単に作り方を解説しようと思います。似たようなアプリを作ろうと思っている誰かのお役に立てれば幸いです。

なお実際に使ってみたい人は、下記からダウンロードしてください😀 またソースコードもGitHubで公開しているので見てみてください。Macアプリ初心者が作ったのでハッキリ言って雑コードです。ごめんなさい🙇

- [ダウンロードURL](https://github.com/zaru/LightningQR/releases/download/v1.0/LightningQR.app.zip)
- [ソースコード](https://github.com/zaru/LightningQR)

## なぜ作ったのか

僕はスマホ用のWebサービスの開発中、実機で動作確認をしたいときはURLはQRコードに変換して、スマホのQRコードリーダーで読み取っていました。普段使いしているiPhoneとかであれば、もしかしたらもっとスマートに共有する方法があるのかもしれませんが、手元にある検証機は特に何も入ってない・アカウントの連携もされていない無味乾燥なやつなのでこうしてます。

これはスマホ側は楽でいいんですが、MacでいちいちURLをQRコードに変換するのが面倒くさかったんです。数十秒くらいでできるとはいえ、けっこうなストレスです。なのでキーボード操作だけで済むようにしたかったのが作った理由です。

## 常駐アプリとして起動する

クリップボードを監視したいので普通のアプリとは違い常駐アプリとして起動します。設定はとても簡単です。

![1.png](https://qiita-image-store.s3.amazonaws.com/0/2152/9d070b50-2768-a631-493c-8c7fb75d2987.png "1.png")

TARGETS→アプリ名→Infoタブを開いて、Custom macOS Application Target Propertiesの欄に新しく「Application is agent (UIElement)」というキーを追加します。値は「YES」です。これだけです。

この設定をするとアプリを起動してもDockには表示されません。なのでアプリを終了させるには終了メニューを用意していないとkillなどでプロセスを殺すしかなくなります。

## ステータスバーにアイコンを表示する

次にMacのステータスバーにアイコンを表示させます。ちなみに上にあるバー自体はメニューバーという名前で、右側にアプリアイコンとか時計が表示されているエリアをステータスバーと呼ぶようです。[参考](https://support.apple.com/kb/PH21890?viewlocale=ja_JP&locale=ja_JP)

あらかじめ `StatusImage` という名前のImageSetを作っておきます。サイズは 16px と 32px の２種類を用意しました。16pxのアイコンが非常に小さくて作るのが大変でした…。常に表示されるものなので色はモノクロにしました。

![2.png](https://qiita-image-store.s3.amazonaws.com/0/2152/0850ced2-b9e9-b762-dead-49d5d01023e3.png "2.png")

AppDelegateにてアイコンを登録します。

```swift
class AppDelegate: NSObject, NSApplicationDelegate {

    var statusItem = NSStatusBar.system().statusItem(withLength: NSSquareStatusItemLength)
    
    func applicationDidFinishLaunching(_ aNotification: Notification) {
    
        if let button = statusItem.button {
            button.image = NSImage(named: "StatusImage")
            button.action = #selector(hogeAction)
        }
        
    }
}
```

今回はアイコンだけを表示するので `NSSquareStatusItemLength` を指定しています。文字など他の情報を表示するような場合は `NSVariableStatusItemLength` を指定してください。

これでステータスバーにアイコンが表示されていると思います。

![3.png](https://qiita-image-store.s3.amazonaws.com/0/2152/1e7d1f71-b271-ef64-0062-2f5b16390862.png "3.png")

## ポップオーバーのViewを表示する

QRコードを表示するためのViewを用意します。せっかくステータスバーにアイコンがあるのでそこからニョキッとでるようなViewが良いですね。 `NSPopover` というAPIでそれができるみたいなのでこれを使います。

```swift
class AppDelegate: NSObject, NSApplicationDelegate {

    let popover = NSPopover()
    
    func applicationDidFinishLaunching(_ aNotification: Notification) {
        
        popover.behavior = .transient
        popover.contentViewController = QrcodeViewController(nibName: "QrcodeViewController", bundle: nil)
        
    }
    
    func togglePopover(sender: AnyObject?) {
        if popover.isShown {
            closePopover(sender: sender)
        } else {
            showPopover(sender: sender)
        }
    }
    
    func showPopover(sender: AnyObject?) {
        if let button = statusItem.button {
            popover.show(relativeTo: button.bounds, of: button, preferredEdge: NSRectEdge.minY)
            NSApplication.shared().activate(ignoringOtherApps: true)
        }
    }
    
    func closePopover(sender: AnyObject?) {
        popover.performClose(sender)
    }
}
```

`popover.behavior = .transient` を指定することでNSPopoverのViewからフォーカスが外れた際に自動で閉じてくれます。これがないとずっと開いたままでうざったいです。

表示するViewは `popover.contentViewController` にいれてあげればOKです。今回はxibを使用しています。表示する際のアニメーションは `show` メソッドの引数で指定しています。ステータスバー常駐の場合は基本 `preferredEdge: NSRectEdge.minY` で下に伸びるようにするのが良いと思います。

また `NSApplication.shared().activate(ignoringOtherApps: true)` で全てのアプリの上に優先して表示するようにしてくれます。これで一応アプリとしての見た目ができてきました。

## クリップボードを監視する

コピーしたタイミングでURL判定をしておきたいのでクリップボードを監視します。 `NSPasteboard` というAPIででクリップボードのデータを見ることができます。ただイベントハンドラ的なものはなくタイマーで監視する必要があります。

```
var changeCount: Int = 0

func beginObservingPasteboard() {
    self.changeCount = NSPasteboard.general().changeCount
    Timer.scheduledTimer(timeInterval: 1.0,
                         target: self,
                         selector: #selector(observePasteboard),
                         userInfo: nil,
                         repeats: true)
}

func observePasteboard() {
    let pboard = NSPasteboard.general()
    if (pboard.changeCount > self.changeCount) {
        pasteboardChanged(pboard: pboard)
    }
    self.changeCount = pboard.changeCount
}

func pasteboardChanged(pboard:NSPasteboard) {
    let url = pboard.string(forType: NSStringPboardType)!
}
```

`NSPasteboard.general().changeCount` でコピーするたびにインクリメントされるので、1秒毎に値が変わっていないかをチェックして変わっていれば新しい何かがコピーされたと判断をしています。

コードはこちらの記事を参考にしました。ありがとうございます。

- [NSPasteboard の変更を監視する](http://qiita.com/uasi/items/559949a709ea1c5e346f)

## URLを判定する

URL判定は自前正規表現で荒くやろうかなと思ったんですが調べるともっと簡単な方法があったので、そちらを使っています。

```
class AppDelegate: NSObject, NSApplicationDelegate, Validator {
}

protocol Validator {
    func validateURL(urlString: String) -> Bool
}

extension Validator {
    func validateURL(urlString: String) -> Bool {
        var result = false
        let types: NSTextCheckingResult.CheckingType = .link
        let detector = try? NSDataDetector(types: types.rawValue)
        guard let detect = detector else {
            return result
        }
        let matches = detect.matches(in: urlString, options: .reportCompletion, range: NSMakeRange(0, urlString.characters.count))
        for match in matches {
            if let url = match.url {
                result = true
            }
        }
        return result
    }
}
```

コードはこちらの記事を参考にしました。ありがとうございます。

- [NSTextCheckingTypeを使って正しいリンク形式かどうかを判断する](http://qiita.com/Kyam28/items/c7ac4c464cbf84c4d555)

## QRコードを生成する

URLからQRコードを生成する部分ですが、CoreImageのCIFilterの中に `CIQRCodeGenerator` というまさになフィルタがあって非常に簡単でした。CIFiler→CIImage→NSImageに変換をしています。

```
import CoreImage

class Qrcode {
    func generateQR (url:String) -> NSImage {
        let data = url.data(using: String.Encoding.utf8)
        
        let qr = CIFilter(name: "CIQRCodeGenerator",
                          withInputParameters: [
                            "inputMessage": data,
                            "inputCorrectionLevel": "M"])!
        
        
        let sizeTransform = CGAffineTransform(scaleX: 10, y: 10)
        let qrImage = qr.outputImage!.applying(sizeTransform)
        let rep = NSCIImageRep.init(ciImage: qrImage)
        let nsimage = NSImage.init(size: rep.size)
        nsimage.addRepresentation(rep)
        return nsimage
    }
}
```

`inputCorrectionLevel` は誤り訂正レベルです。画面が物理的に壊れたりしない限りは特に気にしなくて良いと思います。これでURLをQRコードにすることができました。

なお、[CIFilterの一覧はこちら](https://developer.apple.com/library/content/documentation/GraphicsImaging/Reference/CoreImageFilterReference/)。

## ホットキーを登録する

QRコードは生成できましたが、URLをコピーするたびに都度QRコードを表示してたのでは少々うざったいです。95%くらいは普通にMacの中だけで使うようなコピーでしょうし。そこでQRコードの表示をホットキーに任せます。グローバルショートカットとも言うのかな。アプリがアクティブになっていなくても指定キーバインドのイベントハンドラを取ることができるというものです。

これを実現するには `EventHotKeyID` や `InstallEventHandler` `RegisterEventHotKey` などを使えばできるみたいですが、[Magnet](https://cocoapods.org/pods/Magnet) というナイスなライブラリがあったのでこれを利用します。

Podfileに下記を書いて `pod install` します。

```
pod 'Magnet'
```

使い方も簡単で、以下のコードだけで `Cmd + Ctrl + C` のホットキーを登録しています。各キーの数値についてはこの記事を参考にしました。

- [Where can I find a list of Mac virtual key codes?](http://stackoverflow.com/questions/3202629/where-can-i-find-a-list-of-mac-virtual-key-codes)

```
import Magnet

if let keyCombo = KeyCombo(keyCode: 8, carbonModifiers: 4352) {
            let hotKey = HotKey(identifier: "CommandControlC", keyCombo: keyCombo, target: self, action: #selector(hoge))
            hotKey.register()
}
```



上記の処理を組み合わせれば最低限のアプリとしての挙動をすると思います😀

## ログイン項目に登録する

最後にこのアプリをMac起動時に自動起動するようにします。調べてみると主に3つの方法があるようです。

- Launch Services
- Helper Application
- AppleScript

AppStoreで配布するならSandbox環境にする必要があるので必然的にHelper Applicationになります。今回はAppStore以外で配布する予定で、かつSandboxにする予定もないので簡単なLaunch Services形式にしました。

ただこちらも[LoginServiceKit](https://github.com/Clipy/LoginServiceKit)という便利なライブラリがあるので、それを利用します。

```
pod 'LoginServiceKit', :git => 'https://github.com/Clipy/LoginServiceKit.git'
```

```
import LoginServiceKit

func addingToLoginItems() {
    let appPath = Bundle.main.bundlePath
    LoginServiceKit.addLoginItems(at: appPath)
}
```

これだけでログイン項目にアプリ名が並んで自動的に起動してくれます。ほんとうに便利ですね。

## さいごに

今回Macアプリを作ったのは2回目なんですが、ほとんど迷わず作ることができました。というのも、コアで面倒くさい部分は全部ライブラリがやってくれたからです。iOS/Mac開発もRubyGems並にライブラリが主日していて初心者でもなんとか形になるものまで持っていくことがやりやすいなーと感じました。今回のアプリが完成するまで実質3〜4時間ほどしかかかってないです。ライブラリ開発者の皆さんありがとうございます😆

また、分からないことがあっても検索すれば大抵あります。iOSに比べるとMacアプリは情報量は少ないですが英語で検索すれば見つかります。なのでMacアプリに関しては最初から英語クエリで検索したほうが良さそうです。

今後も開発をより加速化するようなMacアプリを作っていきたいと思いました。以上です。ありがとうございました。