ひ to り go と

WKWebView 内の JavaScript から NSAlert を async await で呼び出す

MacDev

年末に反省したのに(→ 2018.12.29)またもや青いカテゴリーの記事だ1。とはいえ、いつもみたいに Cocoa ネイティブの話だけではない。

快適に“ひ to り go と”を更新するため自分専用に作っている記事エディタがある。記事やデータベースのファイルはすべてローカルにあるものを編集し、あとから手動でサーバにコピーするから 1 台の Mac 内で完結しているのだけど2、CMS のエンジンにアクセスする都合上エディタも PHP(とクライアント側の小さな JavaScript)で動いている…というのは以前書いた通りだ。

Web ブラウザ内での使用感では飽き足らずそれをラップする Cocoa App まで作ったものの、WKWebView の中では POST でフォーム上のデータを送信する仕組みのため、記事を保存するたびに画面が真っ白になって再読み込みがかかりエディタやプレビューのスクロール位置がリセットされてしまうものだった。保存せずに記事を離れたりウインドウを閉じるときでも警告すら出てくれない。

macOS 上で macOS らしくない体験はすべてノイズだ。純粋なネイティブ App ではないものの改善の余地はある。ちょうど別件で最近の JavaScript 事情を勉強していたところなので、ES2017 までの新機能をふんだんに使って記事エディタの中身を作り直したのである。

あらゆるものをウインドウの表示範囲の中で無理やり完結しているのが Electron みたいな Web ベースで作られたデスクトップ App の気持ち悪さの一因3だと思う。WKWebView にさせる仕事がほとんどだとはいえ、せっかく Mac 専用に App を作るのだ。積極的にウインドウの外にもはみ出していこう!

NSAlert を JavaScript から使う

これだけ長い前置きを書いたもののこの記事で触れるのは NSAlert だ。これをシートとして表示するだけでずいぶんと Mac App らしくなる。好きなボタンを並べられるし、“保存しない”ボタンを command + delete で押せるようにするようなカスタマイズも簡単だ。「保存しますか?」をネイティブなシートで表示させてみよう。

タイトルバーがないとシートを表示する場所に困る。

まずは Cocoa 側を準備だ。前提として、WKWebView 内の JavaScript の世界から送られてくるメッセージは WKScriptMessageHandler というプロトコルのメソッドを実装したオブジェクトが処理する。WKWebView を生成するタイミングでハンドラ(JavaScript 側から呼び出す)ごとにそれらを登録しておくとよいだろう。ここでは "showAskForSaveAlert" という名前にする。

let configuration = WKWebViewConfiguration(); do {
    let userContentController = WKUserContentController(); do {
        userContentController.add(self, name: "showAskForSaveAlert")
    }
    configuration.userContentController = userContentController
}
self.webView = WKWebView(frame: CGRect(x: 0, y: 0, width: 300, height: 300), configuration: configuration)
WKWebView の生成

具体的な処理は WKScriptMessageHandler.userContentController(_:didReceive:) で行う。message.name には先ほど登録したハンドラ名 "showAskForSaveAlert"、そして message.body には JavaScript 側から与えるパラメータが Cocoa のオブジェクトに変換されて入ってくる。単体オブジェクトや配列でもいいけど、汎用性を考えるといつも [String: Any] として必要なものとその名前を格納する習慣にしておくとよさそうだ。

受け取ったパラメータ {articleName, hasSavedIdentifier} をもとに NSAlert を表示するコードは下のような感じ。

Cocoa 側でボタンが押されたら JavaScript 側に用意した関数 _alertDidComplete() を呼び出して結果を伝える。WKWebView 内の JavaScript を実行するには WKWebView.evaluateJavaScript(_:completionHandler:) を使おう。

func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage)
{
    switch message.name {
    case "showAskForSaveAlert":
        let arguments = message.body as! [String: Any?]
        let articleName = arguments["articleName"] as! String
        let hasSavedIdentifier = arguments["hasSavedIdentifier"] as! Bool
        let alert = NSAlert(); do {
            if hasSavedIdentifier {
                alert.messageText = "Do you want to save the changes made to the article “\(articleName)”?"
                alert.informativeText = "Your changes will be lost if you don’t save them.";
            } else {
                alert.messageText = "Do you want to keep this new article “\(articleName)”?"
                alert.informativeText = "You can choose to save your changes, or delete this article immediately. You can’t undo this action.";
            }
            alert.addButton(withTitle: "Save").keyEquivalent = String(utf16CodeUnits: [unichar(NSCarriageReturnCharacter)], count: 1);
            alert.addButton(withTitle: "Cancel")
            let dontSaveButton = alert.addButton(withTitle: (hasSavedIdentifier ? "Don't Save" : "Delete")); do {
                //assign command + delete.
                dontSaveButton.keyEquivalent = String(utf16CodeUnits: [unichar(NSDeleteCharacter)], count: 1);
                dontSaveButton.keyEquivalentModifierMask = [.command]
            }
        }
        let completionHandler = { (modalResponse: NSApplication.ModalResponse) -> Void in
            let buttonIdentifier: String
            switch modalResponse {
            case .alertFirstButtonReturn:
                buttonIdentifier = "save";
            case .alertSecondButtonReturn:
                buttonIdentifier = "cancel";
            case .alertThirdButtonReturn:
                buttonIdentifier = "dontSave";
            default:
                fatalError()
            }
            self.webView.evaluateJavaScript("_alertDidComplete(\"\(buttonIdentifier)\");", completionHandler: nil)
        }
        if let window = self.webview.window {
            alert.beginSheetModal(for: window, completionHandler: completionHandler)
        } else {
            completionHandler(alert.runModal())
        }
    default:
        break
    }
}
WKScriptMessageHandler を実装

