Skip to content

fix(settings): shorten descriptions in compact mode - #109

Open
AvianJay wants to merge 4 commits into
mainfrom
fix/compact-settings-descriptions
Open

AvianJay wants to merge 4 commits into
mainfrom
fix/compact-settings-descriptions

Conversation

@AvianJay

@AvianJay AvianJay commented Oct 18, 2026 •

Copy link
Copy Markdown
Collaborator

啟用精簡模式後,設定仍顯示完整的長篇說明,造成頁面過長。改為保留簡短易懂的說明,例如「常搭站牌自動加入『常用』最愛」,交代必要權限及清除紀錄的影響;關閉精簡模式後仍顯示完整說明,並保留桌面首頁固定精簡的提示。

涵蓋一般設定、個人化、Wear OS 與更新選項,同時縮小設定頁留白,補齊繁體中文與英文文案及本地化產生檔。廣告切換與濃度保留原有幽默文案、求情提示和貓圖;精簡模式另以短句標示廣告已關閉或已鎖定,維持狀態正確。資料庫摘要保留目前自動更新模式。

驗證:

  • flutter test --no-pub test/settings_navigation_test.dart test/ad_settings_test.dart test/primary_surfaces_localization_test.dart:42 項通過。涵蓋中英文精簡/完整說明切換、個人化文案、資料庫更新摘要、廣告三種狀態與幽默提示,以及精簡模式、窄螢幕和大字版面的貓圖與廣告濃度吐槽。
  • dart analyze lib/screens/settings_screen.dart lib/widgets/ad_density_setting.dart test/settings_navigation_test.dart test/ad_settings_test.dart:無問題。
  • git diff --check:本次修改通過。

@Av1anJay Av1anJay left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hermes Agent Review — 精簡模式設定說明

Verdict: Changes requested(1 個行為缺陷 + 若干在地化/測試問題)

檢視範圍:origin/main...90193c4(10 檔、+968/−88),已實際 checkout PR 分支逐檔閱讀並執行下列檢查。

檢查項目與結果

  • gh pr checks 109(head 90193c4):全綠 — Verify(flutter analyze → No issues found;531 tests passed;Web route geometry 2 tests passed)、Android/Wear/iOS/macOS/Windows/Linux/Web build 全 pass。
  • 本機無 Flutter/Dart toolchain(flutter、dart 皆未安裝),因此未重跑 flutter test/dart analyze,改用 CI 結果 + 靜態檢查。
  • ARB ↔ 產生檔一致性(自寫腳本):en/zh/zh_TW 各 996 個 key,三份 ARB key 集合完全相同;app_localizations.dart 抽象 getter 805 個、AppLocalizationsEn/Zh/ZhTw 各 805 個實作,無缺漏;39 個新 *Compact key 在 3 個 locale class 全部有實作,字串與 ARB 完全相符(0 mismatch)。
  • 39 個新 key 在 lib/(排除產生檔)全部有被引用,無死字串。
  • 重複 key 掃描:無;placeholder 差異:無。
  • git diff --check:通過。

缺陷(建議修)

  1. lib/screens/settings_screen.dart:929 — 精簡模式的廣告說明把「廣告已關閉」狀態吃掉。非精簡分支依 _adToggleLocked / enableAds 分成三種文案(鎖定、已開啟 adsEnabledDescription、未開啟的求情文案),精簡分支只判斷 _adToggleLocked。結果:使用者關掉廣告後,精簡模式仍顯示「顯示廣告以支持開發。」,與實際狀態相反。建議保留 enableAds 判斷,例如 _adToggleLocked ? adsLockedDescriptionCompact : (enableAds ? adsDescriptionCompact : <另一句關閉狀態的精簡文案>)。

  2. lib/l10n/app_zh.arb:83 / app_zh_TW.arb:83(同步影響 app_en.arb:83 與產生檔) — compactModeDescription 從「…關閉後會顯示較完整的說明;桌面版首頁固定使用精簡版面。」改成「縮短說明,精簡介面。」,而這正是唯一告訴使用者「關掉就能看到完整說明」的字串。改完後整個 App 沒有任何地方說明如何取回完整說明,也沒交代桌面版首頁固定精簡。這與 PR 目標(保留必要資訊)相衝突,建議至少補回「關閉後顯示完整說明」語意。

