1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

macOS でシステムカーソルが非表示かを判定する ― dlsym で CGCursorIsVisible を引き、NSCursor.currentSystem の画像サイズで補う(Swift)

1
Posted at

対象読者と動作環境

macOS で「他のアプリがシステムカーソルを消したか」を判定したい人、Swift から SDK 経由では呼べない C 関数を dlsym で呼ぶ方法を知りたい人を対象にしています。題材はメニューバー常駐アプリ「Neon Pointer」の v1.3.0 で追加した「カーソルが消えたらネオンも隠す」モードです(既定は OFF)。

アプリの動作環境とビルド設定(project.yml)は次のとおりです。

  • macOS 14 以上(deploymentTarget が 14.0)
  • Swift 5.0(SWIFT_VERSION: "5.0")
  • XcodeGen(ビルド時のみ)

記事中の検証スクリプトは、次の環境で実行しました。

  • macOS 26.6.2(Apple Silicon / arm64)
  • Xcode 27.0(27A266a)、macOS 27.0 SDK
  • Apple Swift 6.4(swiftc で直接コンパイル)

リポジトリは Tanishi-z/NeonPointer です。なぜ2つの判定を重ねたのか、既定を OFF にした判断などの設計の話は、ペアの Zenn 版で扱っています。この記事はコード、手順、実際の出力に絞ります。

SystemCursorVisibility の全体像

判定は新規ファイル SystemCursorVisibility.swift の25行にまとまっています。全文です。

SystemCursorVisibility.swift
import AppKit

/// システムカーソルが表示中かどうかを返す。
/// ブラウザの全画面動画などは `CGDisplayHideCursor` ではなく、1x1 の透明な
/// カーソル画像に差し替えて隠すため、現在のカーソル画像の大きさで判定する。
/// `CGCursorIsVisible` は非推奨で SDK のヘッダから外れているため `dlsym` で引き、
/// こちらの方法で隠すアプリにも備える。シンボルが無ければ画像の判定だけを使う。
@MainActor
enum SystemCursorVisibility {
    private typealias IsVisibleFunction = @convention(c) () -> Int32

    private static let isVisibleFunction: IsVisibleFunction? = {
        guard let handle = dlopen(nil, RTLD_NOW),
              let symbol = dlsym(handle, "CGCursorIsVisible") else { return nil }
        return unsafeBitCast(symbol, to: IsVisibleFunction.self)
    }()

    static func isVisible() -> Bool {
        if let isVisibleFunction, isVisibleFunction() == 0 { return false }
        if let size = NSCursor.currentSystem?.image.size, size.width <= 1, size.height <= 1 {
            return false
        }
        return true
    }
}

isVisible() は次の順に判定します。

  1. CGCursorIsVisible が取れていて、0 を返したら非表示
  2. NSCursor.currentSystem の画像が幅・高さとも 1 以下なら非表示
  3. どちらにも当てはまらなければ表示中

どちらかの手がかりが取れないとき(シンボルが無い、currentSystem が nil)は、その判定を飛ばします。どちらも取れなければ true(表示中)を返します。判定に失敗したときは、ネオンを出し続ける側に倒れるということです。

dlopen/dlsym と @convention(c) で Swift から呼べない関数を呼ぶ

Swift から直接呼ぶとコンパイルエラーになる

コードのコメントには「SDK のヘッダから外れている」とあります。ただ、手元の macOS 27.0 SDK を grep すると、宣言自体は残っていました。

$ grep -rn "CGCursorIsVisible" "$(xcrun --show-sdk-path)/System/Library/Frameworks/CoreGraphics.framework/Headers/"
/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk/System/Library/Frameworks/CoreGraphics.framework/Headers/CGRemoteOperation.h:203:CG_EXTERN boolean_t CGCursorIsVisible(void)
CGRemoteOperation.h
/* Return true if the mouse cursor is visible, false otherwise. */

CG_EXTERN boolean_t CGCursorIsVisible(void)
    API_DEPRECATED("No longer supported", macos(10.3,10.9));

宣言は残っていますが、Swift から直接呼ぶとコンパイルが通りません。

direct.swift
import CoreGraphics
print(CGCursorIsVisible())
$ swiftc direct.swift -o direct
direct.swift:2:7: error: 'CGCursorIsVisible()' is unavailable in macOS: No longer supported
1 | import CoreGraphics
2 | print(CGCursorIsVisible())
  |       `- error: 'CGCursorIsVisible()' is unavailable in macOS: No longer supported
3 | 

/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX27.0.sdk/System/Library/Frameworks/CoreGraphics.framework/Headers/CGRemoteOperation.h:203:21: note: 'CGCursorIsVisible()' has been explicitly marked unavailable here
201 | /* Return true if the mouse cursor is visible, false otherwise. */
202 | 
203 | CG_EXTERN boolean_t CGCursorIsVisible(void)
    |                     `- note: 'CGCursorIsVisible()' has been explicitly marked unavailable here
204 |     API_DEPRECATED("No longer supported", macos(10.3,10.9));
205 | 

-target arm64-apple-macos14.0 を付けても同じエラーでした。Apple のドキュメントでも CGCursorIsVisible() は非推奨で、「There is no replacement.」とされています。一方、シンボル自体は CoreGraphics の .tbd に _CGCursorIsVisible として載っています。そこで、実行時にシンボルを探して呼びます。

dlopen(nil, RTLD_NOW) で得られるハンドル

dlopen(3) には、path に NULL を渡したときの挙動が次のように書かれています(手元の macOS 26.6 の man ページも同じ文面です)。

If a null pointer is passed in path, dlopen() returns a handle equivalent to RTLD_DEFAULT.

RTLD_DEFAULT を渡した dlsym は、プロセスに読み込まれたすべての Mach-O イメージ(RTLD_LOCAL で読み込まれたものを除く)を、読み込まれた順に探します(dlsym(3))。NeonPointer は AppKit をリンクしているので、CoreGraphics もプロセス内に読み込まれています。そのため、CGCursorIsVisible も見つかります。

同じ man ページには、RTLD_DEFAULT での検索は「costly」なので避けるべきだとも書かれています。isVisibleFunction は static let なので、初期化クロージャは最初にアクセスされたときに1回だけ走ります。毎フレームの判定で検索を繰り返すことはありません。

@convention(c) の関数型に unsafeBitCast する

dlsym が返すのは UnsafeMutableRawPointer です。これを C の関数ポインタとして呼べるよう、@convention(c) の関数型に unsafeBitCast します。

戻り値の型 boolean_t は、SDK の mach/arm/boolean.h では int、mach/i386/boolean.h では x86_64 向けに unsigned int と定義されています。どちらも 32 ビットです。コードは 0 と比べるだけなので、Int32 で受けて問題ありません。なお、型を間違えても unsafeBitCast はコンパイルを通してしまいます。シグネチャはヘッダの宣言と照らし合わせて決める必要があります。

実際に呼んでみる

NeonPointer と同じ手順でシンボルを引き、NSCursor.currentSystem の画像サイズと合わせて出力するスクリプトです。scratch ディレクトリで実行しました。

probe.swift
import AppKit

typealias IsVisibleFunction = @convention(c) () -> Int32

let handle = dlopen(nil, RTLD_NOW)
print("dlopen(nil):", handle as Any)
let symbol = handle.flatMap { dlsym($0, "CGCursorIsVisible") }
print("dlsym CGCursorIsVisible:", symbol as Any)

if let symbol {
    let f = unsafeBitCast(symbol, to: IsVisibleFunction.self)
    print("CGCursorIsVisible() =", f())
}

if let cursor = NSCursor.currentSystem {
    print("NSCursor.currentSystem image.size =", cursor.image.size, "hotSpot =", cursor.hotSpot)
} else {
    print("NSCursor.currentSystem = nil")
}
$ swiftc probe.swift -o probe && ./probe
dlopen(nil): Optional(0xfffffffffffffffe)
dlsym CGCursorIsVisible: Optional(0x000000018e3d3c68)
CGCursorIsVisible() = 1
NSCursor.currentSystem image.size = (28.0, 40.0) hotSpot = (5.0, 5.0)

dlopen(nil) が返した 0xfffffffffffffffe は -2 で、dlfcn.h の #define RTLD_DEFAULT ((void *) -2) と一致します。カーソルが見えている状態では、CGCursorIsVisible() は 1 を返しました。画像サイズは、実行した瞬間のカーソルの形によって変わります。

シンボルが見つからない場合は、dlsym が nil を返すだけでクラッシュはしません。同じハンドルで存在しない名前 CGCursorIsVisibleXXX を引き、dlerror() を表示した結果です。

missing symbol: nil
dlerror: dlsym(RTLD_DEFAULT, CGCursorIsVisibleXXX): symbol not found

非表示状態の値は取れなかった

カーソルが非表示のときに 0 が返ることも確かめたかったのですが、今回の環境では取れませんでした。probe.swift と同じ方法で値を読み、間に CGDisplayHideCursor(CGMainDisplayID()) と 0.2 秒の待ちを挟むスクリプトをターミナルから実行しました。値は変わりませんでした。

$ ./hide
before: CGCursorIsVisible=1 currentSystem.image.size=(28.0, 40.0)
CGDisplayHideCursor -> 0
after hide: CGCursorIsVisible=1 currentSystem.image.size=(28.0, 40.0)
after show: CGCursorIsVisible=1 currentSystem.image.size=(28.0, 40.0)

CGDisplayHideCursor のドキュメントには「In most cases, the caller must be the foreground application to affect the cursor.」とあります。ターミナルから起動したプロセスは前面のアプリではないので、これに該当したと考えられます。フルスクリーン動画の再生中に 0 や 1x1 が返ることは、今回は検証しておらず、出力として示せていません。

NSCursor.currentSystem の画像サイズによる判定

NSCursor.currentSystem は、どのアプリが設定したかに関係なく、いまシステムに表示されているカーソルを返します。ドキュメントの説明は次のとおりです。

This method returns the current system cursor regardless of which application set the cursor, and whether Cocoa or Carbon APIs were used to set it.

この画像サイズを見るのは、ブラウザが CGDisplayHideCursor を使わずにカーソルを消すことがあるからです。CSS の cursor: none に対して、Chromium と WebKit はどちらも 1x1 の空の NSImage からカーソルを作っています。

Chromium ui/base/cocoa/cursor_utils.mm
NSCursor* NoneNSCursor() {
  static NSCursor* cursor = [[NSCursor alloc]
      initWithImage:[[NSImage alloc] initWithSize:NSMakeSize(1, 1)]
            hotSpot:NSZeroPoint];

  return cursor;
}
WebKit Source/WebCore/platform/mac/CursorMac.mm
    case Type::None:
#if ENABLE(CUSTOM_CURSOR_SUPPORT)
        m_platformCursor = adoptNS([[NSCursor alloc] initWithImage:adoptNS([[NSImage alloc] initWithSize:NSMakeSize(1, 1)]).get() hotSpot:NSZeroPoint]);
#else
        m_platformCursor = [NSCursor arrowCursor];
#endif
        break;

出典は Chromium の cursor_utils.mm と WebKit の CursorMac.mm です(いずれも 2026-10-12 時点の main)。カーソル画像の差し替えでは、隠すカウンタは動かないはずです。CGCursorIsVisible だけではこのケースを捉えられないので、画像サイズでも判定します。冒頭に載せたコードのコメントも、同じ理由を挙げています。判定の条件は size.width <= 1, size.height <= 1 で、幅と高さの両方が 1 以下のときだけ非表示とみなします。

currentSystem も非推奨になっている

注意点があります。macOS 27.0 SDK の NSCursor.h では、currentSystemCursor(Swift 名 currentSystem)が非推奨のカテゴリに移っていました(Apple のドキュメントのページでは「macOS 27.0 で deprecated」と表示されています)。

NSCursor.h(macOS 27.0 SDK)
@interface NSCursor (Deprecated)
/// This property will always be `nil` in a future version of macOS.
@property (class, readonly, nullable, strong) NSCursor *currentSystemCursor API_DEPRECATED("No longer recommended. Use ScreenCaptureKit to capture the screen. Use the `showsCursor` property on `SCStreamConfiguration` to control whether or not to include the cursor in the capture. Or, use `NSCursor.currentCursor` if needing to just get the current cursor for this application.", macos(10.6, API_TO_BE_DEPRECATED));

API_TO_BE_DEPRECATED なので、-target arm64-apple-macos27.0 でコンパイルしても警告は出ませんでした。ただ、コメントのとおり将来 nil が返るようになると、if let size = NSCursor.currentSystem?.image.size が成り立たなくなります。そのときは画像サイズの判定が素通りになり、isVisible() は CGCursorIsVisible の結果だけで決まります。

毎フレームの判定と suppressionChanged による再描画

判定は OverlayController.handleTick で毎 tick 呼びます。tick は CursorTracker が CADisplayLink で回しているもので、仕組みは v1.0.0 の実装記事で、tick を毎フレーム呼ぶようにした経緯は v1.2.0 の実装記事で書きました。v1.3.0 の handleTick です。

OverlayController.swift
/// システムカーソルが隠れていて、ネオンも隠すべき状態か。
private var isCursorSuppressed = false

private func handleTick(location: CGPoint, flags: NSEvent.ModifierFlags) {
    let suppressed = settings.hideWhenCursorHidden && !SystemCursorVisibility.isVisible()
    let suppressionChanged = suppressed != isCursorSuppressed
    isCursorSuppressed = suppressed

    let shouldDraw = settings.isEnabled && settings.trailEnabled
        && settings.trailModifier.isActive(in: flags) && !suppressed
    let locationChanged = location != lastCursorLocation

    // カーソルが静止していて、描画中でも軌跡が残ってもいなければ何もすることがない。
    guard shouldDraw || trail.isDrawing || !trail.strokes.isEmpty || locationChanged || suppressionChanged else {
        return
    }

    let now = CACurrentMediaTime()
    trail.setDrawing(shouldDraw, at: now)
    if shouldDraw, locationChanged {
        trail.append(location)
    }
    trail.prune(now: now, hold: settings.trailHoldDuration, fade: settings.trailFadeDuration)

    lastCursorLocation = location
    reposition()
}

v1.2.0 からの変更点は3つです。

1つ目は suppressed の計算です。settings.hideWhenCursorHidden && ... の短絡評価により、設定が OFF なら SystemCursorVisibility.isVisible() は呼ばれません。static let の初期化も走らないので、OFF のままなら dlopen も実行されません。

2つ目は guard への suppressionChanged の追加です。v1.2.0 の guard は、カーソルが静止していて軌跡も無い tick を早期 return で捨てていました。フルスクリーン動画では、カーソルを止めたままカーソルが消えます。この状態変化は locationChanged にも軌跡の条件にも現れません。前回の値と比べた suppressionChanged を条件に足すことで、表示と非表示が切り替わった tick だけは reposition() まで進むようにしています。

3つ目は shouldDraw への !suppressed の追加です。隠している間は修飾キーを押していても新しい線を描きません。trail.setDrawing(false, at:) が呼ばれるので、描いている途中の線は確定し、通常どおりフェードアウトしていきます。

判定1回あたりのコスト

設定を ON にすると、毎フレーム2つの関数を呼ぶことになります。scratch で1000回ずつ呼んで、1回あたりの時間を測りました(swiftc -O、CACurrentMediaTime() で計測)。

$ for i in 1 2 3; do ./bench; done
CGCursorIsVisible: 27.59 us/call, NSCursor.currentSystem.image.size: 56.68 us/call (acc=1000)
CGCursorIsVisible: 17.53 us/call, NSCursor.currentSystem.image.size: 31.46 us/call (acc=1000)
CGCursorIsVisible: 11.39 us/call, NSCursor.currentSystem.image.size: 19.75 us/call (acc=1000)

実行ごとのばらつきは大きいものの、どちらも数十マイクロ秒の範囲でした。一番遅かった1回目でも、2つ合わせて約 84 マイクロ秒です。120Hz のディスプレイの1フレーム(約 8.3ms)に対して約1%にあたります。測定はこの環境での値で、他の機種で同じになるかは確かめていません。

ビューではなく描画だけを隠す理由

隠し方は reposition() の1行です。

OverlayController.swift
for overlay in overlays {
    let cursorOnScreen = overlay.panel.frame.intersects(cursorRect)
    let trailOnScreen = trailRect.map { overlay.panel.frame.intersects($0) } ?? false
    // どちらの外接矩形もそのスクリーンに掛からないなら、合成コストを避けるため隠す。
    // カーソルが隠れているときもビュー自体は隠さない。隠すとディスプレイリンクが止まり、復帰を検知できなくなる。
    overlay.view.isHidden = !cursorOnScreen && !trailOnScreen
    overlay.view.setCursorVisible(cursorOnScreen && !isCursorSuppressed)
    overlay.view.update(
        cursorLocation: lastCursorLocation,
        screenOrigin: overlay.panel.frame.origin
    )
    overlay.view.updateTrail(renderItems)
}

