iOS Simulatorに画像をOverlayするmacOSアプリケーションを作ってみよう

Takuma Matsushita
Eureka Engineering

--

iOSのシミュレータに画像をオーバレイし、デザイン通りに実装されていることを確認するツールをmacOS向けにつくってみます。

はじめに

普段の開発において、iOSエンジニアは主に、XcodeとシミュレータやSwiftUIでのプレビューとを行き来することが多いと思います。 昨年はSwiftUIのプレビューについて(?)書いたので、今回はシミュレータでの開発を少し便利にするツールを簡単にですが作ってみます。 具体的にはFigmaなどのデザインツールから出力された画像をシミュレータに重ねることで、デザイン通りに実装されていることを確認するツールを作ります。

iOS Simulatorに画像をオーバレイするといえば、RocketSimなどのアプリケーションをご存知の方も多いと思います。 RocketSimはとても便利なアプリケーションで、画像のオーバレイにとどまらず様々な機能が搭載されています。 RocketSimがどうやって画像のオーバーレイを実現しているのかは詳細には不明ですが、Accessibility APIを応用することで同じような機能を実現することができるのではないかと考えました。

Accessibility API

Accessibility APIはAppleから提供されている、起動中のアプリケーションの知覚情報を取得するためのAPIです。

https://developer.apple.com/accessibility/

このAPIを応用することでGUI上のWindowのframe、メニューバーのタイトルや様々なUIの情報を取得することが可能です。 こちらのAPIは少しインタフェースが古く、最近のSwiftでは実装方法に悩むことが少々あります。 また、同じような実装を試みる例も少なく、比較的情報を見つけづらいこともあります。しかしながら、他の起動中のアプリケーションの情報を取得できるAPIが公式から提供されているということは貴重であり、Xcode Extensionや他のアプリケーションに作用するようなアプリケーションの開発に広く使われています。 比較的最近のアプリケーションだと、Copilot For XcodeにもAccessiblity APIの機能が使用されています。 そのほかにもRaycast、BartenderなどのツールがAccessibility APIを経由し、情報を取得してアプリケーションに応用しています。

オーバレイ用のアプリケーションを開発する

以下のステップでWindowに追従するような実装を行います。

  1. CGWindowListからWindowの一覧を取得
  2. 一覧からSimulatorのWindowを取得
  3. そのWindowに対してWindow情報の変更の監視を行い、アプリケーションのframeを追従させる
  4. オーバレイ画像の描画

SimulatorのWindow情報を取得する

Windowの情報を入手するためにはCore Graphicsの関数であるCGWindowListCopyWindowInfoを使用し、macOS上のWindowの一覧を取得します。 また、そこからオーナーネームとPIDを取得することができます。 Accessiblity APIはAPIが古いため、このAPIを利用する際にはCFStringでやり取りする必要があります。CGWindowListCopyWindowInfoの返り値はAXUIElementとなっており、kCGをプレフィックスとするkeyを使ってプロパティにアクセスする必要があります。オブジェクトへのプロパティアクセスが多少面倒なため、Windowというclassを定義しています。

AXUIElementはAccessibility APIにおける画面の単位のようなもので、UIKitにおけるUIViewに近い概念です。 AXUIElementは子要素としてchildrenというプロパティを持ち、その子要素であるAXUIElementもまたchildrenを持つような構造になっています。

guard
let windowList = CGWindowListCopyWindowInfo(.optionOnScreenOnly, kCGNullWindowID) as? Array<AnyObject>
else {
return nil
}

return windowList.map {
let ownerName = $0.object(forKey: kCGWindowOwnerName) as? String ?? "N/A"
let ownerPID = $0.object(forKey: kCGWindowOwnerPID) as? pid_t ?? 0
return Window(
ownerName: ownerName,
ownerPID: ownerPID,
window: $0 as! AXUIElement
)
}

SimulatorのWindowに追従させる

前述の通りオーナーネームが取れたあとは、その文字列が”Simulator”となっているWindowを探し、そのPIDからWindowのイベントを監視します。 今回は画像のオーバーレイを実現することに集中したいので、Windowが複数存在することは考慮しないことにしました。 以下のコードでは、対象のPIDが持つWindowの通知イベントを監視するためのものです。Observerのcallback内でオーバーレイ用のWindowのリサイズ、表示と非表示を呼び出しています。