在地化用語(非阻擋,但建議一起修)

  1. app_zh.arb:14 / app_zh_TW.arb:14 — normalUpdateIntervalDescriptionCompact 用「刷新」。全檔 996 個字串中「刷新」只出現這 1 次,其餘一律用「更新/重新取得」(如 normalUpdateIntervalDescription 的「重新取得即時到站資訊」)。建議改「到站資訊更新間隔;越短越耗電。」
  2. app_zh.arb:34、app_zh_TW.arb:34-35 — 「鎖屏」是中國用語,同一份檔案既有的 backgroundTripDescription / backgroundTripIosDescription 都用「鎖定螢幕」。建議統一為「鎖定螢幕」。

測試缺口

  1. 本 PR 沒有任何測試檔變更,但新增了 39 個字串 × 3 locale 與約 20 處分支。PR 描述引用的 34 項測試全是既有測試,grep -rn "Compact" test/ 只找到 enableCompactMode 的持久化斷言,沒有任何測試驗證精簡模式會顯示 *Compact 文案。像上面第 1 點這種「少判斷一個狀態」的錯,現有測試完全抓不到。建議在 test/settings_navigation_test.dart 開關精簡模式後斷言至少 2–3 個代表性字串(一般設定、外觀、廣告)。

覆蓋範圍不一致

  1. personalization_screen.dart 只補了 3 個字串(背景圖、配色來源、首頁套用範圍),同頁其他長說明(personalizationAmoledDescription、personalizationHomeGradientDescription、personalizationPerPageDescription)在精簡模式下仍是全文。PR 描述說「涵蓋…個人化」,實際只做了一半。
  2. settings_screen.dart:403 — 精簡模式直接隱藏資料庫「啟動更新:{mode}」整行。若目的是縮短,建議改成短句而非整行消失,否則使用者無法得知自動更新設定。

命名與格式(nit)

  1. adsDescriptionCompact、adsLockedDescriptionCompact、adDensityDescriptionCompact 三個 key 沒有對應的 base key(其餘 36 個都有 xxx / xxxCompact 成對),破壞命名慣例,之後很難確認「完整版」是哪一條。
  2. 新增行有 6 行超過 80 欄(如 personalization_screen.dart:258、:791、app_localizations_en.dart:126),dart format 會重排;CI 沒有 format gate,所以不擋,但建議跑一次。
  3. personalization_screen.dart 夾帶了幾處與功能無關的純重排(:442、:958、:967、:1094),讓 diff 多出噪音。

做得好的地方

  • _buildSettingDescription / _buildSettingSubtitle 統一收斂分支,compactDescription ?? description 的 fallback 讓未提供精簡文案的項目不會爆掉。
  • 三份 ARB 與產生檔完全同步,沒有漏 key、沒有空字串、沒有 placeholder 走鐘 — 這是最容易出錯的部分,處理得很乾淨。
  • localizedAppUpdateChannelDescription / localizedAppUpdateCheckModeDescription 用 compact: 具名參數擴充,沒有破壞既有呼叫端。

Comment thread lib/screens/settings_screen.dart Outdated
contentPadding: EdgeInsets.zero,
title: Text(l10n.showAdsTitle),
subtitle: _adToggleLocked
subtitle: _compactMode

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 狀態判斷退化:非精簡分支依 _adToggleLocked 與 controller.settings.enableAds 分成三種文案,精簡分支只看 _adToggleLocked。使用者關閉廣告後,精簡模式仍顯示「顯示廣告以支持開發。」,與實際狀態相反。建議保留 enableAds 判斷(並補一條「已關閉」的精簡文案)。

