Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -568,3 +568,71 @@ jobs:
echo ""
echo "clippy の -A を 1 行消すごとに債務が 1 種類返済される (.github/workflows/test.yml)。"
} >> "$GITHUB_STEP_SUMMARY"
# スマホ画面 (assets/remote/) を **本物のブラウザで描いて**検査する。
#
# ■ なぜ要るのか
# 0.18 系で「端末と一覧が同時に見える」が出荷まで生き残った。原因は
# CSS の詳細度 (`#alist.mid` が `#alist{display:none}` に勝っていた) で、
# Rust のテストは 1 本も落ちていない — **誰も CSS を評価していなかった**。
# 経緯と層の地図は docs/preflight.md。
#
# ■ 安い
# Rust を 1 行もコンパイルしない (assets/remote/ は読むだけ)。
# ubuntu ランナーには Chrome も Node も最初から入っており、
# npm パッケージは 1 つも要らない (WebSocket も自前)。
#
# ■ `[skip]` を緑にしない
# remote-check.sh は node / ブラウザが無いと **rc=0 のまま降りる**。
# 手元ではそれが正しいが、CI で通すと**何も描かないジョブが永久に緑**に
# なる。ここは揃っている前提なので、降りたら赤にする。
remote-screen:
# fast / pty / msrv / installers / lint / gui-smoke と**同じ判定**にすること。
if: >-
github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.full_name != github.repository ||
!(github.event.pull_request.head.ref == 'main' ||
startsWith(github.event.pull_request.head.ref, 'night/'))
name: mobile screen (chrome)
runs-on: ubuntu-latest
# **最後の砦**。ここに来る前に、道具が自分で理由を書いて赤で終わること
# (ZV_DEADLINE_MS 10 分 < ZV_WALL_S 11 分 < ここ 15 分)。この順序を
# 崩すと「無音のまま打ち切り」に戻り、原因が 1 行も残らない。
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: 使う道具の版を出す (降りた理由を後から辿れるように)
run: |
node --version
(google-chrome --version || chromium --version || echo 'ブラウザが見つかりません')
# コンテナだと 64MB しか無いことがある (Chrome が固まる定番)。
df -h /dev/shm || true
nproc || true