@MainActor
class WindowMoveObserver {
let uiElement: AXUIElement
var resizeObserver: AXObserver?
var showObserver: AXObserver?
var hideObserver: AXObserver?
private static let resizeNotifications: [NSAccessibility.Notification] = [
.windowMoved,
.windowResized,
]
private static let showNotifications: [NSAccessibility.Notification] = [
.applicationShown,
.applicationActivated,
]
private static let hideNotifications: [NSAccessibility.Notification] = [
.applicationHidden,
.applicationDeactivated,
]
init(processID: pid_t) {
self.uiElement = AXUIElementCreateApplication(processID)
let resizeCallback: AXObserverCallbackWithInfo = { observer, element, notification, info, _ in
let delegate = (NSApplication.shared.delegate as! AppDelegate)
let _ = delegate.resizeWindow()
}
let showCallback: AXObserverCallbackWithInfo = { observer, element, notification, info, _ in
let delegate = (NSApplication.shared.delegate as! AppDelegate)
let _ = delegate.showOverlayWindow()
let _ = delegate.resizeWindow()
}
let hideCallback: AXObserverCallbackWithInfo = { observer, element, notification, info, _ in
let delegate = (NSApplication.shared.delegate as! AppDelegate)
guard !(delegate.configurationWindow.isMainWindow) else {
return
}
let _ = delegate.hideOverlayWindow()
}
addObserver(to: &resizeObserver, notifications: Self.resizeNotifications, pid: processID, callback: resizeCallback)
addObserver(to: &showObserver, notifications: Self.showNotifications, pid: processID, callback: showCallback)
addObserver(to: &hideObserver, notifications: Self.hideNotifications, pid: processID, callback: hideCallback)
}
deinit {
Task { @MainActor in
removeObserver(notifications: Self.resizeNotifications, from: &resizeObserver)
removeObserver(notifications: Self.showNotifications , from: &showObserver)
removeObserver(notifications: Self.hideNotifications, from: &hideObserver)
}
}
func addObserver(
to observer: inout AXObserver?,
notifications: [NSAccessibility.Notification],
pid: pid_t,
callback: AXObserverCallbackWithInfo
) {
AXObserverCreateWithInfoCallback(pid, callback, &observer)
for notification in notifications {
AXObserverAddNotification(observer!, uiElement, notification as CFString, nil)
}
CFRunLoopAddSource(CFRunLoopGetCurrent(), AXObserverGetRunLoopSource(observer!), .defaultMode)
}
func removeObserver(
notifications: [NSAccessibility.Notification],
from observer: inout AXObserver?
) {
notifications.forEach {
AXObserverRemoveNotification(observer!, uiElement, $0 as CFString)
}
}
}

あまり良い実装ではありませんが、AXObserverCallbackWithInfoに渡すAXObserverCallbackはCで実装されており、内部でクロージャをキャプチャすることができないためAppDelegateを直接呼び出しています。AXObserverCallbackの定義は以下のようになっています。

public typealias AXObserverCallback = @convention(c) (AXObserver, AXUIElement, CFString, UnsafeMutableRawPointer?) -> Void

Windowのイベントを監視したら、イベントが飛んでくるごとにWindowをresizeするメソッドを呼びます。 resizeのメソッドはpid経由でAXUIElementに変換し、取得されたframeに追従させています。以下のコードはPIDが持つWindowのframeを取得するためのコードです。

public static func getRect(from pid: pid_t) -> CGRect? {
// Create an AXUIElement for the target application
let targetApp = AXUIElementCreateApplication(pid)
// Get the value of kAXWindowsAttribute to obtain an array of windows
var windows: CFTypeRef?
let copyAttributeResult = AXUIElementCopyAttributeValue(targetApp, kAXWindowsAttribute as CFString, &windows)
if copyAttributeResult == .success, let windowArray = windows as? [AXUIElement] {
for window in windowArray {
return window.frame
}
} else {
print("Failed to get the list of windows for the application with PID \(pid)")
return nil
}
print("Failed to get the list of windows for the application with PID \(pid)")
return nil
}

