Wailsでデスクトップアプリを作る #4 システム統合 — ダイアログ・メニュー・ウィンドウ制御

読了 6分

「Wailsでデスクトップアプリを作る #3 Goとフロントエンドの連携 — メソッドバインディングとイベント」までの作業は、すべてウィンドウの中で完結していました。ボタンを押すとGoのメソッドが実行され、結果が画面に反映される流れは整いましたが、このままではブラウザのタブで動くWebアプリと変わりません。デスクトップアプリをデスクトップアプリらしくするのは、OSとの統合です。ファイルを開くネイティブダイアログ、画面上部のメニューバー、ウィンドウのサイズと位置の制御が今回のテーマです。

全 8 編(本編 6 編 + 応用 2 編)です。

  • #1 Wailsとは — Goで作る軽量デスクトップアプリ
  • #2 プロジェクト構造と開発ループ — wails devとバインディング
  • #3 Goとフロントエンドの連携 — メソッドバインディングとイベント
  • #4 システム統合 — ダイアログ・メニュー・ウィンドウ制御 ← この記事
  • #5 実践機能 — 設定の保存とエラー処理
  • #6 ビルドと配布 — プラットフォーム別パッケージング

今回扱う機能はすべてgithub.com/wailsapp/wails/v2/pkg/runtimeパッケージに集まっています。共通のルールはひとつです。すべてのランタイム関数は第1引数としてcontextを受け取ります。#3でstartupフックから受け取ったctxを構造体に保管してあれば、準備は済んでいます。

ネイティブダイアログ — ファイルを開く・保存する #

Webでファイルを開くには<input type="file">を使いますが、WailsアプリではOSのネイティブダイアログを直接表示できます。ユーザーが慣れ親しんだ見た目がそのまま出て、パス文字列を受け取ってGoですぐファイルを扱えます。

app.go — ファイルを開くダイアログ
func (a *App) OpenTodoFile() (string, error) {
	path, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{
		Title: "ToDoファイルを開く",
		Filters: []runtime.FileFilter{
			{DisplayName: "JSONファイル (*.json)", Pattern: "*.json"},
		},
	})
	if err != nil || path == "" {
		return "", err // ユーザーがキャンセルすると path は空文字列
	}
	data, err := os.ReadFile(path)
	if err != nil {
		return "", err
	}
	return string(data), nil
}

保存はSaveFileDialogが担当します。デフォルトのファイル名を提案できる点だけが違います。

app.go — 保存ダイアログ
path, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
	Title:           "エクスポート",
	DefaultFilename: "todos.json",
})

ユーザーに何かを確認したり知らせたりするときはMessageDialogを使います。タイプによってアイコンとボタン構成が変わります。

app.go — 確認ダイアログ
choice, err := runtime.MessageDialog(a.ctx, runtime.MessageDialogOptions{
	Type:    runtime.QuestionDialog,
	Title:   "削除の確認",
	Message: "完了した項目をすべて削除しますか?",
})
// macOS では choice は "Yes" または "No" の文字列で返ります
注記
ダイアログでキャンセルを押すのはエラーではありません。OpenFileDialogはキャンセル時に空文字列とnilエラーを返すため、空文字列のチェックを省くと、キャンセルが「空のパスを開こうとする試み」につながります。キャンセルを正常な流れとして扱う習慣が必要です。

アプリケーションメニュー — メニューバーとショートカット #

メニューバーはmenuパッケージで構成し、アプリのオプションに接続します。メニュー項目ごとにショートカットとクリックコールバックを指定できます。

main.go — メニューの構成
import (
	"github.com/wailsapp/wails/v2/pkg/menu"
	"github.com/wailsapp/wails/v2/pkg/menu/keys"
)

appMenu := menu.NewMenu()
fileMenu := appMenu.AddSubmenu("File")
fileMenu.AddText("Open", keys.CmdOrCtrl("o"), func(_ *menu.CallbackData) {
	runtime.EventsEmit(app.ctx, "menu:open")
})
fileMenu.AddSeparator()
fileMenu.AddText("Quit", keys.CmdOrCtrl("q"), func(_ *menu.CallbackData) {
	runtime.Quit(app.ctx)
})