# わざと壊して赤になることまで確かめる (--self-test)。
# 素通りする検査を CI に置かない。
#
# **出力を変数へ溜めない。** 以前は out=$(...) で溜めていたので、
# 固まると 15 分間 1 バイトも出ないまま打ち切られ、どこで止まったのか
# 1 行も残らなかった。tee で素通ししながら控えを取る。
- name: スマホ画面を描いて不変条件を確かめる
env:
# ジョブの timeout より必ず短くする。**道具のほうが先に**
# 「なぜ終わらないか」を書いて赤で終わるのが要点。
ZV_DEADLINE_MS: '600000'
ZV_STALL_MS: '120000'
ZV_WALL_S: '660'
ZV_HEARTBEAT_MS: '20000'
run: |
set -o pipefail
log=$(mktemp)
rc=0
sh tools/remote-check.sh --self-test 2>&1 | tee "$log" || rc=$?
if [ "$rc" -ne 0 ]; then
echo "::error::スマホ画面の検査が赤 (rc=$rc)"
exit "$rc"
fi
if grep -q '\[skip\]' "$log"; then
echo "::error::スマホ画面を 1 度も描かずに降りた (このジョブでは skip は不合格)"
exit 1
fi
73 changes: 73 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,21 @@
のどこから変えても `ui_language` と揃う(真実の在り処を 1 つに保つ)。
- スマホ側 (`remote.rs`) も同じ辞書を使う。文言は `T('remote.xxx','日本語原文')` と
`data-i18n` 属性で差し替え、**第 2 引数の原文がフォールバック**になる。
- **走査に現れない文字列は、番人も `zai i18n missing` も何も言わないまま
永久に日本語で出る。** 0.20 系で 71 箇所がこの状態だった (`tr("…\` の
行継続を正規表現が食えず、**マッチ自体が不成立**だった)。画面には出るのに
検査は 0 件と言うので、いちばん見つけにくい。
- `tr(` に渡すのは**素の文字列リテラル 1 つ**。`concat!` / `format!` / 変数は
静的に辿れない (`src/mesh.rs` の usage が実際に全言語で日本語のまま)
- **行継続を使わない**。使うと `unescape_rust` の畳み方と 1 バイトでも
ずれた瞬間に辞書が引けなくなる
- `zai i18n missing` が見るのは **Rust の `tr()` だけ**。スマホ側 JS の
`T()` の ID 抜けは `remote::tests::スマホ画面のt呼び出しidは同梱辞書にある`
でしか捕まらない (抜けても日本語で表示されるので、動いて見える)
- **機械的な置換を言語をまたいで当てない。** 辞書 6 言語を 1 本の正規表現で
掃除したら、**120 箇所で単語がくっついた** (`runs regardless` →
`runsregardless`)。日本語は改行位置に空白が要らないが、英語・スペイン語・
ポルトガル語・韓国語は要る。接合部の判断は言語ごとにしかできない。

## テストと検証

Expand Down Expand Up @@ -263,6 +278,64 @@
Windows 由来のリポジトリが「証明できず」**になっていた。
読み書きで**改行を保存する**こと。「対象を汚していない」の検査は内容まで見る。

- **ソースを読む番人は「関数の中だけ」を見る。範囲を広げると空回りする。**
改行をまたぐ照合 (`PAGE.contains("a\nb")`) は Windows の CRLF で必ず外れるので
`replace("\r\n", "\n")` を通す — これは前から書いてあったのに**また踏んだ**
(`remote::tests::音声の案内は…` が Windows の CI でだけ赤)。
そこで `cli::tests::改行をまたぐ照合は正規化してから使う` を足したが、
**最初の 2 版は空回りした**:
- 「直前 60 行」で正規化を探す → **同じファイルの別のテスト**が書いた
`replace` を拾い、わざと壊しても緑
- 自分のコメント文と、判定に使う語 (`"SRC"` 等) を自分で拾って誤検出
直った形は「**囲っている関数の中だけ**を見る + コメント行は除く」。
番人を足したら必ず**わざと壊して赤になること**を確かめる — この 3 版とも
「テストは通っている」状態だったが、守っていたのは 1 版だけだった。

- **関門そのものが固まると、関門ごと信用を失う。**
新設した `mobile screen` が CI で**無音のまま 15 分**でタイムアウトした
(原因が 1 行も残らない)。ランダムに止まる関門は、そのうち誰も見なくなる。
- **時限は 3 段**にし、必ず CI ジョブの上限より内側で撃つ
(`ZV_DEADLINE_MS < ZV_WALL_S < timeout-minutes`。順序は番人が数で照合)
- 固定の待ちにすると遅いランナーを誤って殺す。**進捗が観測できる限り延ばす**
- **`--hang` で自分をわざと固まらせ、時限が効くことを毎回証明する**
(`--self-test` が子プロセスで撃つ)。空回りする時限を残さない
- ワークフローで `out=$(…)` と溜めない。固まると 1 バイトも出ない。`tee` で素通し
- 再現は運任せにしない。`pkill -STOP` でブラウザを凍らせれば**決定的に**作れる
- 実測で分かった真犯人: **Node の `srv.close()` は処理中の要求が 1 本あると
コールバックを呼ばない**。後始末が毎回そこを通っていた (12 行で最小再現)
- **CI の赤を「間欠」と言ってよいのは、同じ内容で緑を取れたときだけ。**
`windows (lease/instances)` は 64 体の取り合いで busy-deny 18 件。
**同一コミットの再実行で緑**になったので負荷依存と判定できた。
再実行せずに「たぶん間欠」と書くのは、直せる赤を捨てるのと同じ。
- **サブエージェントに `git checkout --` を撃たせない。** 共有ツリーでは
**他のエージェントの未コミット作業を消す**。実際に添付アップロードの
173 行が消え、セッション記録から再適用して復元した。退避が要るなら
WIP コミット (`git stash` も全ワークツリー共有なので同じ理由で禁止)。
- **squash マージした PR の上に積んだ PR は、必ず衝突する。**
main には 1 個の squash コミットしか無いので、同じ変更が二重に見える。
`git rebase --onto origin/main <積んだ元の先端>` で落としてから push し直す。
- **リリース前は `tools/release-gate.sh` 1 本。誰も動かしていない層を残さない。**
0.18 系で 4 件が出荷まで生き残った (スマホの 2 重表示 / チップが切り替わらない /
PC の履歴二重 / `tr("…\` 行継続の 71 箇所)。**どれも実際に動かせば 5 秒で
分かる**のに、Rust のテストはソースの文字列を見るだけで**ブラウザの DOM も
CSS も 1 度も評価していなかった**。道具 (`verify.sh` / `gui-smoke.sh` /
`linux-test.sh` / `windows-check.sh` / `zai i18n missing`) は揃っていて、
**走らせるかどうかが人の記憶に委ねられていた**だけである。
- `tools/remote-check.sh` が `assets/remote/` を**本物のブラウザ (CDP)** で
描き、遷移を総当たり + 乱歩 (種は固定) して不変条件を見る:
見えている `.view` は 1 つ / 主枠 (flex:1 の子) は同時に 1 つ /
矩形が重ならない / 横に見切れない / 押せない大きさのボタンが無い /
JS 例外ゼロ / **ポーリングが実際に飛んでいる** (判定は偽 PC 側の受信数)
- **`--self-test` で 7 つの欠陥を仕込み、全部捕まることを毎回証明する。**
空振りする検査は残さない。新しい不変条件を足すときは、**それを破る仕込みも
同時に足す**
- ブラウザが無い環境の最後の砦として、**CSS の法則**を Rust の番人に置いた
(`cli::tests::スマホのcssは隠したものを脇道から見せない`) —
「`display:none` で隠したものを見せる規則は、クラス集合の包含で 1 本の鎖に
なっていること」。枝分かれ (`#alist.show` と `#alist.mid`) が抜け道になる
- **`[skip]` は緑ではない。** 関門は最後に `緑 / 赤 / 未確認` を出す。
CI では skip を不合格にする。経緯と層の地図は `docs/preflight.md`

## UI の原則

- **画面が突然変わらない。** 何かを起動しただけでレイアウトが激変してはいけない。
Expand Down
21 changes: 21 additions & 0 deletions assets/plugins/english-mode/lang/80-tailscale.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,24 @@

# ── 張り替えの失敗 ──
"待ち受けの切り替えに失敗しました: {e} (元の待ち受けに戻しました)" = "Could not switch where we listen: {e} (restored the previous one)"

# ── HTTPS (tailscale serve) — スマホの音声入力が動く唯一の形 ──
"Tailscale の HTTPS 経由 — 🎤 音声入力が使えます (QR を読み取って接続)" = "Over Tailscale HTTPS — 🎤 voice input works (scan the QR to connect)"
"Tailscale に TLS を終端させ、tailnet のホスト名で\n本物の証明書 (Let's Encrypt) を出します。スマホから見た URL が\nhttps になるので、ブラウザの音声認識が動きます\n(平文の http では、どの端末でも仕様上ぜったいに動きません)" = "Let Tailscale terminate TLS and issue a real certificate\n(Let's Encrypt) for your tailnet hostname. The phone then sees an\nhttps URL, so the browser's speech recognition works\n(over plain http it can never work, on any device)."
"tailnet の HTTPS 公開 (tailscale serve) を解除して、\n同じ Wi-Fi から繋げるように戻します" = "Turns off HTTPS publishing (tailscale serve) on your tailnet\nand goes back to connecting from the same Wi-Fi"
"※ 最初の 1 回だけ証明書の取得で待つことがあります\n (失敗したらもう一度読み込んでください。2 回目からは一瞬です)" = "※ Only the very first connection may wait while the certificate is fetched\n (if it fails, just reload — from the second time on it is instant)"
"127.0.0.1 のみ (Tailscale の HTTPS 経由)" = "127.0.0.1 only (via Tailscale HTTPS)"
"🔒 tailnet の HTTPS 公開を準備しています…" = "🔒 Preparing HTTPS publishing on your tailnet…"
"🔒 証明書を取得しています… (初回だけ 1 分ほどかかることがあります)" = "🔒 Fetching the certificate… (the first time can take about a minute)"
"🔓 tailnet の HTTPS 公開を解除しています…" = "🔓 Turning off HTTPS publishing on your tailnet…"
"tailscale コマンドが見つかりません" = "The tailscale command was not found"
"Tailscale アプリは見つかりましたが、CLI を実行できませんでした" = "The Tailscale app was found, but its CLI could not be run"
"この tailnet では HTTPS 証明書が無効です" = "HTTPS certificates are disabled for this tailnet"
"管理コンソールの DNS 画面で「HTTPS Certificates」を有効にしてください" = "Turn on \"HTTPS Certificates\" on the DNS page of the admin console"
"Tailscale の HTTPS 公開に失敗しました" = "Tailscale could not publish over HTTPS"
"下の出力がそのままの理由です" = "The output below is the reason, verbatim"
"🔓 HTTPS をやめる" = "🔓 Turn off HTTPS"
"🎤 HTTPS で待ち受ける (スマホの音声入力が使えます)" = "🎤 Listen over HTTPS (enables voice input on your phone)"
"🔒 https://{d}/ で待ち受けています (スマホの音声入力が使えます)" = "🔒 Listening on https://{d}/ (voice input works on your phone)"
"管理コンソールの URL をコピーします" = "Copies the admin console URL"
"管理コンソールの URL をコピーしました" = "Copied the admin console URL"
42 changes: 32 additions & 10 deletions assets/remote/js/45-term.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@
let sbOff = false; // /api/scrollback が無いサーバ
let loading = false; // 上への継ぎ足し中
let noTop = false; // これ以上前は無い
// 「いま読んでいる行が絶対何行目か」の対応 (`bufFrom` と buf の並び) を
// 作り直した回数。**取得の前提**であり、await から戻ったときにこれが
// 変わっていたら、その応答はもう今の buf に繋がらない。
// 追い付き (applyTail の末尾追記) は対応を動かさないので数えない —
// 数えると出力が流れている端末では遡りが永久に成立しなくなる
let epoch = 0;
let curAgent = -2; // 表示中のエージェント (変わったら作り直す)
let findOn = false, findQ = '', findTimer = null;
// 一致はいまの buf 内の添字で持つが、**いま選んでいる 1 件だけは絶対行で覚える**。
Expand Down Expand Up @@ -222,7 +228,7 @@
// 組み替えると、アイドルのはずの画面が毎秒仕事をする)
function showEmpty() {
if (!buf.length && SCR.classList.contains('zvmid')) return;
buf = []; bufFrom = 0; total = 0; plainSig = '';
buf = []; bufFrom = 0; total = 0; plainSig = ''; epoch++;
hits = []; byRow = new Map(); cur = 0; curAbs = null;
syncCount();
clearBox();
Expand Down Expand Up @@ -466,7 +472,7 @@
function applyTail(from, rows) {
const rs = rows.map(norm);
if (!buf.length || from > bufFrom + buf.length || from < bufFrom) {
bufFrom = from; buf = rs; noTop = false;
bufFrom = from; buf = rs; noTop = false; epoch++;
fullRender();
return true;
}
Expand All @@ -483,6 +489,7 @@
SCR.appendChild(mkRow(rs[i], null));
}
}
if (buf.length > MAX_ROWS) epoch++; // 前を捨てる = 絶対行の対応が動く
while (buf.length > MAX_ROWS) {
buf.shift(); bufFrom++; changed = true;
if (SCR.firstChild) SCR.removeChild(SCR.firstChild);
Expand All @@ -496,33 +503,48 @@
return;
}
loading = true;
// **取ったときの前提を応答に持たせる。** `loading` は遡り同士しか守らない
// ので、await の間に追従 (`pollSb` → `applyTail`) が走りうる:
// * 先頭が MAX_ROWS で間引かれる → `bufFrom` が増える
// * 追い付けない量が出た / セッションが消えた → `buf` ごと作り直す
// * 別のエージェントへ切り替わる (`syncAgent`)
// どれが起きても `from` はもう今の buf に繋がらない。それでも繋ぐと
// 絶対行が **ずれたまま** 残り、次の追従が `from + i - bufFrom` で
// 別の場所へ書き込んで **同じ行を 2 度描く** (実測 60 行)。
// 前提が変わっていたら **この 1 回の取得を捨てる** — `noTop` は立てない
// ので、次に上端へ触れば取り直せる (利用者から見た損失は 1 往復)。
const at = bufFrom, era = epoch;
try {
const j = await fetchSb(CHUNK, bufFrom);
const j = await fetchSb(CHUNK, at);
if (era !== epoch) return; // 前提が変わった — この応答は捨てる
const from = j.from | 0;
const rows = (j.rows || []).map(norm);
if (typeof j.total === 'number') total = j.total;
if (!rows.length || from >= bufFrom) {
if (!rows.length || from >= at) {
noTop = true;
toast(T('remote.term_top_reached', 'これより前の記録はありません'));
} else {
const head = rows.slice(0, Math.min(rows.length, bufFrom - from));
const head = rows.slice(0, Math.min(rows.length, at - from));
// 読んでいた行が飛ばないように、増えたぶんだけ scrollTop をずらす
const h0 = SCR.scrollHeight, t0 = SCR.scrollTop;
const f = document.createDocumentFragment();
for (let i = 0; i < head.length; i++) f.appendChild(mkRow(head[i], null));
SCR.insertBefore(f, SCR.firstChild);
buf = head.concat(buf);
bufFrom = from;
bufFrom = from; epoch++;
SCR.scrollTop = t0 + (SCR.scrollHeight - h0);
if (bufFrom <= 0) noTop = true;
refreshHits(new Map());
syncStatus();
}
} catch (e) {
if (e && e.gone) sbOff = true;
else if (e && e.none) noTop = true; // 今は返せない — 上端を叩き続けない
// 今は返せない — 上端を叩き続けない。ただし前提が変わっているなら
// それは「前が無い」ではなく「別の buf の話」なので立てない
else if (e && e.none && era === epoch) noTop = true;
} finally {
loading = false;
}
loading = false;
}
async function pollSb() {
let j;
Expand Down Expand Up @@ -550,7 +572,7 @@
plainSig = text;
const lines = text.split('\n');
buf = lines.map(t => norm({ spans: [{ t: t }] }));
bufFrom = 0; total = buf.length; noTop = true;
bufFrom = 0; total = buf.length; noTop = true; epoch++;
computeHits();
fullRender();
if (follow) scrollBottom();
Expand All @@ -562,7 +584,7 @@
const a = agentIdx();
if (a === curAgent) return;
curAgent = a;
buf = []; bufFrom = 0; total = 0; plainSig = '';
buf = []; bufFrom = 0; total = 0; plainSig = ''; epoch++;
noTop = false; follow = true;
hits = []; byRow = new Map(); cur = 0; curAbs = null;
clearBox();
Expand Down
Loading