Comment thread lib/l10n/app_zh_TW.arb Outdated
"themeModeDark": "深色",
"compactModeTitle": "精簡模式",
"compactModeDescription": "減少首頁與部分卡片的輔助說明,讓常用功能更集中,適合熟悉操作後使用。關閉後會顯示較完整的說明;桌面版首頁固定使用精簡版面。",
"compactModeDescription": "縮短說明,精簡介面。",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ 這條字串原本是唯一說明「關閉精簡模式後會顯示較完整說明;桌面版首頁固定使用精簡版面」的地方,改成「縮短說明,精簡介面。」後,使用者無從得知怎麼取回完整說明。建議保留這層語意(可壓成一句)。app_zh.arb:83、app_en.arb:83 與三個產生檔需同步。

Comment thread lib/l10n/app_zh_TW.arb Outdated
"themeModeDescriptionCompact": "選擇淺色、深色或跟隨系統。",
"mapProviderDescriptionCompact": "切換底圖,不影響公車資料來源。",
"wearSyncCategoryDescriptionCompact": "選擇同步至手錶的最愛分類。",
"normalUpdateIntervalDescriptionCompact": "到站資訊刷新間隔;越短越耗電。",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 用語不一致:全檔僅此處用「刷新」,其餘 995 條都用「更新/重新取得」(cf. normalUpdateIntervalDescription)。建議「到站資訊更新間隔;越短越耗電。」

Comment thread lib/l10n/app_zh_TW.arb Outdated
"autoFavoriteDescriptionCompact": "常搭站牌自動加入「常用」最愛。",
"smartNotificationDescriptionCompact": "常用時段發送到站提醒,需通知與背景定位。",
"keepScreenAwakeDescriptionCompact": "查看路線時保持螢幕亮起。",
"backgroundTripDescriptionCompact": "鎖屏繼續乘車提醒,需通知與定位權限。",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 「鎖屏」為中國用語;同檔既有 backgroundTripDescription / backgroundTripIosDescription 均用「鎖定螢幕」。第 35 行同理,建議一併統一。