setCursorVisible は NeonCursorView の中で、ネオンのリングのレイヤーだけを隠すメソッドです。

NeonCursorView.swift
func setCursorVisible(_ visible: Bool) {
    neonLayer.isHidden = !visible
}

isHidden の条件には isCursorSuppressed を入れていません。ビュー全体を隠したほうが合成コストは減りそうに見えます。それでもビューを残す理由が、コメントにある「ディスプレイリンクが止まる」です。

CursorTracker は、スクリーンごとのビューから displayLink(target:selector:) でディスプレイリンクを作っています。

CursorTracker.swift
func start() {
    guard displayLink == nil, let hostView else { return }
    let link = hostView.displayLink(target: self, selector: #selector(tick))
    link.add(to: .main, forMode: .common)
    displayLink = link
    tick()
}

NSView.displayLink(target:selector:) のドキュメントには、次のように書かれています。

If the view is hidden, or not on any display, the callback will not be invoked.

カーソルが乗っていないスクリーンのビューは、もともと isHidden = true です(この間引きは v1.1.0 の実装記事で書きました)。そのため、ディスプレイリンクのコールバックも止まっています。tick を回しているのは、カーソルが乗っているスクリーンのビューだけです。ここでカーソルが消えたからとそのビューまで隠すと、すべてのディスプレイリンクが止まります。tick が来なければ SystemCursorVisibility.isVisible() も呼ばれず、カーソルが戻ってもネオンは隠れたままになります。

そこでビューは表示したまま、neonLayer だけを隠しています。何も描いていないビューが1枚残りますが、そのぶんの合成コストと引き換えに tick を回し続けています。

設定の追加

SettingsStore に hideWhenCursorHidden を追加しました。保存先は他の設定と同じく UserDefaults で、既定値は false です。

SettingsStore.swift
private enum Key {
    static let isEnabled = "isEnabled"
    static let hideWhenCursorHidden = "hideWhenCursorHidden"
    // ...
}

private enum Default {
    static let isEnabled = true
    static let hideWhenCursorHidden = false
    // ...
}

@Published var hideWhenCursorHidden: Bool {
    didSet { persist(hideWhenCursorHidden, forKey: Key.hideWhenCursorHidden) }
}

// init 内
hideWhenCursorHidden = defaults.object(forKey: Key.hideWhenCursorHidden) as? Bool
    ?? Default.hideWhenCursorHidden

func resetToDefaults() {
    hideWhenCursorHidden = Default.hideWhenCursorHidden
    // ...
}

defaults.object(forKey:) as? Bool ?? 既定値 という読み方は、既存の設定項目と同じです。bool(forKey:) はキーが無いと false を返すので、「未設定」と「false を保存した」を区別できません。既定値が false のこの項目では、どちらで書いても結果は同じです。resetToDefaults() の対象にも含めています。

設定画面には、「ログイン時に自動的に起動」の下にトグルを足しています。

SettingsView.swift
Toggle("カーソルが消えたらネオンも隠す", isOn: $settings.hideWhenCursorHidden)
    .toggleStyle(.switch)
    .font(.callout)
    .disabled(!settings.isEnabled)

全体の「ネオンポインタを表示」が OFF のときは、トグルを disabled にします。このときはネオン自体が出ておらず、ディスプレイリンクも止まっているので、この設定を変えても見た目は変わりません。

最後に、設定を OFF にしたときの後始末です。persist は値を保存したあと didChange を送り、OverlayController.applySettings() が呼ばれます。

OverlayController.swift
private func applySettings() {
    let configuration = NeonConfiguration(settings: settings)
    if !settings.hideWhenCursorHidden { isCursorSuppressed = false }
    // ...
    reposition()
}

カーソルが消えている最中に設定を OFF にした場合を考えます。ここで isCursorSuppressed を戻さないと、applySettings() の最後の reposition() は古い true のまま setCursorVisible(false) を通します。ネオンが戻るのは、次の tick で suppressionChanged が立ったときです。applySettings() の中で先に false に戻しておけば、その場の reposition() でネオンが戻ります。

バージョンは project.yml の MARKETING_VERSION を 1.3.0 に上げました。

NeonPointer の関連記事

1
0
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?