そして JavaScript の方ではアラートを表示するおおもとの関数 askForSave() を用意。ここで引数のコールバックを保持しておき、 Cocoa から _alertDidComplete() が呼び出されたら取り出す4のがポイント。

Cocoa 側に登録したハンドラは window.webkit.messageHandlers.ハンドラ名.postMessage() でパラメータを渡しながら呼び出すことができる。

let alertCompletionCallbacks = [];

const askForSave = (article, callback) => {
    if (!article.hasUnsavedChanges) { 
        return callback('dontSave');
    }
    alertCompletionCallbacks.push(callback);
    window.webkit.messageHandlers.showAskForSaveAlert.postMessage({
        articleName: article.title,
        hasSavedIdentifier: (article.savedIdentifier !== null)
    });
};
const _alertDidComplete = buttonIdentifier => { //called by Cocoa.
    let callback = alertCompletionCallbacks.pop();
    callback(buttonIdentifier);
};
JavaScript 側の定義

簡単にネイティブなアラートを表示できるようになった。記事を閉じるまえに保存するかどうかを確認させてみよう。ボタンごとに処理を分岐している。

const performCloseArticle = () => {
    try {
        askForSave(
            currentArticle,
            buttonIdentifier => {
                switch (buttonIdentifier) {
                    case 'save':
                        currentArticle.save();
                        currentArticle = new Article();
                        break;
                    case 'dontSave':
                        currentArticle = new Article();
                    case 'cancel':
                        //do nothing
                        break;
                }
            }
        );
    } catch (error) {
        presentError(error);
        throw error;
    }
};
使用例

Promise を使うと

先ほどの定義だと呼び出すときの引数としてコールバックが存在したけど、いまどきの JavaScript であれば Promise を使用する手もあるらしい。

let alertPromiseHandlers = [];

const askForSave = article => { //throws UserCancelError
    return new Promise((resolve, reject) => {
        if (!article.hasUnsavedChanges) { 
            return resolve(false);
        }
        alertPromiseHandlers.push({ resolve, reject });
        window.webkit.messageHandlers.showAskForSaveAlert.postMessage({
            articleName: article.title,
            hasSavedIdentifier: (article.savedIdentifier !== null)
        });
    });
};
const _alertDidComplete = buttonIdentifier => { //called by Cocoa.
    let handler = alertPromiseHandlers.pop();
    if (buttonIdentifier === 'cancel') {
        handler.reject(new UserCancelError());
    } else {
        handler.resolve(buttonIdentifier === 'save');
    }
};
JavaScript 側の定義(Promise バージョン)

Promise を作るときに得られる resolve(エラーなら reject)をコールバックの代わりに呼び出すというだけで基本的に同じだ。

ついでにキャンセルボタンが押されたときは Error のサブクラス UserCancelError(別の場所で定義しておく)を投げるようにしてみた。基本的には処理を途中で抜けてほしいのでこのやり方が好き。もちろんこれをキャッチしたときは何もしない。

それによって結果も保存するかどうかの二択になってシンプルだ。

const performCloseArticle = () => {
    askForSave(currentArticle).then(save => {
        if (save) {
            currentArticle.save();
        }
        currentArticle = new Article();
    }, error => {
        if (error instanceof UserCancelError) {
            //do nothing
            return;
        }
        presentError(error);
        throw error;
    });
};
Promise.then() での使用例

また、Promise であれば async の関数内から await をつけて呼び出すと直接結果を取得できる。ほかの Promise や async 関数の呼び出しが増えてもシンプルに書けるし、ユーザの操作に対してエラーを表示する処理を共通化するのも簡単だ。

const performOrPresentError = async asyncHandler => {
    try {
        await asyncHandler();
    } catch (error) {
        if (error instanceof UserCancelError) { return; }

        await presentError(error);
        throw error;
    };
}
const performCloseArticle = () => {
    performOrPresentError(async () => {
        if (await askForSave(currentArticle)) {
            currentArticle.save();
        }
        currentArticle = new Article();
    });
}; 
async await を使った使用例

ちなみに presentError() というのは NSAlert でエラーを表示してくれる関数だ。ネイティブ、ネイティブ!

App アイコン欲しい。

---

慣れないし古い知識しかなかった JavaScript。この本のおかげで少し頭が整理された気がする:

「最新の書き方でできれいに書く」が前提になっているところが気持ちいい。


  1. 実は昨年末に“買ってよかったものまとめ”を書いたりもしていたのだが内容を欲張りすぎて間に合わなかった... ↩︎

  2. 設計思想は完全にこの人の影響。 ↩︎

  3. 例えば環境設定をタブとして開く Web ブラウザなんて Mac では使いたくない。 ↩︎

  4. 複数のアラートが同時に表示されることを真面目に考えると push と pop で適当に出し入れなんてせずに識別子を持たせた方がいいかも。 ↩︎

Share

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

© 2005-2019 zumuya