windowArrayは[AXUIelement]でframeというプロパティを取得していますが、以下のようなextensionでプロパティを生やしています。

extension AXUIElement {
var size: CGSize? {
if let value = self.copytAttributeValue(attribute: kAXSizeAttribute, ofType: AXValue.self) {
var size: CGSize = .zero
if AXValueGetValue(value, .cgSize, &size) {
return size
} else {
return nil
}
} else {
return nil
}
}

var frame: CGRect? {
if let position = self.position, let size = self.size {
return CGRect(origin: position, size: size)
} else {
return nil
}
}

func copytAttributeValue<T>(attribute: String, ofType: T.Type) -> T? {
var value: AnyObject?
let copyPositionResult = AXUIElementCopyAttributeValue(self, attribute as CFString, &value)
if copyPositionResult == .success, let value = value as? T {
return value
} else {
return nil
}
}
}

上記のコードで取得されたframeを元にオーバーレイ用のWindowの位置を調整します。frameを設定する際の注意点として、macOSはNS系のAPIのためsetFrameする際には座標系を左上を原点とするものから、左下を原点とするものに変換する必要があります。

overlayWindow.setFrame(
.init(
x: rect.origin.x,
y: (NSScreen.main?.frame.height ?? 0) - rect.origin.y - rect.height,
width: rect.width,
height: rect.height
),
display: false
)

ここまでのコードを使用することでSimulatorのWindowに追従させることができます。

Notificationが来るタイミングは毎フレームではないため、完璧に追従することはできません。setFrameのメソッドにはanimationをするかどうかのフラグがあるのですが、ガタガタしてしまったので外しています。今回のように頻繁にsetFrameする場合には向かないようです。

画像のオーバレイを実現する

画像のオーバレイはSwiftUIでImageを配置しました。簡単なコードなので、ここでは省略します。表示する画像の選択はオーバレイ用のWindowとは別に用意し、さらにopacityやoffsetを設定できるようにしました。View間の連携はConfigurationというオブジェクトを定義し、@Observableマクロを使用して共有します。 設定の永続化をするため、overlayImageURLはcomputed-propertyにし、getとsetの際にUserDefaults経由の読み書きを行います。

@Observable class Configuration {
var isHidden: Bool = true
var opacity: Double = 0.5
var xOffset: CGFloat = 0
var yOffset: CGFloat = 0
var overlayImageURL: URL? {
get {
access(keyPath: \.overlayImageURL)
if let urlString = UserDefaults.standard.string(forKey: "overlay_image_url") {
return URL(string: urlString)
} else {
return nil
}
}
set {
withMutation(keyPath: \.overlayImageURL) {
if let urlString = newValue?.absoluteString {
UserDefaults.standard.setValue(urlString, forKey: "overlay_image_url")
}
}
}
}
}

おわりに

Accessiblity APIはそれなりに古いAPIですが、Swift自体の進化のおかげもあってそこまで使いづらさを感じることはありませんでした。 また、今回Acccessibility APIを使いつつ、ヘッダファイルなどのリソースを見ることによって、APIデザインやSwiftの進化を垣間見ることができました。 SwiftUIの登場でmacOSのアプリケーション開発も以前と比べると比較的容易になっています。 特に今年登場したObservable macroを使用することで、Window間のデータのやり取りがかなり便利になっていることが実感できました。 iOSのアプリケーション開発が容易になっていくにつれて、同時にmacOSの開発も同等ではありませんが容易になってきています。 SwiftUIにはパフォーマンスの問題や、一部の画面の実現が難しいなど課題はありますが、macOSの開発では比較的静的な画面が多いため、活用できるポイントが豊富にあります。

今回作成したアプリケーションは改善点が多く不具合も散見されますが、これらの技術なしではここまで高速に作ることはできなかったと思います。普段のiOSの開発とは違う体験ができ、工夫が必要なポイントも多く非常に楽しめました。macOSの開発では低レイヤーとのコミュニケーションが頻出するのが魅力です。新しい技術のおかげで以前よりも開発のハードルはかなり下がっているので、もしアイデアや機会があればmacOSの開発をすることで新たな知識を得られるはずです。

エウレカでは、ビジネス成長を技術面から支え、加速させることに興味関心があるiOSエンジニアを募集しています。

--

--