Go デスクトップアプリを Windows / Mac 両対応で公開するまでの全ハマりどころ — cgo・名前付きパイプ・未署名アプリ
連載「AI エージェントに SSH を渡すのが怖いので、人間承認ゲートウェイを自作した」第7回(クロスプラットフォーム・公開編/連載の締め)。
- リポジトリ:
ssh-gete— https://github.com/yoshiyakato/ssh-gate-oss- 対象バージョン:
main(タグ未発行。本記事のコード引用は記事公開時点のmainを参照)- この回は単体でも読めます。連載の入口は第1回(コンセプト編)です。
0. この記事のゴール
ssh-gete は「AI エージェントが要求した SSH コマンドを、人間が承認してから実行する」デスクトップアプリです。第6回までで、MCP サーバー・承認フロー・Wails GUI・SQLite 永続化・シリアルコンソールまで作り終えました。
最終回のテーマは 配布です。Go + Wails で書いたこのアプリを、Windows と macOS の両方で動くバイナリにして、GitHub Releases に置くところまで持っていきます。
最初に身も蓋もない結論を書いておきます。
「Go はクロスコンパイルできるから、Windows 対応なんて
GOOS=windows go buildで終わりでしょ?」——これは半分嘘です。
確かに純粋な Go コードならそうです。しかし実アプリには、
- cgo(C コンパイラに依存するライブラリ)
- OS 固有のプロセス間通信(Unix ソケット vs Windows 名前付きパイプ)
-
環境変数の流儀の違い(
USERvsUSERNAME) - GUI の WebView(OS ごとに別物)
- コード署名(未署名だと OS に弾かれる)
が必ず混ざってきます。ssh-gete でも全部踏みました。この記事は、そのハマりどころと逃げ道の記録です。
1. ビルドタグによる OS 分離 — まず「分ける場所」を決める
クロスプラットフォーム対応の基本動作は、OS 依存のコードをファイルごと分けることです。Go にはビルドタグ(build constraints)という仕組みがあり、ファイル先頭の //go:build ... 行で「このファイルはこの条件のときだけコンパイルする」と宣言できます。
ssh-gete で一番分かりやすい例が、macOS だけで効くフレームワークリンク指定です。
// darwin_frameworks.go:1
//go:build darwin && cgo
package main
// #cgo LDFLAGS: -framework UniformTypeIdentifiers
import "C"
darwin && cgo のときだけ、UniformTypeIdentifiers フレームワークをリンクします。Windows や Linux ビルドではこのファイルは存在しないも同然になるので、#cgo LDFLAGS も評価されません。逆に言うと、OS 固有の知識をここに閉じ込めておけば、他のコードは OS を意識せずに済みます。
この「分ける」発想を、次の章では ssh-agent でフル活用します。
2. ssh-agent の罠 — Unix ソケット vs Windows 名前付きパイプ
ここが一番きれいにハマった箇所です。
ssh-gete は SSH 鍵認証のとき、ローカルの ssh-agent に署名を依頼します(鍵パスフレーズをアプリが持たずに済む)。そのために agent と接続する必要があるのですが——agent への繋ぎ方が OS でまったく違います。
| OS | agent の在りか | 繋ぎ方 |
|---|---|---|
| macOS / Linux |
SSH_AUTH_SOCK が指す Unix ドメインソケット
|
net.Dial("unix", socket) |
| Windows |
名前付きパイプ \\.\pipe\openssh-ssh-agent
|
winio で DialPipe |
Windows の OpenSSH agent は SSH_AUTH_SOCK も Unix ソケットも使いません。固定の名前付きパイプを使います。net.Dial("unix", ...) は Windows では通りません。
そこで、dialSSHAgent() という1つの関数を 2ファイルに分離しました。シグネチャ(func dialSSHAgent() (net.Conn, error))は両者で完全に同じです。
Unix 側:
// ssh_agent_unix.go:1
//go:build !windows
func dialSSHAgent() (net.Conn, error) {
socket := strings.TrimSpace(os.Getenv("SSH_AUTH_SOCK"))
if socket == "" {
return nil, errors.New("SSH_AUTH_SOCK not set")
}
return net.Dial("unix", socket)
}
Windows 側:
// ssh_agent_windows.go:16
const windowsSSHAgentPipe = `\\.\pipe\openssh-ssh-agent`
func dialSSHAgent() (net.Conn, error) {
pipe := strings.TrimSpace(os.Getenv("SSH_AUTH_SOCK"))
if pipe == "" {
pipe = windowsSSHAgentPipe // 既定の OpenSSH agent パイプ
}
return winio.DialPipe(pipe, nil)
}
Windows 側は github.com/Microsoft/go-winio の DialPipe を使います。一応 SSH_AUTH_SOCK が(パイプパスとして)セットされていればそれを優先し、無ければ既定のパイプに繋ぐ、という二段構えにしてあります。
呼び出し側の agentAuthMethods() は、この OS 差をまったく知りません。
// ssh_exec.go:173
func agentAuthMethods() []ssh.AuthMethod {
conn, err := dialSSHAgent()
if err != nil || conn == nil {
return nil
}
return []ssh.AuthMethod{ssh.PublicKeysCallback(sshagent.NewClient(conn).Signers)}
}
dialSSHAgent() が net.Conn を返してくれさえすれば、その先(golang.org/x/crypto/ssh/agent に渡す)は OS 共通です。「OS 差は1関数の中に閉じ込め、上のレイヤには net.Conn という共通の顔だけ見せる」——これがビルドタグ分離の定石です。
ハマりの実態:当初は Unix 前提で
net.Dial("unix", os.Getenv("SSH_AUTH_SOCK"))を直書きしていました。これだと Windows では agent 認証がまるごと無言で失敗します(SSH_AUTH_SOCKが無いので即 nil 返し)。dialSSHAgentという継ぎ目を1枚かませて、初めて両 OS で agent 認証が成立しました。
3. 環境変数の罠 — USER vs USERNAME
地味ですが必ず踏むのがこれです。ログインユーザー名を環境変数から取る方法が OS で違います。
- Unix(macOS / Linux):
USER - Windows:
USERNAME
ssh-gete は初回起動時、接続先のシード(雛形)として「自分のユーザー名」を埋めておきたい。素直に os.Getenv("USER") と書くと、Windows では空文字になります。
// app.go:847
func currentUsername() string {
if u := strings.TrimSpace(os.Getenv("USER")); u != "" {
return u
}
return strings.TrimSpace(os.Getenv("USERNAME"))
}
両方を見て、USER が空なら USERNAME に落ちるだけ。これだけです。ビルドタグで分けるほどでもない(実行時に両方見れば済む)ので、ここは1関数内で吸収しました。使われるのはシード接続先のデフォルト値です。
// app.go:1132
User: currentUsername(),
教訓:OS 差は「ビルド時に分ける」「実行時に分ける」の2択。net.Conn のように API ごと別実装になるならビルドタグ、単に値が違うだけなら実行時フォールバックで十分です。
4. cgo の罠と回避 — SQLite をピュア Go に乗り換えた(この回の山場)
ここが配布難度を一番下げた判断です。
4.1 cgo は「クロスコンパイルの天敵」
Go の標準ビルドは C コンパイラ不要で、GOOS / GOARCH を変えるだけで他 OS 向けにクロスコンパイルできます。ところが cgo を使った瞬間にこれが崩れます。cgo は C コードをコンパイルするので、
- ターゲット OS の C コンパイラ / クロスツールチェーンが必要になる
- Windows なら MinGW、macOS なら Xcode の clang……と、各 OS のネイティブツールチェーンに縛られる
- CI でクロスコンパイルしようとすると、ツールチェーンの導入とリンクで延々ハマる
ssh-gete の SQLite ドライバは、もともと定番の github.com/mattn/go-sqlite3 でした。これは SQLite 本体(C で書かれている)を cgo でリンクします。つまりこの1つのライブラリのために、配布全体が cgo 必須になっていました。Windows ビルドのたびに MinGW を気にする、という未来が見えていました。
4.2 modernc.org/sqlite(ピュア Go)への乗り換え
そこで、SQLite を ピュア Go 実装の modernc.org/sqlite に切り替えました。これは SQLite の C ソースを Go へトランスパイルしたもので、cgo を使いません。
go.mod を見ると、依存が modernc 系になっているのが分かります。
// go.mod:10
modernc.org/sqlite v1.52.0
// (indirect)modernc.org/libc, modernc.org/mathutil, modernc.org/memory ...
コード側の変更は驚くほど小さく、import のドライバ名と sql.Open のドライバ識別子を変えるだけでした。
// storage.go:13
import (
"database/sql"
// ...
_ "modernc.org/sqlite" // ← go-sqlite3 から差し替え
)
// storage.go:25
db, err := sql.Open("sqlite", path+"?_pragma=busy_timeout(5000)&_pragma=foreign_keys(on)")
ドライバ名は "sqlite3" から "sqlite" に変わります(mattn が sqlite3、modernc が sqlite)。database/sql の口を通しているので、クエリや upsert ロジック(第5回参照)は一切触っていません。
4.3 利点と割り切り
乗り換えの配当:
-
C コンパイラ依存が消えた。
CGO_ENABLED=0でビルドが通り、CI から MinGW / clang のお守りが消える。 - DSN の pragma 指定がクエリパラメータで素直に書ける(
?_pragma=...)。
割り切り(正直に):
- ピュア Go なので、ネイティブ実装よりわずかに遅い可能性はある。ただし
ssh-geteはローカル単一ユーザー・監査ログ用途で、SQLite は秒間数十クエリも捌けば十分です。性能要件と引き換えに配布の単純さを買った、という判断です。 - バイナリサイズは少し増えます(SQLite 相当のコードを Go で抱える)。これも配布物のサイズより「ビルドが詰まらないこと」を優先しました。
この回で一番効いたのは、新機能でも署名でもなく、**「cgo を1個消したこと」**でした。cgo が残っていると、後述の GitHub Actions が一気に複雑化します。
4.4 ただし WebView だけはクロスコンパイルしない
cgo を消しても、Wails の GUI 自体は各 OS のネイティブ WebView を使います(macOS は WKWebView、Windows は WebView2)。これらは OS のシステムライブラリに依存するので、「Linux 上で Windows 版を完全クロスコンパイル」のような芸当には固執しません。
設計判断は明確にこうしました。
cgo は外す。だがビルドは各 OS のネイティブ runner で行う。
cgo を外したのは「C ツールチェーンのお守りをやめる」ため。各 OS でビルドするのは「その OS の WebView と素直に噛み合わせる」ため。この2つは矛盾しません。次章の GitHub Actions が、まさにこの方針の実装です。
5. GitHub Actions — 両 OS のバイナリを Releases に置く
.github/workflows/release.yml が配布の本体です。方針は前章のとおり「各 OS のネイティブ runner でビルドする」です。
5.1 トリガとマトリクス
v* タグの push(または手動実行)で発火し、macos-latest と windows-latest の2つの runner で並列ビルドします。
# .github/workflows/release.yml
on:
push:
tags:
- 'v*'
workflow_dispatch: {}
permissions:
contents: write # Release 作成に必要
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- os: macos-latest
platform: darwin/universal
artifact: ssh-gete-macos-universal
- os: windows-latest
platform: windows/amd64
artifact: ssh-gete-windows-amd64
runs-on: ${{ matrix.os }}
fail-fast: false にしてあるので、片方の OS が転んでももう片方は走り切ります(最初の実走で片 OS だけ落ちたとき、原因切り分けが楽)。macOS は darwin/universal(Intel + Apple Silicon の両対応バイナリ)でビルドします。
5.2 ビルド手順
各 runner でやることは「Go と Node を入れ、Wails CLI を入れ、wails build する」だけ。cgo を外したので、C ツールチェーンのセットアップ手順がどこにも要りません(ここが modernc 化の配当です)。
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.25'
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install Wails CLI
run: go install github.com/wailsapp/wails/v2/cmd/wails@latest
- name: Build
run: wails build -platform ${{ matrix.platform }} -clean -trimpath
wails build は内部で frontend:build(npm run build)を呼んでフロントを生成し、それを Go バイナリに埋め込みます(埋め込みの仕組みは次章)。-trimpath でバイナリからローカルの絶対パスを除き、-clean で前回成果物を消します。
5.3 zip 化と Release への添付
成果物の形が OS で違う(macOS は .app バンドル、Windows は .exe 単体)ので、パッケージ手順も分けます。
- name: Package (macOS)
if: runner.os == 'macOS'
run: |
cd build/bin
ditto -c -k --sequesterRsrc --keepParent ssh-gete.app "../../${{ matrix.artifact }}.zip"
- name: Package (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
Compress-Archive -Path build/bin/ssh-gete.exe -DestinationPath "${{ matrix.artifact }}.zip"
macOS は .app がディレクトリ構造なので、リソースフォークごと保つ ditto を使います(zip コマンドだと .app の属性が壊れがち)。Windows は Compress-Archive。
ビルドした zip は upload-artifact で一旦アーティファクトに上げ、最後に別ジョブ(ubuntu-latest)でまとめて落として、softprops/action-gh-release で Release に添付します。
release:
needs: build
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/')
steps:
- name: Download artifacts
uses: actions/download-artifact@v4
with:
path: dist
- name: Create / update GitHub Release
uses: softprops/action-gh-release@v2
with:
files: dist/**/*.zip
generate_release_notes: true
generate_release_notes: true で、前回タグからのコミットを Release ノートに自動生成させます。タグを切る → ビルド → zip 添付 → Release 公開、が1本のワークフローで完結します。
6. 単一バイナリの正体 — //go:embed all:frontend/dist
配布物が「実行ファイル1つ(+macOS は .app バンドル)」で済むのは、フロントを Go バイナリに埋め込んでいるからです。Web サーバーを別に立てる必要も、フロントのファイルを横に置く必要もありません。
// main.go:11
//go:embed all:frontend/dist
var assets embed.FS
func main() {
app := NewApp()
err := wails.Run(&options.App{
Title: "ssh-gete",
Width: 1440,
Height: 960,
AssetServer: &assetserver.Options{
Assets: assets, // ← 埋め込んだ FS をそのまま渡す
},
// ...
Bind: []interface{}{ app },
})
// ...
}
ポイントは all: プレフィックスです。//go:embed は既定で _ や . で始まるファイルを無視しますが、all: を付けるとドット始まりのファイルも含めてディレクトリ配下を丸ごと埋め込みます。フロントのビルド成果物に隠しファイルが混ざっても取りこぼさないための保険です。
wails.json 側でフロントのビルドコマンド(npm run build)と出力先(frontend/dist)が定義されており、wails build がこの順序——フロントをビルド → frontend/dist を Go が embed → 単一バイナリ——を回します。
// wails.json
{
"name": "ssh-gete",
"outputfilename": "ssh-gete",
"frontend:install": "npm install",
"frontend:build": "npm run build"
}
結果として、ユーザーは zip を解凍して起動するだけ。ランタイムも設定ファイルも要りません(DB だけは初回起動時にユーザーのデータディレクトリへ作られます)。
7. 未署名アプリの現実 — Gatekeeper と SmartScreen
ここは「動くけど、OS が止めにくる」話です。ssh-gete は現状コード署名していません(Apple Developer Program / Windows のコード署名証明書はどちらも有償)。未署名バイナリは各 OS のセキュリティ機構に弾かれます。
macOS の Gatekeeper
ダウンロードした未署名 .app を初回起動すると「開発元を確認できないため開けません」と出ます。逃げ道は .app を右クリック →「開く」。これで「このアプリを開く」許可ダイアログが出て、一度許可すれば次回以降は普通に起動できます(quarantine 属性の例外登録)。
README にもこの手順を明記しています。
# README.md より
- macOS(Apple Silicon / Universal):.app(zip)。
未署名のため初回は .app を右クリック →「開く」で許可。
Windows の SmartScreen
未署名 .exe を初めて実行すると「Windows によって PC が保護されました」(SmartScreen)が出ます。逃げ道は 「詳細情報」→「実行」。
ここは正直に書きます。署名しないと「ユーザーに一手間かけさせる」のは避けられません。個人開発で証明書代を払わない選択をするなら、README で逃げ方を丁寧に案内するのが現実的な落としどころです。署名は将来の課題として残しています。
8. ランディングページと配布
GitHub Releases へのリンクだけでも配布は成立しますが、入口としてランディングページを web/ に用意しています(web/index.html)。
- アプリの一言説明とスクリーンショット
- 各 OS のダウンロードボタン(GitHub Releases へ)
- 連載記事へのリンク
README からもこのページを案内しています。
# README.md より
紹介ページ(ランディングページ)は web/(web/index.html)にあります。
当初の構想では「Releases API を叩いて最新版ボタンを自動更新する」小技も入れる予定でしたが、本記事時点では
web/index.htmlの静的ページに留めています(最新版へのリンクは Releases ページに集約)。実走後の改善余地として残します。
9. public 化チェックリスト
リポジトリを public にする前に棚卸ししたものを残します。OSS 公開の最低限です。
- README — 何のアプリか・なぜ必要か・ダウンロード・ソースからのビルド・MCP クライアント接続手順。未署名アプリの起動方法もここに(第7章)。
-
LICENSE — MIT License(
Copyright (c) 2026 QuickIterate Co., Ltd.)。OSS として配るならライセンス明示は必須。 -
機密情報の棚卸し — ハードコードされた鍵・トークン・社内ホスト名・実機の IP が混ざっていないか。
ssh-geteは資格情報をコードにもリポジトリにも持たない設計(パスフレーズはメモリのみ、トークンはローカル DB のみ)なので、ソースには機密が無いことを確認。 -
シード接続先がダミーか —
seedConnections()(app.go:1125)の雛形が127.0.0.1のローカル確認用だけで、実在ホストを含まないこと。 -
GitHub Actions が secrets を要求しないか —
release.ymlはGITHUB_TOKEN(自動付与)のcontents: writeだけで動き、外部 secret を使わない。 -
.gitignore — DB ファイル・
frontend/dist・build/binなどビルド成果物がコミットされていないか。
10. 正直コーナー — まだ CI は実走していない
脚色しないために明記します。
本記事時点で、
release.ymlはまだ一度も実走していません。 タグ(v*)を切っていないからです。つまり、Windows 実機での最終ビルド確認は CI 任せの状態です。
ローカルの macOS では wails build も go test ./... も通っています。ssh-agent の Windows 名前付きパイプ実装(第2章)も、ビルドタグでコンパイルが分離されることまでは go vet / go build 相当で確認済みです。しかし、
- Windows runner 上で
wails build -platform windows/amd64が最後まで通るか - 生成した
.exeが Windows 実機で起動し、WebView2 が描画され、名前付きパイプ経由で ssh-agent に繋がるか
——これらの最終確認は、初回タグを切って CI を走らせたときになります。modernc 化で cgo を外したのは、まさにこの「CI 実走の不確実性」を最小化するためでもありました(cgo が残っていたら、ここに MinGW のリンク問題という不確定要素がもう1個乗っていたはずです)。
正直に言えば、これは「設計上はクロスプラットフォーム対応が済んでいて、実走の最終確認が残っている」段階です。タグを切った結果は、本記事を更新する形で追記します。
まとめ — 「ビルドが通る」と「OS の慣習に従う」は別物
連載最終回として、クロスプラットフォーム対応の勘所をまとめます。
- クロスコンパイルできる=楽、は半分嘘。純 Go なら本当だが、実アプリには cgo・OS 固有 IPC・WebView・署名が必ず混ざる。
-
OS 差は「閉じ込めて」上に共通の顔を見せる。ssh-agent は
dialSSHAgent()をssh_agent_unix.go/ssh_agent_windows.goに分け、上のレイヤにはnet.Connだけ返す。Unix ソケットと名前付きパイプの違いは下に隠れる。 -
OS 差は「ビルド時に分ける」か「実行時に分ける」かの2択。API ごと別実装ならビルドタグ(agent)、値が違うだけなら実行時フォールバック(
USER/USERNAME)。 -
cgo を1個消すだけで配布難度が大きく下がる。SQLite を
mattn/go-sqlite3(cgo)からmodernc.org/sqlite(ピュア Go)に替えたことで、CI から C ツールチェーンのお守りが消えた。これがこの回の最大の配当。 - WebView だけはクロスコンパイルに固執しない。cgo を外しても GUI は各 OS のネイティブ WebView を使うので、CI は 各 OS のネイティブ runner(matrix)でビルドする。
- 未署名アプリは「逃げ方を README で案内する」が現実解。Gatekeeper は右クリック→開く、SmartScreen は詳細情報→実行。署名は有償なので将来課題に。
-
public 化は機密の棚卸しから。
ssh-geteは資格情報をコードに持たない設計なので、ソースには機密が無いことを確認して公開。
クロスプラットフォーム対応は、結局**「ビルドが通る」ことではなく「OS 固有の慣習に従う」こと**でした。SSH_AUTH_SOCK を見るか名前付きパイプに繋ぐか、USER か USERNAME か、.app を ditto で固めるか .exe を Compress-Archive するか——一つひとつは小さい差ですが、これを律儀に拾うかどうかが「両 OS で本当に動くアプリ」と「自分の環境でだけ動くアプリ」の分かれ目です。
連載全体の締めくくり
全7回、おつきあいいただきありがとうございました。
ssh-gete は「AI エージェントに SSH を渡したいが、無断実行は怖い」という、一つの実務不安から始まりました。第1回でその課題と「人間承認ゲートウェイ」というコンセプトを示し、第2回で SDK なしの MCP 実装、第3回で承認キュー→SSH 実行→監査ログの心臓部、第4回で Wails によるバニラ JS の管理画面、第5回で SQLite による永続化とテスト、第6回でトランスポート抽象化によるシリアルコンソール、そして本回で Windows / Mac 両対応の配布まで——「思いつき」から「公開できるデスクトップアプリ」までの一通りを、コードに即して辿ってきました。
通底していたのは、「ツールに何をさせないか」を設計し、それをコードで強制するという姿勢です。execute_command を呼んでも実行されない。Low リスクでも自動実行しない。シリアル接続先は MCP から実行できない。安全装置は1個ではなく層で積む。そして最後に、それを安全に他人へ配れる形にする。
このアプリと連載が、あなた自身の「AI に任せたいけど怖い」を一段安全にする助けになれば幸いです。続きはリポジトリで。
参照コード(本記事の引用元)
-
darwin_frameworks.go:1—//go:build darwin && cgoとフレームワークリンク -
ssh_agent_unix.go/ssh_agent_windows.go—dialSSHAgent(Unix ソケット / 名前付きパイプ\\.\pipe\openssh-ssh-agentを winio で dial) -
ssh_exec.go:173—agentAuthMethods(dialSSHAgent経由で OS 差を吸収) -
app.go:847—currentUsername(USER/USERNAMEフォールバック)、app.go:1125—seedConnections -
go.mod:10—modernc.org/sqlite(ピュア Go)、github.com/Microsoft/go-winio -
storage.go:13,25— modernc ドライバの import とsql.Open("sqlite", ...) -
main.go:11—//go:embed all:frontend/distによる単一バイナリ -
wails.json— フロントビルド設定 -
.github/workflows/release.yml— タグ push で macOS/Windows をネイティブビルド → zip → Releases -
README.md/LICENSE(MIT)— 未署名アプリの起動案内・公開物
逐行の詳細は
docs/解説.mdを参照。連載各回のリンクは目次から。
この記事はオープンソース ssh-gate の紹介記事です。
