Go言語とwebviewで簡単デスクトップアプリを作ろう

Go言語でデスクトップアプリを作るのは、なかなかハードルが高いと感じています。それで、ブラウザベースの「webview」を使うと、手軽にUIが作れます。（ちなみに、[日本語プログラミング言語「なでしこ3」](https://nadesi.com)でWin/Mac向け配布パッケージを作ったので、その技術的調査のまとめです。）

# webview/webview を使う場合

[webview/webview](https://github.com/webview/webview)はSafari/EdgeなどOSに最初からインストールされているブラウザのコンポーネントを利用してHTMLを表示するパッケージです。これを使うことで、配布サイズがElectronなどのアプリに比べて小さくなるのが特徴です。Win/Mac/Linuxで同じように使えます。

非常によくできたパッケージです。ただし、欠点があって、Windowsでうまく動かないことが多いです。また、macOSではalertなどのダイアログが表示されません。

使い方は以下の感じです。最初にパッケージをインストールします。

```init.sh
go mod init desktop
go get github.com/webview/webview
```

WebViewを使うには、以下のようなプログラムを作ります。

```main.go
package main

import (
    "github.com/webview/webview"
)

func main() {
	// ブラウザを起動
	debug := true
	w := webview.New(debug)
	defer w.Destroy()
	w.SetTitle("test")
	w.SetSize(640, 400, webview.HintNone)
	w.Navigate("https://nadesi.com")
	w.Run()
}
```

## macOSでalertを使いたい場合

macOSのwebviewではalertが使えないので、以下のようにBindで自作する必要があります。ただし、非同期に実行されるために、連続で実行する場合は注意が必要です。

```main.go
func main() {
	// ... 省略 ...
	w.Bind("alert", func(text string) bool {
		return messagebox(text, `"Yes"`)
	})
	// ... 省略 ...
	w.Run()
}

func messagebox(text string, buttons string) bool {
	title := GlobalInfo.Title
	script := `set T to button returned of ` +
		`(display dialog "%s" with title "%s" buttons {%s} default button "Yes")`
	out, err := exec.Command("osascript", "-e", fmt.Sprintf(script, text, title, buttons)).Output()
	if err != nil {
		if exitError, ok := err.(*exec.ExitError); ok {
			return exitError.Sys().(syscall.WaitStatus).ExitStatus() == 0
		}
	}
	return strings.TrimSpace(string(out)) == "Yes"
}
```

# Windowsでwebview2を使う

Windowsでは、webview2を使うとかなり良いです。ただし、WebView2ランタイムのインストールが必要となります。

 - [WebView2のランタイムのインストールはこちら](https://developer.microsoft.com/en-us/microsoft-edge/webview2/)

go-webview2をインストールします。

```init.sh
go get github.com/jchv/go-webview2
```

go-webview2を利用するには、以下のプログラムを作ります。

```main.go
package main

import (
	webview "github.com/jchv/go-webview2"
)

func main() {
	// ブラウザを起動
	debug := true
	w := webview.New(debug)
	defer w.Destroy()
	w.SetTitle("test")
	w.SetSize(640, 400, webview.HintNone)
	w.Navigate("https://nadesi.com/")
	w.Run()
}
```

## macOSとWindowsでソースコードを切り替える

ソースコードに以下の宣言を書くと、OSごと(macOSとWin)にソースコードを切り替えることができます。

```main_mac.go
//go:build darwin || linux
// +build darwin linux
...
```

```main_win.go
//go:build windows
// +build windows
...
```

これでなんとか良い感じにWin/Macで分岐できて便利です。

しかし、WebView2のランタイムのインストールが結構面倒です。また、当然ながら、Win/Macでソースを切り替えるのが面倒です。そこで同一ソースでプログラムを動かす方法として「lorca」を使う方法があります。

# Chromeをランタイムに使う「lorca」

[lorca](https://github.com/zserge/lorca)はChromeがインストールされていれば動くので便利です。上記、WebView2のランタイムのインストールは比較的面倒なので、Chromeを動作ランタイムと考えて使えば、インストールも容易なのでかなり便利です。WebView2の動作が安定するまでは、lorcaを使う

```init.sh
go get github.com/zserge/lorca
```

プログラムは以下の通りです。めちゃくちゃ簡単。

```main.go
package main

import (
	"github.com/zserge/lorca"
)

func main() {
	// ブラウザを起動
	ui, _ := lorca.New("https://nadesi.com", "", 800, 600)
	defer ui.Close()
	<-ui.Done()
}
```

# webviewとローカルサーバーを動かす

上記だけだとブラウザだけです。そこで、テクニックとして、ローカルサーバーを動かして、WebViewで表示するようにします。

```main.go
package main

import (
    "github.com/zserge/lorca"
    "net/http"
    "strconv"
    "fmt"
    "log"
    "net"
    "strings"
)

var PortNo int // 自動的に空きポート番号を探す

func StartServer() string {
    // set handler
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        // ここで表示したいHTMLを指定
        w.WriteHeader(200)
        w.Header().Set("Content-Type", "text/html; charset=utf8")
        w.Write([]byte("<html><body><h1>Hello</h1></body></html>"))
    })
    // start server
    addr := "127.0.0.1:" + strconv.Itoa(PortNo)
    fmt.Printf("[Server] http://%s\n", addr)
    err := http.ListenAndServe(addr, nil)
    if err != nil {
        log.Fatal(err)
    }
    return addr
}

func checkPort() {
    // 適当に空いているポートを探す
    l, err2 := net.Listen("tcp", "127.0.0.1:0")
    if err2 != nil {
        // 空きポートの検索に失敗
        log.Fatal(err2)
    }
    // ポート番号を得る
    addr := l.Addr().String()
    a := strings.Split(addr, ":")
    pno, err3 := strconv.Atoi(a[1])
    if err3 != nil {
        log.Fatal(err3)
    }
    PortNo = pno
    l.Close() // HTTPサーバーの起動前にソケットを閉じておく
}

func indexHandler(w http.ResponseWriter, r *http.Request) {
    w.WriteHeader(200)
    w.Header().Set("Content-Type", "text/html; charset=utf8")
    w.Write([]byte("<html><body><h1>Hello</h1></body></html>"))
}

func main() {
    // ローカルサーバーを起動
    checkPort()
    go StartServer()
    // WebViewを起動
    addr := "127.0.0.1:" + strconv.Itoa(PortNo)
    fmt.Printf("%s\n", addr)
    ui, _ := lorca.New("http://" + addr + "/", "", 800, 600)
    defer ui.Close()
    <-ui.Done()
}
```

# 参考

 - [nadesiko3webkit](https://github.com/kujirahand/nadesiko3webkit) ... なでしこ3で作ったゲームやツールをWin/Macで手軽に配布するプロジェクト
 - [マイナビ/100行未満かつGo標準ライブラリだけで作る掲示板](https://news.mynavi.jp/article/gogogo-9/)