Comment thread lib/screens/settings_screen.dart Outdated
localizedDatabaseAutoUpdateMode(
l10n,
controller.settings.databaseAutoUpdateMode,
if (!_compactMode)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 精簡模式把「啟動更新:{mode}」整行移除,使用者將無法得知資料庫自動更新設定。若目的是縮短,建議換成短句而非整行消失。

@Av1anJay Av1anJay left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hermes Agent Review — 精簡模式設定說明(第二輪,head ae15ac9)

Verdict: Comment(無阻擋問題,可直接合併;下列為非阻擋建議)

前一輪 90193c4 的 REQUEST_CHANGES 已逐項確認修好:

  1. ✅ 廣告狀態退化 → 精簡分支現在依 _adToggleLocked / enableAds 給出三種文案(adsLockedMessageCompact / adsEnabledDescriptionCompact / adsDisabledStatus),關閉廣告不再顯示「已開啟」。
  2. ✅ compactModeDescription 已補回「關閉後顯示完整說明;桌面首頁固定精簡」,三語同步。
  3. ✅ 「刷新」→「更新」、「鎖屏」→「鎖定螢幕」,用語與全檔一致。
  4. ✅ 測試缺口已補:新增精簡/完整切換(zh_TW + en)、廣告開啟/關閉/鎖定三態、窄螢幕大字 × 精簡與否。
  5. ✅ 資料庫「啟動更新:{mode}」不再於精簡模式整行消失。
  6. ✅ 個人化頁補齊 AMOLED/首頁漸層/每頁背景三條精簡文案。

本次實際檢查

  • gh pr checks 109(head ae15ac9):全綠 — Verify(flutter analyze → No issues found;537 tests passed;Web route geometry 2 passed),Android/iOS/Wear/Linux/Windows/macOS/Web build 全 pass,Publish Nightly skipped。
  • 本機無 Flutter/Dart toolchain(flutter、dart 皆未安裝,機器僅 ~1GB RAM),未重跑 flutter test/dart analyze,改以 CI 結果 + 靜態腳本查核,不臆測結果。
  • ARB ↔ 產生檔一致性:en/zh/zh_TW 各 1000 key、key 集合完全相同、無重複 key;抽象類 809 getter + 191 method,三個 locale 實作數相同、0 缺漏;本次動到的 44 個 key 在 ARB 與產生檔字串完全相符(0 mismatch)。
  • 舊 key 清除乾淨:adsDescriptionCompact / adsLockedDescriptionCompact / adDensityDescriptionCompact 在 lib/、test/ 已無殘留引用。
  • 39 條 *Compact 皆有被引用(除 localized_labels.dart 內以 compact: 間接取用者);無死字串。
  • git diff --check 通過;diff 內無 debug print/TODO/FIXME/密鑰樣式。

非阻擋建議

  1. (inline)ad_density_setting.dart:34 — 副標題已在地化,標題 '廣告濃度' 仍是硬編碼中文,英文語系會出現中英混排。
  2. (inline)ad_density_setting.dart:36 — 精簡模式把「等級 + 貓圖 + 分級回饋」整段換成通用提示,使用者調等級後失去任何確認感;建議精簡版仍保留當前等級字樣。
  3. personalization_screen.dart:450 — 與功能無關的純重排(且該行 83 欄),建議還原以縮小 diff。
  4. (inline)測試:personalizationColorSourceDescriptionCompact 是本次唯一沒有任何斷言的新字串(僅在配色對話框內使用)。
  5. 格式:personalization_screen.dart:264、:344、:797 超過 80 欄,dart format 會重排;CI 無 format gate 故不擋。

做得好的地方

  • _buildSettingSubtitle / _buildSettingDescription 統一收斂,compactDescription ?? description 的 fallback 讓未提供精簡文案的項目不會壞掉。
  • localizedAppUpdateChannelDescription / localizedAppUpdateCheckModeDescription 以具名 compact: 擴充,既有呼叫端零破壞。
  • 廣告三態在精簡模式下語意正確,且新增測試真的會抓到上一輪那個 bug。

Reviewed by Hermes Agent(head ae15ac9)

children: [
ListTile(
contentPadding: EdgeInsets.zero,
title: const Text('廣告濃度'),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 這個 PR 已經替同一個 ListTile 的副標題接上 AppLocalizations,但標題仍是硬編碼 '廣告濃度'。英文語系使用者會看到「中文標題 + 英文說明」的混排。建議順手加一個 adDensityTitle(en: Ad density)並改用 AppLocalizations.of(context).adDensityTitle。

Comment thread lib/widgets/ad_density_setting.dart Outdated
fit: BoxFit.contain,
excludeFromSemantics: true,
subtitle: settings.enableCompactMode
? Text(AppLocalizations.of(context).adDensityLevelHint)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 精簡模式把「貓圖 + 分級回饋(感謝支持/哇 感謝你的支持…)」整段換成通用提示 adDensityLevelHint,結果是使用者切換 1~4 級後,畫面上完全沒有「目前是哪一級、有沒有生效」的文字回饋(只剩 SegmentedButton 的選中狀態)。若目的是縮短,建議保留等級字樣,例如 '等級 {level}:數值越高,顯示的廣告越多。',而不是整段語意替換。

});

for (final locale in [const Locale('zh', 'TW'), const Locale('en')]) {
_settingsTest('compact descriptions switch with the setting in $locale', (

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 新測試覆蓋了語言/天氣/介面縮放、個人化四條與廣告三態,但 personalizationColorSourceDescriptionCompact 是本次新增字串中唯一沒有任何斷言的(只在配色對話框內使用)。建議在既有「開精簡 → 進個人化」段落順手開一次配色對話框斷言它,避免之後改名或漏接。

@Av1anJay Av1anJay left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hermes Agent Review — 精簡模式設定說明(第三輪,head 1c12fcc)

Verdict: Approve — 無阻擋問題,可合併。以下兩點為非阻擋的維護性建議。

本次 delta(ae15ac9..1c12fcc,10 檔、+123/−125)

把廣告區塊改回「保留幽默文案 + 精簡模式附加狀態短句」的做法:

  • 鎖定態:cat_laugh.png + adsLockedMessageCompact(「切換過於頻繁,已鎖定為開啟。」)+ 原有 adsLockedMessage(「再玩啊哈哈」)。
  • 關閉態:cat_cry/cat_sad.png + adsDisabledStatus(「已關閉廣告。」)+ 隨機求情句。
  • 移除 adsEnabledDescriptionCompact / adDensityLevelHint 兩個 key,ad_density_setting.dart 回到 main 的版本(git rev-parse 確認與 main: blob 完全相同)。

實際檢查與結果

  • gh pr checks 109(head 1c12fcc):全綠。 Verify job 117877201825 逐步 success,log 實測:flutter analyze → No issues found! (31.1s);flutter test → 539 tests passed;Web route geometry → 2 tests passed。Android/Wear OS/iOS/macOS/Windows/Linux/Web build 全 pass,Publish Nightly skipped。
  • 本機 toolchain:我抓了 flutter 3.41.6(與 CI FLUTTER_VERSION 相同)到暫存目錄,成功執行 flutter pub get 與 flutter gen-l10n。但這台機器只有 ~1GB RAM,flutter analyze / flutter test 都在編譯階段 thrash 十幾分鐘後被我中止,沒有得到本機結果 — 因此測試結論一律引用 CI 對同一 SHA 的實跑,不臆測。
  • l10n 產生檔無漂移(本機實測):cp -r lib/l10n /tmp/l10n_before && flutter gen-l10n && diff -rq → 完全一致,lib/l10n/*.dart 與 ARB 同步,未手改產生檔。
  • ARB 一致性(自寫腳本):en/zh/zh_TW 各 998 key、key 集合完全相同、無重複 key、placeholder 差異 0、空字串 0。
  • 移除的 key 無殘留:grep -rn "adsEnabledDescriptionCompact\|adDensityLevelHint" lib/ test/ → 無命中。
  • 新 key 引用:40 個 *Compact key 在 lib/(排除產生檔)全部有被引用,無死字串。
  • 呼叫端完整性:_buildSettingDescription / _buildSettingSubtitle / _buildHistoryAction 的每個呼叫點都正確帶上 compactDescription;localizedAppUpdateChannelDescription / localizedAppUpdateCheckModeDescription 的 compact: 具名參數沒有漏接的呼叫端。
  • dart format --output=none --set-exit-if-changed:settings_screen.dart、ad_density_setting.dart、localized_labels.dart、兩個測試檔皆已格式化;personalization_screen.dart 有 4 處會被重排,但同樣的 4 處在 main 上就存在(我把 git show main:... 丟進 formatter 比對確認),屬既有問題,不是本 PR 引入。
  • git diff main...HEAD --check:通過。

非阻擋建議

  1. test/ad_settings_test.dart:112 — AdDensitySetting 已回到 main 版本、完全不讀 enableCompactMode,但測試仍用 for (final compactMode in [false, true]) 展開。結果 4 個 test case 其實只有 2 種唯一行為,compact=true/false 是同義重複,test 名稱卻聲稱在驗精簡模式。建議移除這層迴圈(或改回單層),避免以後有人誤以為這裡有精簡分支。

  2. lib/screens/personalization_screen.dart:459 — personalizationColorSourceDescriptionCompact 是本次新增 40 個字串中唯一完全沒有任何斷言的(只在配色對話框內使用)。上一輪已提過,本輪仍未補。建議在既有「開精簡 → 進個人化」段落順手開一次配色對話框斷言它,成本很低。

做得好的地方

  • 廣告三態在精簡模式下狀態正確且幽默文案完整保留:鎖定/關閉各有獨立短句,且關閉時不再誤顯示「已開啟」。這是前兩輪最主要的缺陷,現在處理得很乾淨。
  • 果斷移除 adsEnabledDescriptionCompact / adDensityLevelHint 兩個為了「縮短」而犧牲文案特色的 key,讓 ad_density_setting.dart 完全回到 main,diff 淨變化為零 — 比留著半套精簡邏輯好。
  • ARB 與產生檔完全同步、無重複 key、無殘留引用;flutter gen-l10n 重跑零漂移。

Reviewed by Hermes Agent — checks: CI@1c12fcc(analyze + 539 tests + 2 web tests,全綠)、flutter gen-l10n 漂移比對、ARB key/placeholder 稽核、死 key 掃描、dart format 檢查、git diff --check

body: SingleChildScrollView(
padding: EdgeInsets.all(20),
child: AdDensitySetting(),
for (final compactMode in [false, true]) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 非阻擋:這層迴圈已經是同義重複。 ad_density_setting.dart 在本 PR 已回到 main 版本(git rev-parse HEAD:lib/widgets/ad_density_setting.dart == main: blob),完全不讀 enableCompactMode,所以 compactMode=false 與 compactMode=true 跑的是完全相同的行為。4 個 case 只剩 2 種唯一行為,但 test 名稱寫著 with compact=$compactMode,會讓後人誤以為這裡有精簡分支要顧。建議移除這層 for(或至少在 AdDensitySetting 真的再分支時才加回來)。

AppLocalizations.of(
context,
).personalizationColorSourceDescription,
settings.enableCompactMode

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 非阻擋:personalizationColorSourceDescriptionCompact 仍無任何測試斷言。 它是本次新增 40 個 *Compact key 中唯一完全沒被測試碰到的(grep -rc 在 test/ 為 0),而且只在這條對話框路徑上使用,屬最容易在之後改名/漏接時靜默壞掉的一條。建議在既有「開精簡 → 進個人化」的測試段落順手開一次配色對話框斷言它即可,成本很低。

@Av1anJay Av1anJay left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hermes Agent Review — head ea81541(Node.js CVE-2026-111497 硬化)

Verdict: Comment(自審限制,GitHub 不允許對自己的 PR 按 APPROVE/REQUEST_CHANGES)— 但合併前必須先解衝突,因為目前 head 完全沒有 CI 覆蓋。

這個 commit 從哪來

ea81541 是今天新推的 commit,與 PR 標題「shorten descriptions in compact mode」無關:它是 10 檔、+105/−7 的 Node.js 執行環境硬化(.node-version、engines、preinstall 守門、wrangler 部署改走 npm run deploy:web)。PR 描述(仍在講 42 項測試那版)完全沒有提到這件事,reviewer 從 PR 頁面看不出範圍變了。

🔴 阻擋:head 沒有任何 CI 結果,且 PR 目前 CONFLICTING

  • gh pr view 109 → mergeable: CONFLICTING、mergeStateStatus: DIRTY。
  • repos/.../actions/runs?head_sha=ea81541… → total_count = 0;branch 上最後一次 Build 是 1c12fcc(2026-10-19,success)。GitHub 在無法計算 merge commit 時不會觸發 pull_request workflow,所以這個 commit 的 workflow 改動(setup-node、npm run test:node、deploy 改寫)一次都沒跑過。
  • 我實測 git merge origin/main → 2 個衝突:
    • lib/screens/settings_screen.dart:1182–1207 — main 的 eb5eb86(手動檢查更新改版)把 app-updates 區塊重寫成 _buildSettingDescription(l10n.appUpdateManualCheckDescription) + _manualAppUpdateResult,本 PR 同處是 compactDescription: 版本。
    • test/settings_navigation_test.dart:162–362 — 同一段測試。
      解衝突時不能只挑一邊:_buildSettingDescription 的簽名已被本 PR 改成 optional named compactDescription,要保留 main 的新 UI 結構再把精簡文案接上去。

⚠️ 建議:把 ea81541 拆成獨立 PR

理由不只是「整潔」:這個安全修正現在被一段完全無關的在地化衝突卡住,而它本身其實已經可以合併。拆出來能立刻 merge,也讓 CI 真的跑到 workflow 改動。

✅ 這個 commit 本身:實測通過

(本機抓了 node-v24.22.1,與 .node-version 一致)

檢查 結果
npm run test:node(Node 24.22.1) 3/3 pass
npm run test:node(無 node_modules) pass — 測試不依賴任何套件
npm ci(Node 24.22.1) success,37 packages
npm ci(Node 22.23.1,未修補) EBADENGINE, exit 1 — 擋住了
npm run deploy:web / npm run serve:web(Node 22.23.1) 兩者都在 predeploy/preserve 被擋,exit 1
node scripts/stamp-web-build.mjs(Node 24.22.1,假 build/web) 正確寫入 sw.js + version.json,連跑兩次輸出完全相同(idempotent)
npx wrangler deploy --dry-run success,正確讀到 workers/web/index.mjs + assets
npm install --package-lock-only lock 無漂移(唯一差異是我 clone 目錄名)
actionlint 1.7.7 兩份 workflow clean
版本判定真值表(11 組,含 24.22.1-rc.1、24.22.1 尾空格、25.0.0、27.0.0) 全部符合預期
git diff --check clean
Flutter 相關 未執行 — 本機無 Flutter toolchain(機器僅 ~1GB RAM),不臆測結果

💡 非阻擋建議(詳見 inline)

  1. .npmrc:1 — engine-strict=true 讓 preinstall 那句寫得很好的中文錯誤訊息變成永遠看不到的死碼。實測 Node 22.23.1 下 npm ci 直接吐 npm 的 EBADENGINE / Required: {"node":"^22.23.4 || …"},> preinstall 根本沒執行。
  2. scripts/check-node-version.mjs:26 — 版本下限同時存在於 package.json engines、這個 Map、README、測試字面值共 4 處,下次 Node 發新修補版很容易只改一處。
  3. scripts/check-node-version.mjs:26 — 模組頂層副作用讓 test/node_version_test.mjs 的 import 在舊版 Node 上直接 throw,整個測試檔載入失敗,「blocks vulnerable runtimes」這個 case 永遠不可能在它描述的環境下被驗證。
  4. package.json:9 — preserve:web 只擋 npm 入口;直接 flutter run -d web-server 或 npx wrangler deploy 仍完全繞過守門。
  5. README.md:160 — 停用 wrangler dev 後,workers/web/index.mjs(AASA、assetlinks、快取標頭、SPA fallback)沒有本地驗證途徑,repo 內也沒有任何針對它的測試。

做得好的地方

  • 用 engines + engine-strict + preinstall + predeploy 四層防線,而不是只在 README 寫一行「請升級」— 而且 npm ci 實測真的會被擋。
  • 版本判定沒用字串比較,而是逐段數字比較;prerelease(-rc.1)與 v 前綴都正確落到 reject 分支。
  • .node-version 由 actions/setup-node 的 node-version-file 讀取,CI 與本機同一來源,不寫死兩份。
  • stamp-web-build.mjs 改成 import './check-node-version.mjs',讓部署路徑無論怎麼呼叫都受保護,且 idempotent(我實測重跑不影響 BUILD_VERSION)。
  • README 老實交代了 workerd 內嵌舊 Brotli 這個無法靠升級修掉的限制,並說明 App 端 brotli 是純 Dart 實作 — 沒有假裝修好了。

Reviewed by Hermes Agent — checks: npm run test:node、npm ci(24.22.1 / 22.23.1)、deploy:web/serve:web 守門實測、stamp-web-build idempotency、wrangler deploy --dry-run、lock 漂移比對、actionlint 1.7.7、版本真值表、git merge origin/main 衝突定位、GitHub Actions run 查詢

Comment thread .npmrc
@@ -0,0 +1 @@
engine-strict=true

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ engine-strict=true 會讓 scripts/check-node-version.mjs 裡那句解釋 CVE 的錯誤訊息變成永遠看不到的死碼。

實測(Node 22.23.1):

npm error code EBADENGINE
npm error notsup Required: {"node":"^22.23.4 || ^24.22.1 || ^26.12.1"}
npm error notsup Actual:   {"npm":"10.9.8","node":"v22.23.1"}

> preinstall 根本沒被執行 — npm 在解析階段就 abort 了。也就是說最需要看到「這是 CVE-2026-111497,請用 22.23.4+/24.22.1+/26.12.1+」的使用者,只會看到 npm 的原文訊息。

兩個選項:

  • 移除 .npmrc 的 engine-strict,讓 engines 只當 warning、由 preinstall 給出可讀訊息(安裝仍會被擋,只是擋在 preinstall)。
  • 保留 engine-strict,但接受 preinstall 的訊息只對「已通過 engines 但語意不符」的邊角情況生效 — 那就別在 README 宣稱使用者會看到友善提示。

}
}

requireSafeNode();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 兩個問題集中在這一行。

1. 版本下限重複 4 份:package.json 的 engines.node、這個 patchedVersions Map、README:155、test/node_version_test.mjs:7,12。下次 Node 為新 CVE 再發修補版(例如 24.23.x),很容易只改 package.json 而漏掉這裡,然後 engines 放行、preinstall 卻擋下來,使用者完全無法理解。建議直接從 package.json 解析 engines.node 推導下限,或反過來由這個 Map 產生 engines 並加一個測試斷言兩者一致。

2. 模組頂層副作用:requireSafeNode() 在 import 時就執行,於是 test/node_version_test.mjs 的 import { requireSafeNode } 在未修補的 Node 上會直接 throw,整個測試檔載入失敗 — blocks vulnerable, EOL, unsupported and prerelease runtimes 這個 case 永遠不可能在它描述的環境下被驗證。把這行移到 if (import.meta.main) 之類的進入點判斷(或拆成 cli.mjs),測試才能真正測到函式行為。

Comment thread package.json
"scripts": {
"serve:web": "wrangler dev",
"preinstall": "node scripts/check-node-version.mjs",
"preserve:web": "node scripts/check-node-version.mjs",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 守門只覆蓋 npm 入口。npm run serve:web → preserve:web 會擋(實測 Node 22.23.1 下 exit 1,正確),但:

  • flutter run -d web-server 直接跑(README 也是這樣教)完全繞過。
  • npx wrangler deploy 繞過 predeploy:web,也就繞過 stamp-web-build.mjs 裡的檢查。

既然 README 已經寫「Web 開發與部署命令會拒絕舊版 Node.js」,建議要嘛把 serve:web 的實作也納入(例如包一層 node scripts/check-node-version.mjs && flutter run …,而不是靠 preserve 這種容易被 npm 版本差異影響的隱式 hook),要嘛把 README 的措辭縮小到「npm scripts」。

Comment thread README.md
安裝、Web 開發與部署命令會拒絕舊版 Node.js;只更新 npm 套件無法修補 Node 內建的解碼器。
App 的 `brotli` 套件為純 Dart 實作,未連結此次受影響的原生 C 解碼器。
`npm run serve:web` 改用 Flutter Web 開發伺服器(`http://127.0.0.1:8080`)。
Wrangler 依賴的本機 `workerd` 仍內嵌舊版 Brotli,因此暫停以 `wrangler dev`

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 停用 wrangler dev 之後,workers/web/index.mjs 沒有任何本地驗證途徑,而 repo 內也沒有任何針對它的測試(grep -rln "workers/web" test/ scripts/ 無命中)。

這支 Worker 不是單純靜態檔案伺服器,它處理:/.well-known/apple-app-site-association(AASA,改壞會讓 iOS Universal Links 與 App Clip 靜默失效)、assetlinks.json、sw.js/firebase-messaging-sw.js 的 no-store、字型的長快取,以及 SPA fallback。這些現在只能靠部署到正式站後人工驗證。

建議至少補一個 node --test 的單元測試,直接 import worker 的 fetch handler 打幾個 request(/sw.js、/.well-known/apple-app-site-association、未知路徑)斷言 header 與 body — 純 Node、不需要 workerd,也不會碰到 Brotli。否則這次為了安全而移除本機 dev server 的代價,是讓一條原本就沒測試的關鍵路徑變得更不可驗證。

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants