Wails 実践講座 #9 自動更新の実装 — 新しいバージョンを安全に届ける

読了 5分

#6 で自動更新は戦略だけ話して実装は後回しにしました。この記事がその後回しにしたものを実際のコードにします。配信したアプリはいつか新しいバージョンが出ますが、ユーザーがそれを知る方法がなければ大半は古いバージョンに留まります。ただし自動更新は作り方を誤ると #5 で苦労して積んだ署名の信頼を自ら壊すので、安全な線を守りながら実装します。

このシリーズは全 10 編、2 部構成です。

まずバージョンを埋め込む #

更新確認の出発点は「今実行中のアプリが何バージョンか」です。バージョン文字列をソースに定数で置く方法もありますが、#6 の CI でタグからリリースを作る流れに合わせるには、ビルド時にタグをバージョンとして注入するほうが綺麗です。Go の -ldflags がビルド時に変数の値を埋めてくれます。

version.go — ビルド時に注入されるバージョン
package main

// ビルド時に -ldflags "-X main.Version=v1.2.0" で埋める
var Version = "dev"

CI では git tag がそのままバージョンなので、ワークフローがタグの値を -ldflags で渡してビルドすれば、リリースとアプリ内のバージョンが常に一致します。開発中は既定値 dev のままで、更新確認を飛ばせます。

最新バージョンの照会 #

GitHub Releases は最新のリリースを知らせる API を提供します。アプリの起動時(またはユーザーが「更新を確認」を押したとき)にこの API を呼んで最新のタグを受け取り、今のバージョンと比較します。

update.go — 最新リリースのタグを照会
type release struct {
	TagName string `json:"tag_name"`
	HTMLURL string `json:"html_url"`
}

func latestRelease(ctx context.Context) (release, error) {
	url := "https://api.github.com/repos/OWNER/REPO/releases/latest"
	req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return release{}, err
	}
	defer resp.Body.Close()

	var r release
	if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
		return release{}, err
	}
	return r, nil
}

バージョンの比較は文字列比較でしてはいけません。v1.10.0v1.9.0 より小さいと誤って判断するからです。semver(意味的バージョニング)のルールで比較する必要があり、golang.org/x/mod/semver のような標準系のライブラリを使えば安全です。

現在のバージョンより高いときだけ更新を通知
func (a *App) CheckForUpdate() {
	if Version == "dev" {
		return // 開発ビルドは確認しない
	}
	r, err := latestRelease(a.ctx)
	if err != nil {
		runtime.LogWarning(a.ctx, "更新確認に失敗: "+err.Error())
		return // 失敗は静かに流す — アプリの利用を妨げない
	}
	if semver.Compare(r.TagName, Version) > 0 {
		runtime.EventsEmit(a.ctx, "update:available", r) // フロントに通知
	}
}

更新確認の失敗はアプリを妨げません。ネットワークがないか GitHub が応答しなくても、ノートアプリ自体はオフラインで問題なく動くべきなので、失敗はログだけ残して静かに流します。

通知型が安全な既定値 #

新しいバージョンがあれば何をするか。#6 で二つの道を見ました。個人・小規模の安全な既定値は通知型です。アプリ内に「新しいバージョン v1.2.0 があります — リリースを開く」のようなバナーを出し、ユーザーがクリックすると署名・公証されたインストーラーが上がったリリースページをブラウザで開きます。

frontend — 更新バナー
import { BrowserOpenURL } from "../wailsjs/runtime/runtime";

EventsOn("update:available", (r) => {
  showBanner(`新しいバージョン ${r.tag_name} があります`, () => {
    BrowserOpenURL(r.html_url); // リリースページをブラウザで
  });
});

この方式の利点は #5 の署名の流れと衝突しないことです。ユーザーはいつもどおり署名されたインストーラーをダウンロードしてインストールするので、OS の検証がそのまま働きます。実装が単純で壊れる箇所が少ないです。

完全自動の置き換えが危険な理由 #

新しいバイナリをアプリが自分でダウンロードして置き換える完全自動の方式は滑らかに見えますが、実践では守るべきことが多いです。

  • 署名の検証: ダウンロードしたバイナリが本当に自分の署名かを検証しないと、中間者攻撃で悪意あるバイナリが紛れ込みかねません。自動の置き換えは #5 の信頼をむしろ削るリスクを抱えます。
  • 権限と場所: インストールパス(Program Files、Applications)は書き込み権限がないことが多く、自分のファイルを自分で上書きするのがプラットフォームごとに厄介です。
  • ロールバック: 置き換えの途中で失敗するとアプリが実行不能になりかねないので、失敗時に以前のバージョンへ戻す安全装置が必要です。

だから完全自動は、検証・権限・ロールバックをすべて引き受ける準備ができたとき、そしてユーザー規模が大きくて手動の案内が負担になるときだけ進みます。それまでは通知型が正解です。自分で作るより、検証済みの更新フレームワークを付けるほうが安全な場合が多いことも一緒に覚えておきます。

バージョンの流れを一目で #

ここまでが一つにつながります。#6git tag v1.2.0 をプッシュすると CI がそのタグを -ldflags でアプリに注入してビルドし、署名・公証してリリースに上げます。ユーザーのアプリは起動時に自分のバージョン(v1.1.0)とリリースの最新タグ(v1.2.0)を semver で比較し、高ければバナーで通知します。タグ一つがビルド・バージョン・更新通知を貫くこの一貫性が、前の各編を別々に作らず織り合わせてきた理由です。

まとめ #

  • 更新確認の出発点はアプリの現在のバージョンです。CI のタグを -ldflags でビルド時に注入し、リリースとアプリのバージョンを一致させます。
  • GitHub Releases API で最新のタグを照会し、文字列ではなく semver のルールで比較します。確認の失敗はアプリを妨げず静かに流します。
  • 個人・小規模の安全な既定値は通知型です。バナーで通知して署名されたリリースページを開き、#5 の署名の検証をそのまま生かします。
  • 完全自動の置き換えは署名の検証・権限・ロールバックをすべて引き受ける必要があり、誤ると署名の信頼を削ります。規模が大きくなったときだけ検討します。
  • タグ一つがビルド・バージョン注入・更新通知を貫きます。次の記事では設定・ダークモード・データバックアップでアプリを仕上げます。
X