err := wails.Run(&options.App{
	Title: "todo",
	Menu:  appMenu,
	// ...
})

keys.CmdOrCtrlはmacOSではCmd、WindowsとLinuxではCtrlに自動でマッピングされます。プラットフォームごとの分岐を自分で書く必要はありません。メニューのコールバックからフロントエンドの動作を起こしたい場合は、上のように#3で扱ったイベントを発行し、フロントエンドでEventsOnを使って受け取る構成がきれいです。

ヒント
macOSでは、アプリ名メニューとEditメニューがあってはじめてコピー・ペーストのショートカットが自然に動作します。menu.AppMenu()menu.EditMenu()appMenu.Appendで追加しておくと、標準項目が埋められます。

ウィンドウ制御 — サイズ、位置、状態 #

ウィンドウに対する操作もランタイム関数で処理します。よく使うものを挙げると次のとおりです。

関数動作
WindowSetTitle(ctx, t)タイトルバーのテキストを変更
WindowSetSize(ctx, w, h)ウィンドウサイズを変更
WindowSetMinSize / WindowSetMaxSizeサイズを制限
WindowCenter(ctx)画面中央に配置
WindowFullscreen / WindowUnfullscreenフルスクリーン切り替え
WindowGetSize / WindowGetPosition現在のサイズ・位置を取得

初期サイズと最小サイズはoptions.AppWidthHeightMinWidthMinHeightで決めるほうが安定していて、ランタイム関数は実行中に動的に変えるときに使います。ドキュメントの編集中なら、WindowSetTitleでタイトルに変更マークを付ける、といった使い方です。

ウィンドウを閉じるときに最後のサイズと位置を保存しておき、次回の起動で復元するパターンも、これらの関数の組み合わせで作ります。WindowGetSizeWindowGetPositionで読み取って設定ファイルに保存し、startupでWindowSetSizeWindowSetPositionで戻します。保存そのものは次回のテーマです。

クリップボード #

クリップボードへのアクセスは2つの関数で済みます。

app.go — クリップボード
func (a *App) CopyResult(text string) error {
	return runtime.ClipboardSetText(a.ctx, text)
}

func (a *App) PasteText() (string, error) {
	return runtime.ClipboardGetText(a.ctx)
}

ブラウザのクリップボードAPIが権限プロンプトとセキュアコンテキストの制約を要求するのと違い、デスクトップアプリはOSレベルで直接アクセスします。「コピーしました」のフィードバックのようなUI処理だけがフロントエンドに残ります。

デフォルトブラウザで開く、そしてセキュリティ境界 #

アプリの中で外部リンクを押したとき、WebViewの中でページが開くのは多くの場合望ましい動作ではありません。システムのデフォルトブラウザに渡します。

外部リンクはデフォルトブラウザへ
runtime.BrowserOpenURL(a.ctx, "https://wails.io")

この地点で、Wailsアプリのセキュリティ構造を一度整理しておく必要があります。フロントエンドはWebViewの中で動くため、勝手にローカルファイルを読んだりプロセスを実行したりはできません。OSのリソースに触れる動作は、すべてGo側のメソッドを経由します。何を公開するかは開発者がバインディングで決めるので、ファイルパスを受け取ってそのまま開くメソッドのように範囲の広い入口を作るときは、検証を一緒に置くのが安全です。

まとめ #

今回の要点は3つです。

  • OS統合の機能はruntimeパッケージに集まっていて、すべての関数がcontextを第1引数に受け取ります。
  • ダイアログはキャンセル(空文字列)を正常な流れとして処理し、メニューはイベント発行でフロントエンドとつながります。
  • ローカルリソースへのアクセスはすべてGoのメソッドを経由するため、バインディングの設計がそのままアプリのセキュリティ境界になります。

次回の「Wailsでデスクトップアプリを作る #5 実践機能 — 設定の保存とエラー処理」では、ToDoデータをアプリを再起動しても残るようにし、Goのerrorがフロントエンドの通知までつながる処理の流れを整理します。

X