Electron でも Tauri でもなく Wails — Go + バニラ JS 1ファイルで管理画面を作る
連載「AI エージェントに SSH を渡すのが怖いので、人間承認ゲートウェイを自作した」第4回(Wails GUI 編)。
- リポジトリ:
ssh-gete(ssh-gate-oss) —- 対象バージョン:
main(タグ未発行。本記事のコード引用は記事公開時点のmainを参照)- この回は単体でも読めます。連載の入口は第1回(コンセプト編)です。
0. この記事のゴール
ssh-gete は「AI エージェントが要求した SSH コマンドを、人間が承認してから実行する」デスクトップアプリです。バックエンドは Go、フロントは React なし・ビルドツール最小・frontend/src/main.js の1ファイル(約1,465行) で書かれています。
それでも、この1ファイルで以下の4画面が成立します。
- 接続先管理(SSH / シリアル接続先の登録・編集・疎通テスト)
- コマンド履歴・承認(MCP から来た要求の承認キューと実行履歴)
- ターミナル(xterm.js による SSH 対話シェル/シリアルコンソール)
- MCP 待受設定(MCP サーバーのポート・認証・承認ポリシー)
この記事で扱うのは「なぜ Wails か」「なぜ仮想 DOM なしの全画面再描画で成立するのか」、そして「全画面再描画とライブターミナルをどう両立させたか」です。最後の点が一番のハマりどころで、ここに一番紙幅を割きます。
1. Wails とは — Go の公開メソッドが JS からそのまま呼べる
Wails は Go + Web フロントエンドでデスクトップアプリを作るフレームワークです。最大の特徴は、Go の構造体メソッドが JavaScript の async 関数としてそのまま呼べること。バインディングコードは Wails が自動生成します。
エントリーポイントは拍子抜けするほど短い main.go(全36行)です。
// main.go:14
func main() {
app := NewApp()
err := wails.Run(&options.App{
Title: "ssh-gete",
Width: 1440,
Height: 960,
MinWidth: 1180,
MinHeight: 760,
AssetServer: &assetserver.Options{
Assets: assets, // //go:embed all:frontend/dist
},
BackgroundColour: &options.RGBA{R: 238, G: 243, B: 249, A: 1},
OnStartup: app.startup,
OnShutdown: app.shutdown,
Bind: []interface{}{
app, // ← この app の公開メソッドが JS へ露出する
},
})
// ...
}
ポイントは Bind: []interface{}{app}(main.go:29)です。これだけで App 型の公開メソッドがすべて JS 側に生える。フロント側はこう import します。
// frontend/src/main.js:10
import {
ApproveCommand,
ApproveCommandAndBypassAgent,
CloseTerminal,
DeleteConnection,
// ...
GetInitialData,
ListSerialPorts,
SaveConnection,
SaveMCPSettings,
SendTerminalInput,
StartTerminal,
TestConnectionConfig,
} from '../wailsjs/go/main/App';
import { EventsOn } from '../wailsjs/runtime/runtime';
../wailsjs/go/main/App は Wails が生成するファイルで、各 Go メソッドの Promise ラッパが入っています。たとえば GetInitialData() を await すると、Go の App.GetInitialData() が返す DashboardData(JSON)がそのまま JS のオブジェクトとして手に入ります。REST API も IPC のメッセージ定義も書きません。これが Wails の効きどころです。
main.go:11 の //go:embed all:frontend/dist でフロントのビルド成果物をバイナリに埋め込むので、配布物は単一の実行ファイルになります(ローカルサーバーも別プロセスもなし)。
2. Electron / Tauri との比較 — なぜ Wails を選んだか
| Electron | Tauri | Wails | |
|---|---|---|---|
| バックエンド言語 | Node.js | Rust | Go |
| レンダラ | Chromium 同梱 | OS の WebView | OS の WebView |
| バイナリサイズ | 大(数十〜100MB超) | 小 | 小 |
| メモリ | 重い | 軽い | 軽い |
| Go 資産との相性 | △ | △ | ◎ |
ssh-gete のバックエンドは、SSH 実行(golang.org/x/crypto/ssh)・SQLite・MCP サーバー(net/http)・シリアル(go.bug.st/serial)とほぼ Go エコシステムで完結しています。これを別言語のフロントから IPC で叩くのは無駄が多い。Wails なら 承認ロジックや SSH 実行をそのまま Go で書き、UI だけ Web 技術で被せるのが自然です。
Electron は Chromium を丸ごと同梱するためサイズ・メモリが重く、ローカル管理ツールには過剰でした。Tauri は軽量で魅力的ですが、バックエンドが Rust になり Go 資産を活かせません。「Go で書いたバックエンドを最小コストで GUI 化する」という一点で Wails が最適解でした。
3. AdminLTE / Bootstrap で「管理画面の見た目」を最速調達する
フロントの見た目はゼロから作っていません。冒頭の import で AdminLTE(Bootstrap ベースの管理画面テンプレート) を丸ごと読み込みます。
// frontend/src/main.js:1
import 'admin-lte/dist/css/adminlte.min.css';
import 'admin-lte/dist/js/adminlte.min.js';
import 'bootstrap-icons/font/bootstrap-icons.css';
import '@xterm/xterm/css/xterm.css';
import './style.css';
document.body.className(main.js:30)に AdminLTE のレイアウトクラスを当てるだけで、サイドバー・ヘッダー・カード・バッジ・テーブルといった「管理画面の部品」が全部使えます。あとは card・list-group・badge text-bg-success といったクラス名を文字列で書くだけ。CSS をほとんど自分で書かずに「それっぽい管理画面」が立ち上がります。
個人開発でいちばん時間を食う「デザインの帳尻合わせ」を、既製テンプレートに丸投げするのは現実的な判断です。
4. 全画面再描画モデル — 仮想 DOM なしで成立する条件
フロントの描画方針は驚くほど素朴です。
状態(
state)から HTML 文字列を組み立て、app.innerHTMLに丸ごと代入する。
React も Vue も仮想 DOM もありません。状態が変わったら、画面全体を作り直して差し替えるだけです。
// frontend/src/main.js:1122
function render() {
const content = state.active === 'connections'
? renderConnections()
: state.active === 'commands'
? renderCommands()
: state.active === 'terminal'
? renderTerminal()
: renderSettings();
app.innerHTML = shell(content); // ← サイドバー・ヘッダー・モーダルごと全置換
if (state.active === 'terminal') {
mountTerminal(); // ← ここが伏線(後述)
}
}
状態は単一のプレーンオブジェクト state(main.js:34)に集約します。
const state = {
active: 'connections', // 現在のタブ
selectedConnection: 0,
selectedRequest: 0,
data: { connections: [], requests: [], agentPolicies: [], mcp: {} },
dirty: { connections: false, settings: false }, // フォーム編集中フラグ
passphrase: { /* モーダル状態 */ },
// ...
terminal: { connected: false, label: '' },
serialPorts: [],
toast: '',
};
描画関数(renderConnections・connectionRow・historyTable・inputCol など)はすべて**「state を受けてテンプレートリテラル文字列を返す純関数」**です。状態が決まれば画面が一意に決まる、という点では React と発想は同じで、差分計算をサボっているだけです。
なぜそれで成立するのか
全画面 innerHTML 全置換は、普通は「重い」「フォーカスが飛ぶ」「DOM 状態が消える」と嫌われます。それでも ssh-gete で成立しているのは、次の条件が揃っているからです。
- ローカル単一ユーザーのデスクトップアプリ:描画対象は接続先数件・履歴数十件規模。フル再構築のコストが体感に出ない。
-
イベント委譲:
#appに対するクリック/サブミット/入力のリスナを1度だけ張る(main.js:1138以降)。innerHTMLを入れ替えても再アタッチ不要。要素ごとのdata-action属性で分岐します。 - 状態が JS 側に一元化:DOM は「状態の写像」でしかなく、DOM に状態を溜めない。
ただし、この素朴さがそのままでは破綻する箇所が2つあります。「フォーム入力中の上書き」と「ライブターミナルの破棄」です。順に潰していきます。
5. 編集保護つきポーリング — 再描画とフォーム入力の衝突を防ぐ
MCP の承認待ちは「いつ来るか分からない」ので、フロントは末尾でポーリングを仕掛けています。
// frontend/src/main.js:1464
refreshData();
window.setInterval(() => refreshData({ silent: true }), 5000);
5秒ごとに GetInitialData() を叩いて state.data を更新します。ところが、ユーザーが接続先フォームを編集している最中に再描画が走ると、入力が消えます(全置換なので当然)。
これを防ぐのが dirty フラグと、refreshData 内の編集保護ロジックです。
// frontend/src/main.js:176
async function refreshData({ silent = false } = {}) {
try {
const next = await GetInitialData();
const protectConnections = state.active === 'connections' && state.dirty.connections;
const protectSettings = state.active === 'settings' && state.dirty.settings;
const protectModal = state.passphrase.open || state.deleteConnection.open || state.deleteCommand.open;
// The live xterm must not be torn down by the periodic poll.
const protectTerminal = state.active === 'terminal';
state.data = {
connections: protectConnections ? state.data.connections : next.connections,
requests: next.requests,
agentPolicies: next.agentPolicies ?? [],
mcp: protectSettings ? state.data.mcp : next.mcp,
};
// ...
if (!protectConnections && !protectSettings && !protectModal && !protectTerminal) render();
} catch (error) { /* ... */ }
}
設計のキモは2段構えです。
-
データの上書きを止める:編集中(
dirty.connections/dirty.settings)のセクションはstate.dataを更新しない。一方で承認キュー(requests)は編集対象ではないので常に最新化する。承認待ちの通知が遅れたら本末転倒だからです。 -
再描画そのものを止める:編集中・モーダル表示中・ターミナル表示中は
render()を呼ばない(state.dataだけ静かに更新)。
dirty フラグは、フォームに触れた瞬間に立てます。
// frontend/src/main.js:1430
app.addEventListener('input', (event) => {
// ...
if (event.target.closest('#connection-form')) state.dirty.connections = true;
if (event.target.closest('#mcp-form')) state.dirty.settings = true;
});
そして保存・接続先切替・削除のタイミングで dirty を畳みます(例:main.js:1389、main.js:1403)。「全画面再描画モデルを採るなら、ポーリングとフォーム入力の衝突は必ず設計する」——これが仮想 DOM なしで実用に耐えるための1つ目の条件です。
6. XSS 対策とフロント側の安全固定 — バックエンドとの二重化
6.1 escapeHtml で文字列を必ず無害化する
innerHTML に文字列を流し込む方式は、そのままだと XSS の温床です。接続先名・コマンド・理由など、ユーザー(やエージェント)由来の値を生でテンプレートに埋めれば、<script> が走ります。
そこで、テンプレートに値を入れる箇所では必ず escapeHtml(main.js:90)を通します。
// frontend/src/main.js:90
function escapeHtml(value) {
return String(value ?? '')
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll("'", ''');
}
履歴テーブルのコマンド表示や接続先名など、外部由来の値はすべて ${escapeHtml(...)} で囲んでいます(例:historyTable の main.js:1023、connectionRow の main.js:431)。全画面 innerHTML 方式を採る以上、**escapeHtml の貫徹は「やった方がいい」ではなく「必須」**です。
6.2 危険な機能はフロントでも false に固定する
もう一つ、地味だが重要な防御があります。MCP 設定を収集する collectMCPForm(main.js:1089)では、自動実行フラグを常に false でハードコードしています。
// frontend/src/main.js:1114
return {
enabled: checked('enabled'),
// ...
strictHostKey: checked('strictHostKey'),
autoExecuteLowRisk: false, // ← フォームに無くても、常に false で送る
requireApprovalSudo: checked('requireApprovalSudo'),
// ...
};
autoExecuteLowRisk(低リスクコマンドの自動実行)は、第3回で触れたとおりバックエンドに実装はあるものの、承認ゲートウェイの「デフォルト安全」原則に反するため UI から無効化しています。そもそも設定画面にチェックボックスを置かず、フォーム収集時に問答無用で false を送る。
ここで効くのが「安全側の固定はフロントとバックの二重で」という考え方です。バックエンド側でもサニタイズしていますが、フロントでも false に倒しておく。どちらか一方が緩んでも、もう一方が止める。ローカルアプリでも、危険なスイッチは「押せないようにする」のが堅実です。
7. パスフレーズの UI フロー — DB に置かない値をどう入力・保持するか
ssh-gete のポリシーとして、SSH 鍵のパスフレーズは SQLite に保存しません(メモリのみ、アプリ起動中だけ)。トークンは DB、パスフレーズはメモリ、という線引きです。では、保存しない値をどうやって入力させるのか。
フローはこうです。
- ユーザーが接続先編集で「接続」を押す →
TestConnectionConfig(connection, '')をパスフレーズ空で実行。 - 鍵がパスフレーズ保護されていれば、バックエンドが
passphrase protectedを含むエラーを返す。 - フロントはそれを検知してパスフレーズ入力モーダルを開く。
// frontend/src/main.js:1214
if (action === 'connect-ssh') {
const connectionToTest = collectConnectionForm();
const result = await TestConnectionConfig(connectionToTest, '');
if (!result.ok && result.message.includes('passphrase protected')) {
state.passphrase = {
open: true,
connectionName: connectionToTest.name || connectionToTest.host || '未保存の接続先',
value: '',
connection: connectionToTest, // ← 入力中の接続先設定を保持して再試行に使う
};
render();
return;
}
// ...
}
モーダルのフォームには「SQLite には保存せず、このアプリ起動中の SSH 実行だけに使います」と明記してあります(passphraseModal、main.js:406)。入力値は state.passphrase.value に持ち、サブミットで TestConnectionConfig(connection, passphrase) を再実行します。
// frontend/src/main.js:1415
if (event.target.id === 'passphrase-form') {
const data = Object.fromEntries(new FormData(event.target));
const connectionToTest = state.passphrase.connection || collectConnectionForm();
const result = await TestConnectionConfig(connectionToTest, field(data, 'passphrase', state.passphrase.value));
// ...
state.passphrase = { open: false, connectionName: '', value: '', connection: null };
// ...
}
成功すればバックエンドがそのプロセスのメモリにだけパスフレーズを覚え、以降の SSH 実行で使います。フロントは送り終えたら state.passphrase をクリアして手放す。「DB に置かない値」は、入力モーダル → 即バックエンドへ受け渡し → フロントは保持しない、という一方通行で扱うのが安全です。
8. ライブターミナルとの両立 — 全画面再描画の中で xterm.js を破棄させない
ここが本記事の核心です。第6回で作るシリアルコンソール/SSH 対話シェルは、xterm.js のライブ端末です。これを全画面 innerHTML 再描画モデルに素直に置くと、5秒ポーリングや他操作の再描画のたびに端末 DOM が破棄され、対話セッションが壊れます。
たとえば vim を開いている最中に再描画が走れば、端末の DOM ごと作り直されてセッションが切れる——これでは使い物になりません。解決は2段構えです。
8.1 xterm インスタンスを再描画の外に保持し、DOM を「移植」する
まず、xterm のインスタンスを state でも DOM でもなくモジュールスコープの変数に持ちます。
// frontend/src/main.js:79
// xterm lives outside the render() innerHTML cycle: the instance is kept here and
// its DOM element is re-attached after each render so the live session survives.
let term = null;
let fitAddon = null;
let termWired = false;
render() の末尾で state.active === 'terminal' のときだけ mountTerminal() を呼びます(main.js:1133)。その中身がポイントです。
// frontend/src/main.js:738
function mountTerminal() {
const host = document.querySelector('#xterm-host');
if (!host) return;
ensureTerminal();
if (!term.element) {
term.open(host); // 初回だけ生成
} else if (term.element.parentElement !== host) {
host.appendChild(term.element); // 2回目以降は“移植”
}
if (!termWired) {
term.onData((data) => { // キー入力を送る配線は1度だけ
if (state.terminal.connected) SendTerminalInput(data);
});
termWired = true;
}
fitTerminal();
}
肝は「再生成ではなく appendChild による移植」です。render() で #app の中身を全置換すると、古い #xterm-host ごと xterm の DOM 要素も親から外れます。しかし term.element 自体は破棄されず、モジュール変数 term が握ったまま。だから、新しく描かれた #xterm-host へ appendChild で付け替えるだけで、スクロールバックも入力中の状態も生きたまま端末が復活します。
キー入力の配線(term.onData)も termWired フラグで1度しか張りません。再アタッチのたびに配線すると、入力が多重送信されてしまうからです。
8.2 ターミナル表示中はポーリング再描画そのものを止める
移植だけでも端末は生き残りますが、もっと確実なのは「そもそも余計な render() を起こさない」ことです。これが第5節で見た protectTerminal の正体です。
// frontend/src/main.js:183
// The live xterm must not be torn down by the periodic poll.
const protectTerminal = state.active === 'terminal';
// ...
if (!protectConnections && !protectSettings && !protectModal && !protectTerminal) render();
ターミナルタブを開いている間、5秒ポーリングは state.data を裏で更新するだけで render() を呼びません。これがないと、端末操作中に勝手に画面が再構築されてしまいます。「移植で生き残らせる」+「そもそも再描画しない」の二重防御です。
8.3 受信は Wails イベント、入力は Go メソッド呼び出し
端末出力は、ポーリングではなく Wails のイベントでフロントへ push します。バイナリ安全のため base64 で運び、フロントで復号して term.write します。
// frontend/src/main.js:788
EventsOn('terminal:data', (payload) => {
if (term) term.write(b64ToBytes(payload));
});
EventsOn('terminal:exit', (message) => {
if (term) term.write(`\r\n\x1b[33m[${message}]\x1b[0m\r\n`);
state.terminal.connected = false;
state.terminal.label = '';
if (state.active === 'terminal') render();
});
// frontend/src/main.js:718
function b64ToBytes(b64) {
const bin = atob(b64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i += 1) bytes[i] = bin.charCodeAt(i);
return bytes;
}
逆向き(キー入力)は SendTerminalInput(data) という Go メソッド呼び出し、ウィンドウリサイズは fitAddon.fit() + ResizeTerminal(cols, rows) で追従します(fitTerminal、main.js:756)。
// frontend/src/main.js:799
window.addEventListener('resize', () => {
if (state.active === 'terminal') fitTerminal();
});
8.4 後始末 — タブ離脱・切断で必ず破棄する
タブを離れる、または「切断」を押したら、teardownTerminal() でバックエンドのセッションを閉じ、xterm インスタンスも破棄して、次回はクリーンに作り直せるようにします。
// frontend/src/main.js:774
async function teardownTerminal() {
if (state.terminal.connected) {
await CloseTerminal();
}
state.terminal.connected = false;
state.terminal.label = '';
if (term) {
term.dispose();
term = null;
fitAddon = null;
termWired = false; // 配線フラグも戻す(次回は1から張り直す)
}
}
タブ切替時にこれを呼ぶのはナビゲーション処理の中です。
// frontend/src/main.js:1139
const nav = event.target.closest('[data-nav]');
if (nav) {
const target = nav.dataset.nav;
if (state.active === 'terminal' && target !== 'terminal') {
await teardownTerminal(); // ターミナルから他タブへ移るとき必ず破棄
}
state.active = target;
render();
return;
}
設計のまとめ(ターミナル):xterm のインスタンスを再描画サイクルの外に逃がす(モジュール変数)→ 再描画後は DOM を移植(再生成しない)→ ターミナル中はポーリング再描画を抑止 → 受信はイベント、送信は Go 呼び出し → 離脱時は確実に破棄。この5点が揃って初めて、「全画面再描画モデル」と「ライブ端末」が同居します。
なお、SSH 対話シェルとシリアルコンソールはフロントから見ると完全に同じ受け口です。違いは「選んだ接続先の種別(type)」だけで、StartTerminal(name) を呼べばバックエンドが自動でディスパッチします(トランスポート抽象化の詳細は第6回で扱います)。現状のターミナル画面は接続先の種別に従属し、renderTerminal(main.js:635)が見出しを「インタラクティブシェル」「シリアルコンソール」に出し分けます。
9. 4画面の構成(参考)
現状の画面は4つで、サイドバーの navItem(main.js:241)で切り替えます。
-
接続先管理(
renderConnections、main.js:315):左に接続先一覧(SSH/Serial バッジ付き)、右に編集フォーム。接続種類セレクタで SSH / Serial フォームが切り替わる。 -
コマンド履歴・承認(
renderCommands、main.js:513):承認キュー+選択要求の詳細(コマンド・理由・stdout/stderr)+全履歴テーブル。「承認して実行」「拒否」「エージェントは承認省略」。 -
ターミナル(
renderTerminal、main.js:635):左に接続先選択+詳細、右に xterm.js のライブ端末。 -
MCP 待受設定(
renderSettings、main.js:803):左に全設定フォーム、右に公開エンドポイントとエージェント用設定 JSON(agentConfig)。
いずれも「state を受けて文字列を返す純関数」で、render() が1本で束ねます。画面追加は「描画関数を1つ足して render() の分岐に加える」だけ。脱フレームワークでも、状態を一元化して描画を純関数にしておけば、見通しは保てます。
まとめ — ローカル管理画面ならフレームワーク不要は現実解
-
Wails は「Go の公開メソッドが JS からそのまま呼べる」。
Bind: []interface{}{app}だけで API 定義も IPC も書かずに済み、Go 資産をそのまま GUI 化できる。バイナリは単一実行ファイル(//go:embed)。 - Electron(重い)でも Tauri(Rust)でもなく Wails を選んだのは、バックエンドが Go エコシステムで完結しているから。
- AdminLTE/Bootstrap で管理画面の見た目を最速調達。CSS をほぼ書かない。
-
全画面
innerHTML再描画+イベント委譲という素朴なモデルでも、ローカル単一ユーザー・状態一元化・純関数描画という条件が揃えば実用に耐える。React は要らなかった。 - ただし素朴さの代償は2つ。編集保護つきポーリング(
dirtyフラグ+protect*)でフォーム入力との衝突を防ぎ、escapeHtmlの貫徹で XSS を防ぐ。危険なスイッチ(autoExecuteLowRisk)はフロントとバックの二重でfalse固定。 - 最大の難所ライブターミナルとの両立は、xterm を再描画サイクルの外に逃がし、DOM を移植し、ターミナル中は再描画を抑止することで解決した。
「フレームワークを入れない」は手抜きではなく、ローカル管理画面という前提に対する最適化です。状態を一元化し、描画を純関数にし、衝突点(ポーリング・XSS・ライブ端末)を1つずつ設計で潰せば、1ファイルでも破綻しません。
次回(第5回)は、この裏側にある永続化(SQLite)・監査ログ・テスト戦略を扱います。
参照コード(本記事の引用元)
-
main.go(36行)— Wails 起動・Bind・//go:embed(main.go:14,main.go:29) -
frontend/src/main.js(約1,465行)— フロント全体- 状態:
state(main.js:34)、xterm 用モジュール変数(main.js:79) - 描画:
render(main.js:1122)、shell(main.js:200)、escapeHtml(main.js:90) - ポーリング編集保護:
refreshData(main.js:176)、setInterval(main.js:1464) - 安全固定:
collectMCPFormのautoExecuteLowRisk: false(main.js:1114) - パスフレーズ:
connect-ssh分岐(main.js:1214)、passphrase-form(main.js:1415) - ターミナル:
mountTerminal(main.js:738)、ensureTerminal(main.js:725)、teardownTerminal(main.js:774)、EventsOn(main.js:788)、b64ToBytes(main.js:718)、renderTerminal(main.js:635)
- 状態:
-
wails.json— プロジェクト定義(name: ssh-gete、frontend:build等)
逐行の詳細は
docs/解説.md第10章「フロントエンドmain.jsの詳説」・付録E・第18.6章を参照。
この記事はオープンソース ssh-gate の紹介記事です。
関連記事
クイックイタレート株式会社
IoT / 電力監視 / AI / 衛星・無線通信 / システムインテグレーション/
ローカル LLM・エージェント基盤に関するお問い合わせはお気軽にどうぞ。
