togo

= ひとりごと to go

zumuya の人による机の上系情報サイト

applicationShouldTerminate(_:) で丁寧に終了しよう

  • Apple
  • ソフトウェア
  • 開発

Mac アプリケーションを開発するなら、数ある Delegate の中でも NSApplicationDelegate は無視できない存在だ。アプリケーションの起動や終了、ファイルを開くときなど、ことあるたびにこの Delegate のメソッドが呼び出され、その先の挙動をカスタマイズすることができる。

最近では SwiftUI を使って開発することもあるが、たとえ SwiftUI の App に Scene を並べる「フル SwiftUI」と呼べるような使い方であっても痒いところが出てきたらこれが重宝する。

この使い方は何もハック的に AppKit 側の世界に手を伸ばさずとも @NSApplicationDelegateAdaptor という標準の API でカバーされている。使い方は簡単で、通常通りに Delegate のクラスを定義したらこれをつけて App 内にプロパティとして宣言するだけだ。

import Cocoa

@MainActor class AppDelegate: NSObject, NSApplicationDelegate
{
}
AppDelegate を定義して
import SwiftUI

@main struct MyApp: App
{
    @NSApplicationDelegateAdaptor private var appDelegate: AppDelegate

    var body: some Scene
    {
        ...
    }
}
App のプロパティにするだけ

終了を延期したい

ここからが本題。最近いくつか Mac アプリケーションを作っていて終了前に少し長めの処理をさせたいことがでてきた。たとえば録音の停止。録音中にそのまま終了すると再生できないファイルが生成されてしまうので後処理が必要だ。

「終了前に処理」と考えると単純に applicationWillTerminate(_:) が思い浮かぶ。確かにそのためのメソッドだがこれは呼ばれたあとすぐにそのまま終了に突き進んでしまう。

秒単位で時間がかかるのであればユーザに見えるよう UI 上に進捗状況を表示したいだろうし、それどころかその処理が失敗したらユーザにエラーを見せて終了をキャンセルしないといけないなど、この単純なコールバックだけでは不十分だ。

AppKit にはそのための機能があり、NSApplicationDelegate にメソッドが用意されている。

func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply

will ではなく should なのがポイント。しかも返り値は Bool ではなく特別な型だ。これはいったい何なのだろう。

ユーザが command + Q を押すと NSApplication.terminate(_:) が呼ばれる。それだけでは終了が決定したことを意味せず、Delegate の applicationShouldTerminate(_:) が呼ばれ、その返り値に応じて挙動が変わるのだ。

その返り値となるのが次の型。

public enum TerminateReply : UInt, @unchecked Sendable {
    case terminateCancel = 0
    case terminateNow = 1
    case terminateLater = 2
}
  • terminateCancel:終了をキャンセル
  • terminateNow:このまま終了
  • terminateLater:あとで終了

というように、名前から想像できるままの意味だ。だけど、最後の terminateLater は使い方を想像しにくい。これを返したらどのタイミングで終了するの?

鐘を鳴らすのはあなたである。終了の可否が決まったらあなた自身が NSApplication に対して次のメソッドを呼ぶ必要がある。

 open func reply(toApplicationShouldTerminate shouldTerminate: Bool)

shouldTerminate に true を渡せばそのまま終了するのはもちろんのこと、false を渡せば終了をキャンセルできる。そこでエラーを表示してもいいだろう。

ここまでをまとめると、使い方の雰囲気は次のようになる。Task を使えば await が入っても大丈夫だ。もちろん処理が終わったらどんな場合でも reply を呼び忘れないように注意する。

func applicationShouldTerminate(_ application: NSApplication) -> NSApplication.TerminateReply
{
    if Recorder.shared.isRecording {
        Task {
            //ここはあとで実行される
            do {
                try await Recorder.shared.stopRecording()
                application.reply(toApplicationShouldTerminate: true)
            } catch let error {
                //エラーを表示して終了をキャンセル
                application.reply(toApplicationShouldTerminate: false)
                application.presentError(error)
            }
        }
        return .terminateLater //終了するかどうかはあとで決める
    } else {
        return .terminateNow //このまま終了
    }
}

UI 上の注意点としては、この reply 待ちの状態でもユーザが UI を操作できてしまうことだ。アプリケーションが終了するはずのタイミングでユーザが新しいことを始めないように、何らかの方法でウインドウへの操作をブロックしたほうがいいだろう。

この API を使ったわかりやすい例は「保存しますか?」のダイアログ。ユーザがメニューから終了を選んでもダイアログが出てるかぎりは終了しないし、キャンセルボタンを押せば終了そのものがキャンセルされる。そしてシートとしてダイアログが出ているからウインドウの UI に対する操作はきちんとブロックされているのだ。

Apple の細かい工夫

この API はよく見ると細かい作り込みがあって、command + Q を押したあとにアプリケーションメニューのハイライトがそのまま残ってくれるのだ。ちょっとした工夫だけど「終了の途中です」感がよく出てて好き。

Share

リンクも共有もお気軽に。記事を書くモチベーションの向上に役立てます。