Macでウィンドウをいい感じに配置するツール再現してみた。

[BetterSnapTool](https://apps.apple.com/us/app/bettersnaptool/id417375580)や[Magnet](https://apps.apple.com/us/app/magnet/id441258766)のようなmacOS用のウィンドウ配置マネージャーツールを再現してみました。現在の最前面のウィンドウを右半分に配置したり、左３分の１に配置したり、最大限広げて配置したりといったことがメニューバーのコマンドやショートカットキーをトリガーとして行えます。
![screenshot.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/67153/57a70906-54f3-ded4-a84b-4b15859a1008.png)

**動作の様子**
![demo.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/67153/6d0c2a1d-dae2-0789-3a82-19c044edc5ae.gif)

## 動機

そもそも普通のアプリというのはSandBoxに守られているため、外部のアプリの管轄であるウィンドウを制御することは本来できません。常駐型アプリであること、別アプリのウィンドウを制御していることに興味があったためどのような技術でこれが実現できているのか調査したいと思いました。

## 技術に関して

### アプリを常駐させる

常駐型アプリを作る方法については別途記事があるのでこちらをご参考に。
https://zenn.dev/kyome/articles/02d9f969fd17e5

### ウィンドウを制御する

まず、最前面にあるウィンドウのアプリを取得するには、`NSWorkspace`の API を用いれば良いです。

```swift
let app = NSWorkspace.shared.runningApplications.first(where: { $0.isActive })
```

あとは、`ApplicationServices`を介してアクセシビリティの　API を使いウィンドウを制御しました。`AXUIElement`というオブジェクトでウィンドウの情報を取得したり上書きすることが可能です。上で取得したアプリの`pid`を用いると`AXUIElement`を生成できます。

`AXUIElementCopyAttributeValue`という API を用いると指定した`AUIElement`の情報を取得することができます。

```swift
// 頻繁に使うため便利関数化
func copyAttributeValue(_ element: AXUIElement, attribute: String) -> CFTypeRef? {
    var ref: CFTypeRef? = nil
    let error = AXUIElementCopyAttributeValue(element, attribute as CFString, &ref)
    if error == .success {
        return ref
    }
    return .none
}

// 情報を取得する例
// ウィンドウの役割をStringで取得
func getRole(element: AXUIElement) -> String? {
    return self.copyAttributeValue(element, attribute: kAXRoleAttribute) as? String
}

// ウィンドウがフルスクリーンかどうかをBoolで取得
func isFullscreen(element: AXUIElement) -> Bool {
    let result = self.copyAttributeValue(element, attribute: "AXFullScreen") as? NSNumber
    return result?.boolValue ?? false
}
```

attribute に`kAX`から始まるキーを指定して`CFTypeRef = AnyObject`型の情報を取得して、のちに適切な型に変換します。ウィンドウの座標や大きさなどの情報を`CGPoint`や`CGSize`で取得する場合は、さらに`AXValueGetValue`というAPIを重ねて使用する必要があります。GtiHubでコード検索するとやり方はわかります。

`AUIElement`の情報を上書きする場合は、`AXUIElementSetAttributeValue`というAPIを用います。

```swift
// 頻繁に使うため便利関数化
func setAttributeValue(_ element: AXUIElement, attribute: String, value: CFTypeRef) -> Bool {
    let error = AXUIElementSetAttributeValue(element, attribute as CFString, value)
    return error == .success
}

// 情報を上書きする例
// 座標を更新
@discardableResult
func setPosition(element: AXUIElement, position: CGPoint) -> Bool {
    var position = position
    if let value = AXValueCreate(AXValueType.cgPoint, &position) {
        return self.setAttributeValue(element, attribute: kAXPositionAttribute, value: value)
    }
    return false
}
```

更新したい値を`AXValue`の型に変換してから、`kAX`から始まるキーと一緒に渡せば更新できます。ウィンドウの制御の根幹はこんな感じです。

## 制限事項

- 現在は、アクセシビリティの API をApp SandBox環境では叩けないようになっているため。SandBoxを外す必要があります。必然的に App Store で配信することは不可能です。BetterSnapToolやMagnetは規制が厳しくなかった頃からリリースされていたから特別に許されているという感じでしょうか。ただ、そもそもバイナリ提出で却下される気がするので、特別な App Sandbox Temporary Exception Entitlements を指定しているかもしれません。
- アクセシビリティの API を叩くには専用の権限をユーザーに付与してもらう必要があります。システム環境設定 -> セキュリティとプライバシー -> アクセシビリティの項目でアプリごとに許可を切り替えられます。故に現在権限が付与されているか確認し、ユーザに催促する仕組みが必要です。

## 配布

GitHubにソースもアプリも公開してあります。
もっとカスタマイズしたい方のコミットをお待ちしています。
https://github.com/Kyome22/ShiftWindow