# M（vpn-cef-browser）

**產品名稱是「M」（智新工作瀏覽器；圖示也是 M）**，程式、套件、資料夾仍叫 `vpn-cef-browser`。改名只改**顯示名稱**（App 名稱、選單、視窗、關於、DMG 名稱、桌面捷徑、文件的產生者欄位）；
**bundle ID `com.steps.vpn-cef-browser`、簽章身分、資料資料夾 `~/.local/share/vpn-cef-browser`、執行檔名、`vpndoc://` 都不能改**——bundle ID 或簽章一變，macOS 就會當成另一個 App，鑰匙圈會再問一次、設定資料也對不上。
舊版的「VPN Browser.app」跟新的「M.app」是同一個 bundle ID：換新版時要把舊的刪掉。


用 CEF（Chromium Embedded Framework）打造「真的帶著自己核心」的瀏覽器 POC，
跟 vpn-browser（Tauri 版，借系統 WebView）是兩個獨立方向的實驗專案。

## 為什麼要有這個專案

跟 Tauri 版最大的差異：不依賴作業系統裝好的 WebView 引擎（Linux 上是
webkit2gtk、Windows 是 WebView2、macOS 是 WKWebView），而是自己帶一份
Chromium（透過 CEF）。代價是安裝檔變大很多（stripped 後約 465MB），換來
三平台渲染引擎一致、不依賴系統是否裝了瀏覽器元件。

## 架構

瀏覽器外框（分頁列 + 工具列 + 書籤列、左側圖示列）是**內建網頁**
（`resources/chrome/`），各裝在一個 BrowserView 裡，跟分頁內容的 BrowserView
用 CEF 的 Views 版面排在同一個原生視窗——不像 Tauri 版要搞多視窗疊貼同步。
早期外框是用 Views 元件（LabelButton / Panel）刻的，但 Views 只能畫純色
方塊，做不出圓角、漸層、陰影，看起來不像 Mac App，所以改成 HTML/CSS。

- `src/shared/window_state.rs`：**每個視窗一份**的狀態——分頁列表、作用中
  分頁、外框 BrowserView 的參照，加上一個全域登記表讓 Client 端共用
  的 Handler 能用 `Browser::identifier()` 反查「這個 Browser 是哪個視窗的
  哪個分頁」。分頁 = 一個 BrowserView，所有分頁都裝在同一個內容欄 Panel
  裡、flex=1，只有作用中的 `set_visible(true)`（BoxLayout 會略過不可見的
  子 view），切分頁就是切可見性，頁面狀態都保留。狀態一變就 `push()`
  整份 JSON 給外框網頁重畫
- `src/shared/chrome.rs` + `resources/chrome/`：外框。`chrome.html`（分頁列、
  工具列、書籤列）、`sidebar.html`（左側圖示列）；網頁上的操作用
  `fetch('vpndoc://app/api/chrome/<指令>')` 回到 Rust。分頁列空白處用 CSS
  `-webkit-app-region: drag` 標成可拖動視窗（DragHandler 轉交給視窗）。
  下拉選單（⋮、下載、所有書籤）是原生選單（`app_menu.rs` 等）
- `src/shared/settings.rs` + `settings.html`：設定（預設首頁〔沒設就是空白頁 `about:blank`，沒有內建的預設網站〕、預設聊天、**說明**），存在
  `settings.json`；「⋮ → 設定…」或 ⌘,。**辦公文件位置**（`locations.rs`）：文書、試算表、簡報、上傳的檔案、資料夾、簡報圖片可以放到別的資料夾
  （設定頁輸入路徑或按「選擇…」〔macOS 原生選資料夾視窗，要在 UI 執行緒跑，所以開工作、頁面輪詢〕；底下會有 `documents/ sheets/ slides/ files/ slide-assets/ folders.json`；
  設定、書籤、歷史紀錄、Tor 等其他資料不動）。換位置之前先問：新位置**已經有辦公資料**→ 改用它（舊的不動、可以切回去）；沒有→「搬移現有文件」（同一個磁碟用
  rename 瞬間完成；跨磁碟先複製、核對檔案數和大小才刪舊的，出錯會還原）或「不搬、從空的開始」。路徑要是完整路徑、不能有 `..`、不能是磁碟根目錄、要寫得進去
  （換位置前就檢查，不是等選完才報錯）；新位置不能在舊位置裡面。搬移在背景執行緒做。`docs.rs` 的 `docs_dir` / `folders_path` / `slide_asset_dir` 都經過
  `locations::office_root()`。**影音資料夾**：影片頁掃描的資料夾可以自訂（多個、檢查過存在、去重；空的 = 預設的下載 / 影片 / 音樂），影片頁的
  播放 / 縮圖 API 只認這些資料夾底下的檔案。「說明」卡片：App 版本（Cargo.toml 的 `version`）、Chromium / CEF 版本（CEF 標頭的版本字串）、
  平台、資料位置，加上本軟體的簡介和功能清單（`about` 欄位由 `settings.rs` 的 `about_json()` 提供）
- `src/shared/security.rs`：連線安全性/憑證檢視。資料來自 CEF 的
  `NavigationEntry::SSLStatus`（Chromium 自己的判定）：TLS 版本、憑證
  簽發給/簽發者/有效期/序號/憑證鏈、`cert_status` 旗標解成中文、混合
  內容。鎖頭圖示（🔒 安全 / ⚠ 有問題或 HTTP / 🏠 內建頁）跟著作用中分頁
  的載入狀態更新；按下去把報告存起來、開一個 `vpndoc://app/cert.html`
  分頁顯示（讀 SSLStatus 要在 UI 執行緒，自訂協定回應在 IO 執行緒，所以
  先存再開）
- `src/shared/simple_app.rs`：視窗/BrowserView 的建立邏輯（改自官方
  `cefsimple` 範例），`on_window_created` 組版面：外層垂直
  [上方外框（全寬）][下面一列]，下面一列水平 [左側圖示列][各分頁 BrowserView]；
  `target="_blank"`/`window.open` 的彈出視窗掛成來源視窗的新分頁
- `src/shared/simple_handler/`：Client 端各種 Handler（標題列、生命週期、
  載入錯誤……，同樣改自 `cefsimple`）。載入錯誤頁是自己畫的中文版，
  憑證錯誤（`ERR_CERT_*`）會標成安全警告——CEF 預設會拒絕憑證有問題
  的連線，使用者看到這頁就是「被擋下來了」
- **「文件」首頁（`resources/docs/drive.*`）**：雲端硬碟式的檔案管理。左側
  ＋新增（資料夾 / 文件 / 試算表 / 簡報）、我的檔案、與我共用、近期存取、
  已加星號、垃圾桶；右側顯示目前資料夾內容，可切清單或圖示檢視；點檔案
  才開啟，文書 / 試算表 / 簡報頁面頂端有 ← 回到首頁。文書、試算表、簡報
  三種檔案共用同一套儲存（`docs.rs` 的 `Kind`，各自目錄與修改紀錄），
  資料夾存 `folders.json`，星號 / 垃圾桶 / 最近開啟是 Doc 上的欄位。
  「與我共用」目前只是說明用的空狀態：沒有雲端帳號，不可能有別人共用給
  你的檔案。瀏覽器左側圖示列的「文件」開這個首頁。
- `src/shared/docs.rs` / `docs_provider.rs` + `resources/docs/`：本機文件
  編輯器，介面照 Google 文件：頁首標題 + 「檔案 編輯 查看 插入 格式 工具
  說明」選單列（含子選單）、右上「修改紀錄」「共用」、灰底白紙的頁面。
  每份文件一個 JSON 檔存在 `~/.local/share/vpn-cef-browser/documents/`，
  前端用 `fetch()` 打 `vpndoc://app/api/*`（list/doc/new/save/delete/
  export/info/history/version/snapshot），資料不離開這台電腦。
  - 列表：摘要、相對時間、搜尋標題/內容；沒打標題時自動用內容第一行
  - 編輯：復原/重做、粗斜底刪、上下標、H1–H3/內文/引用/程式碼、對齊、
    清單/編號/核取方塊、縮排、連結、圖片（「插入 → 圖片」或工具列 🖼：**從電腦選擇**、輸入網址；也可以貼上或把圖片檔拖進文件，
    內嵌 data URL。點陣圖超過 2000 px 或 2.5 MB 會自動縮小〔最長邊 2000 px，JPEG 重新壓縮、PNG / WebP 維持 PNG 保留透明，GIF / SVG 原樣〕，
    縮完還超過 8 MB 就說明、不插入，不是圖片的檔案也會說明；`office.js` 的 `pickImage` / `pickImageBlob`）、表格、分隔線、日期、尋找與取代（Ctrl+H）、
    字數統計、1.2 秒自動存檔 + Ctrl+S
  - 修改紀錄：Rust 端每次存檔做版本快照，5 分鐘內的自動快照合併成一個
    （不然每 1.2 秒一個），最多 100 個；「保留目前版本」會釘選（檔名
    `{ts}-pinned.json`），自動存檔不覆寫也不修剪。側板點版本 → 唯讀預覽
    → 還原
  - 共用：文件只在本機、沒有雲端連結，提供複製純文字/帶格式到剪貼簿
    （scheme 加了 SECURE 才有 `navigator.clipboard`）、匯出 HTML/
    Markdown/純文字到 `~/Downloads`（或 `~/下載`）、開聊天
  - 所有對話框都自己畫（Alloy 沒有 alert/confirm/prompt）；列印 / PDF 見下面
    「文書的版面與排版」
  - 側欄「文書」「聊天」是「已開著就切過去、沒有才開新分頁」
## 開發

```bash
# 第一次要先抓 CEF prebuilt binary（約 1.5GB）
cargo run -p export-cef-dir --manifest-path <cef-rs clone 的路徑>/Cargo.toml \
  -- --force $HOME/.local/share/cef

export CEF_PATH="$HOME/.local/share/cef"
cargo build
# 啟動（debug 版；LD_LIBRARY_PATH 要指到 target/debug，libcef.so 在那裡）
LD_LIBRARY_PATH=$PWD/target/debug ./target/debug/vpn-cef-browser
```

文件編輯器的 HTML / CSS / JS 是編譯時用 `include_str!` 打包進執行檔的，改了前端
也要重新 `cargo build`。加上 `--remote-debugging-port=9222` 啟動，就能用
DevTools 協定（或 `http://127.0.0.1:9222`）檢查頁面、驅動測試。

## 打包安裝檔

```bash
packaging/build-packages.sh      # 輸出到 dist/，約 1 分鐘
```

需要 `dpkg-deb`、`fakeroot`、ImageMagick `convert`。一次產生：

- `dist/vpn-cef-browser_<版本>_amd64.deb`（約 100 MB）：Debian / Ubuntu 用
  `sudo apt install ./vpn-cef-browser_*.deb`（自動裝依賴的系統函式庫），
  裝到 `/opt/vpn-cef-browser`，終端機指令 `m`（在背景開、馬上回到命令列，可以帶網址或檔案；
  `vpn-cef-browser` 是前景執行、看得到記錄），並有應用程式選單捷徑
- `dist/vpn-cef-browser-<版本>-linux-x86_64.tar.gz`（約 135 MB）：其他主機用。
  解開後 `./vpn-cef-browser.sh` 免安裝直接跑，或 `./install.sh` 裝到自己
  帳號（`~/.local`，免 root）、`sudo ./install.sh --system` 裝給所有使用者、
  `./install.sh --uninstall` 解除安裝
- `dist/SHA256SUMS`：校驗碼

`libcef.so` 會 strip（1.4 GB → 約 270 MB），語系只帶 zh-TW / zh-CN / en-US /
en-GB。目標主機需要 x86_64 Linux 與 Chromium 依賴的系統函式庫（deb 會自動裝，
tar.gz 的 `README.txt` 有 apt 指令）。使用者資料在
`~/.local/share/vpn-cef-browser/`，解除安裝不會動它。

### Linux 版跟 macOS 版的對應（`src/linux/`）

macOS 用系統元件做的東西，Linux 各有對應的做法，改 macOS 那邊的時候這邊也要跟著看：

- **名稱 / 圖示**：捷徑名稱是「M」（`Name[zh_TW]` 也要寫，不然中文桌面會沿用舊翻譯），圖示從
  `packaging/icon/AppIcon-1024.png` 轉各種尺寸。視窗的 WM_CLASS / Wayland app id 設成
  `vpn-cef-browser`（`WindowDelegate::linux_window_properties`），要跟 .desktop 的
  `StartupWMClass` 一樣，工作列才對得到捷徑；視窗圖示另外換掉（CEF 預設是 Chromium 的藍色圓圈，
  `resources/linux/app-icon-*.png`）。標題列是「頁面標題 - M」
- **已經開著又開一次**：Linux 再按一次捷徑、用 M 開檔案、`xdg-open` 網址，都會再跑一個行程，它把命令列
  交給已經在跑的那一個後自己結束。CEF 預設的處理是開一個 Chrome 風格的視窗——就是「突然變回原版
  Chromium」（Google 首頁、「Chromium 不是你的預設瀏覽器」）。`on_already_running_app_relaunch` 改成：
  有帶網址 / 檔案就開成新分頁，沒帶就開新視窗。macOS 再按 Dock 圖示不會開第二個行程
- **檔案對話框**（網頁上傳檔案、開啟檔案、另存新檔 / 另存 PDF、設定頁選資料夾）：Alloy 風格沒有內建
  對話框，macOS 用 NSOpenPanel（`mac/file_panel.rs`），Linux 叫 zenity（KDE 用 kdialog，
  `linux/file_dialog.rs`），在背景執行緒等，選完回 UI 執行緒。zenity 4 另存新檔時檔案不存在就帶不出
  檔名，所以先放一個空檔、對話框讀到檔名就刪掉
- **快捷鍵**：macOS 走選單列，Linux 用 `Window::set_accelerator`：Ctrl+T / W / Shift+W / L / D / N /
  Shift+N / O / H / ,、Ctrl+R / F5、Ctrl+Shift+R / Shift+F5、Alt+← / → / Home、F11。同一個指令要兩組按鍵
  得用兩個 id（`set_accelerator` 會先拿掉同一個 id 原本的按鍵）；Ctrl+F5 留給簡報的「從頭播放」
- **影音**：沒有 AVPlayer，影片頁、「開啟檔案…」點影音檔交給系統的播放器（`xdg-open`）；
  縮圖用 ffmpeg（有裝才有）

### Google 雲端硬碟同步的用戶端 ID（只有發行者做一次）

辦公的「上傳到 Google 雲端硬碟」（`src/shared/gdrive_sync.rs`）用官方 Drive API，需要一組 **OAuth 用戶端 ID**。這組是**公司的**（發行者建一次、編進程式），**使用者不用建任何東西**：在設定頁按「連線」、用自己的 Google 帳號按「允許」，檔案存進他們自己的雲端硬碟。

1. Google Cloud Console → 建立（或選一個）專案 → 「API 和服務」→ 啟用 **Google Drive API**
2. 「OAuth 同意畫面」：Workspace 公司選「**內部**」（只有公司帳號能用、不用送審）；範圍加 `https://www.googleapis.com/auth/drive.file`（非敏感範圍：只能碰這個 App 自己建立的檔案）
3. 「憑證」→ 建立憑證 → **OAuth 用戶端 ID** → 應用程式類型選「**電腦版應用程式**」→ 記下用戶端 ID 跟密鑰（桌面應用程式的密鑰不是機密，Google 的規範就是編進程式；安全靠 PKCE 和使用者自己的同意）
4. 放進 `packaging/google-oauth.local`（第 1 行用戶端 ID、第 2 行密鑰；已在 `.gitignore`），再跑 `packaging/build-macos.sh`（或 `build-packages.sh`）。**要重新編譯**（`SKIP_BUILD=1` 不會編進去）。沒有這個檔案的版本，設定頁會說「還沒有設定」，使用者可以在「進階」自己填一組

### macOS

```bash
brew install rustup cmake ninja && rustup default stable   # 第一次
packaging/build-macos.sh         # 輸出到 dist/，第一次會自動下載 CEF（約 470 MB）
```

需要 Xcode（`ibtool` / `codesign` / `hdiutil`）。產生
`dist/M-<版本>-macos-<arm64|x86_64>.dmg`（約 130 MB，架構跟編譯
的機器相同），打開後把「M」拖進「應用程式」即可；`.app` 本身也留在
`dist/stage-mac/` 可直接執行測試。

- macOS 的子行程跑的是另一個執行檔 `vpn-cef-browser-helper`（包成 5 個
  `vpn-cef-browser Helper (…).app`），它也要註冊 vpndoc://，選項跟主程式共用
  `src/shared/scheme.rs`
- 預設只做 ad-hoc 簽章：別台 Mac 第一次開會被 Gatekeeper 擋，要右鍵 →「打開」，
  或 `xattr -cr "/Applications/M.app"`。有 Apple Developer ID 的話用
  `SIGN_ID="Developer ID Application: …" packaging/build-macos.sh`（要正式發佈
  還得再做 notarize）
- 使用者資料目前在 macOS 上也放 `~/.local/share/vpn-cef-browser/`
- 影音播放：CEF 標準版沒有 H.264 / AAC 解碼器（mp4、mov、m4a 播不了，mp3 /
  webm 可以）。macOS 上點到影音連結（或伺服器回應是影音）會開「播放器分頁」
  （`vpndoc://app/player.html`），把 AVKit 的 AVPlayerView 嵌在分頁內容區
  （`src/mac/player.rs`），串流播放並帶上網站 cookie；分頁上方資訊列的「下載」
  才存檔。左側「影片」（`media.html`）列出下載 / 影片 / 音樂資料夾（可以在設定頁改成自己的資料夾，見 `locations.rs`）裡的影音檔，
  左邊有資料夾樹可以切換（來源資料夾 + 底下有影音檔的子資料夾，數字是含底下的檔案數，選的資料夾會記住；「資料夾設定…」開設定頁）
  （同一個檔案掃到兩次會去掉重複；影片有縮圖，由 `qlmanage -t` 在背景產生、快取在
  `media-thumbs/`；「開啟檔案…」可以播任何位置的檔案），點了在播放器分頁播放。
  判斷規則與 API 在 `src/shared/media.rs`
  - 播放器分頁上方資訊列：「← 影片清單」（切回清單分頁）、「⛶ 全螢幕」、下載 /
    在 Finder 中顯示。**影片全螢幕**：視窗進 macOS 全螢幕、播放器蓋滿整個視窗內容，右上角一顆 ✕
    （SF Symbol `xmark.circle.fill` 的調色盤版：白 X + 半透明黑圓，亮暗畫面都看得到），按 ✕
    或 Esc 離開（Esc 在 `SimpleApplication::send_event` 攔，影片全螢幕中才攔，不管鍵盤焦點在哪）；
    切到別的分頁、關掉播放器分頁、使用者自己把視窗退出全螢幕，都會一起結束影片全螢幕
    （`media::set_video_fullscreen`、`WindowState::sync_players` / `set_fullscreen`）
  - **播放器位置的坑**：播放器是視窗內容 view 的子 view，位置要用「分頁內容區在視窗裡的範圍」算。
    原本用 `View::convert_point_to_window`，但 cef-rs 的包裝把傳進去的點複製一份再轉換、結果沒寫回
    （跟 `task_info()`、`set_preference` 的 error 參數同一類問題），永遠拿到 (0, 0)，播放器因此擺到
    視窗左上角、蓋住網址列跟左側欄。現在改用螢幕座標相減（`view.bounds_in_screen()` −
    `window.client_area_bounds_in_screen()`，見 `window_state.rs` 的 `view_rect_in_window`）。
  - 測試用：帶 `--remote-debugging-port` 的測試實例吃環境變數 `VPN_MEDIA_TRACE=1`，把播放器 view
    的位置印出來（`media-trace:`），並開 `vpndoc://app/api/media/self-test`（`click-x` 按下 ✕、
    `escape` 送出 Esc 事件、`hit` 問某個位置最上層是哪個 view）。自動測試沒有輔助使用權限、送不出真的
    滑鼠 / 鍵盤事件，所以改在程式裡面做；平常使用的 App 不開。
- 聊天（`chat.rs` + `resources/chrome/chat.html`）：左側「聊天」可以選內建聊天
  （vpnadmin）、Google Chat、LINE。LINE 個人帳號沒有網頁版，用 **LINE 官方的
  Chrome 擴充功能**在分頁裡開——只用這個方式，Linux（沒有 LINE 電腦版）也一樣
  能用；預設開哪個在
  選擇頁或「設定」裡改，在「聊天」上按右鍵可以隨時切換。為了讓 Google 登入不被
  擋，全域 User-Agent 用 CEF 預設（標準 Chromium），自家品牌「VPNBrowser/1.0」
  只加在送到 steps.tw 的請求標頭（`BrandResourceRequestHandler`）
- Chrome 擴充功能（`extensions.rs`、`crx.rs` + `extensions.html`，「⋮ → 擴充功能…」）：
  CEF 底層是完整的 Chrome，`--load-extension` 載入的擴充功能（背景程式、內容
  腳本、擴充功能頁面）在 Alloy 分頁裡都能用。安裝 = 從 Google 官方下載 .crx →
  驗證 CRX3 的 RSA 簽章（擴充功能 ID 必須等於開發者公鑰的雜湊）→ 解壓到
  `<資料夾>/extensions/<id>/`、manifest 補 `key`（ID 才會跟官方一樣）。啟動時
  在 `on_before_command_line_processing` 接成 `--load-extension`；CEF 沒有執行中
  載入的 API，所以安裝 / 停用 / 移除要重新啟動（App 內建「重新啟動」）。啟動後
  背景檢查更新，新版下次啟動換上。沒有 chrome://extensions、工具列的擴充功能
  按鈕還沒做。下載用系統的 `curl`（Linux 安裝包已列為相依套件）
- 介面語系固定繁體中文（CefSettings.locale = zh-TW），擴充功能（例如 LINE）也會
  跟著顯示中文
- 結束 App（⌘Q、重新啟動）改成「關掉所有視窗」：cefsimple 原本的
  close_all_browsers 握著 SimpleHandler 的鎖呼叫 close_browser，CEF 同步回呼
  do_close 又要拿同一把鎖，整個 App 會卡死
- 辦公的 Word / OpenDocument（`src/shared/office/` 轉檔、`office_api.rs` API、
  `resources/docs/office.js`）：全部是自己的程式，不靠 LibreOffice 或系統工具，
  Mac / Linux 一樣。首頁網址是 `vpndoc://app/office`（頁面是 drive.html，舊網址
  也還能開）。
  - 文書「檔案 → 另存新檔」：.docx、.odt（ODF 1.2，用 OASIS 官方 RelaxNG schema
    驗證過）、.rtf、.doc。Word 97 的二進位格式沒有開放的寫入實作（PHPWord 之類的
    函式庫也一樣），所以 .doc 的內容是 RTF，Word / WPS / LibreOffice 開的時候會看
    內容、正常開（Mac 的「快速查看」預覽不出來）。Mac 上跳系統的存檔視窗
  - 開啟：.docx、.odt、.rtf、.doc——.doc 看內容判斷：Word 97-2003 二進位（自己讀
    OLE 複合文件：文字、字元 / 段落格式、樣式、表格、清單、內嵌與浮動圖片、超連結）、
    其實是 RTF / HTML / MHT 的 .doc（網站系統常見）、其實是 .docx；Word 6/95 只讀
    文字。EMF / WMF 圖片瀏覽器顯示不了，留一段說明文字
  - 上傳到「辦公」的 Word / ODT / RTF 點了直接用文書開（第一次轉檔，之後開同一份），
    改完「檔案 → 儲存回原檔」照原本的格式蓋回去；Excel / ODS / CSV 用試算表開，
    試算表可以存 .xlsx / .xls / .ods（SheetJS）；PowerPoint（.pptx）用簡報開（見下面「匯入 .pptx」）
  - 安全：XML 不處理 DTD（防外部實體攻擊）、解析前先量巢狀深度（擋幾千層的惡意檔）、
    ZIP 每個檔案有大小上限；匯入的內容一律轉成中間格式再重新組 HTML（全部跳脫，
    script / 事件屬性 / javascript: 連結都不會帶進編輯器）
  - 測過的檔案：Apache POI 跟 LibreOffice 的公開測試檔（各版本 Word 產生的 .doc /
    .docx、.odt、中文 Big5 的 RTF）、macOS 存的 .doc、Laravel 式的 HTML .doc、MHT
  - PDF（`resources/docs/pdf-import.mjs`，用 pdf.js 在頁面裡轉，一樣不靠外部程式）：
    在「辦公」點 PDF 會問要「直接開啟」（PDF 檢視器）還是「轉成可編輯的文件」，可以
    記住選擇；⋮ 選單兩種都有。轉換：同一條基線的字併成行，再依行距（每頁各自統計，
    公文常見的固定 27、28 點行距也接得起來）、縮排、上一行有沒有寫滿接成段落；字大的
    短行當標題（「受文者：○○」這種欄位不算）；每頁頂端 / 底端重複出現的頁首、頁尾、
    頁碼拿掉；跨頁的段落接回去。有框線的表格從繪圖指令找出格線，合併儲存格算得出來；
    向量畫的圖表（長條圖、折線圖、流程圖）整塊畫成圖片；點陣圖片照放；掃描檔（只有
    圖片沒有文字）先警告。字型認得標楷體、細明體、正黑體等；康熙部首字元（⼀⽇）換回
    一般漢字。轉好的內容另存一份 .odt 在 PDF 旁邊，文書打開的就是它，改完「儲存回」
    存回 .odt；PDF 本身不動。文書的「檔案 → 開啟…」也能選 PDF。沒有文字層的掃描檔需要
    OCR，目前沒做；文字顏色、超連結、沒有框線的表格還不會還原
    - **理科試卷 / 數學式**（用 110 年指考物理卷測的）：Symbol 字型的私用區字元（U+F0xx，
      α β λ μ Δ Φ ≤ ×…）換回真正的希臘字母 / 符號；上下標依字級與基線轉成 `<sup>` / `<sub>`
      （T甲、R₁、10⁻⁸）；堆疊的分數（分子 / 橫線 / 分母各是獨立的字）攤平成 `(分子)/(分母)`，
      單一數字 / 字母不加括號；根號（Word 公式畫成路徑：左邊一撇 + 上面一條橫線）補回 `√`；
      絕對值的短直線補回 `|`。向量畫的圖（含圖上的標示文字，這些字不會再重複出現在內文）
      把周圍的字與圖形一起分群，整塊畫成一張圖；表格框裡其實是圖（只有一兩個字）的改成圖；
      同一列的選項圖（A）～（E）併成一段。頁首（左右兩邊用很寬的空白字隔開的）跟偶數頁
      頁首不會漏進內文。表格的橫線常被切成一格一格，分數偵測前先接成一條，不然發票這種
      表頭會被誤判成分數。(A)(B) 開頭的行接在解說文字後面（`(B)：…`）不拆段
    - 圖要「完整」：點陣圖加上蓋在上面的小圖 / 標示字（θ、座標、編號、「圖 13」說明文字都是另外畫的）
      一起算成一張圖；選項小圖（A）～（E）連左上角的字母一起裁；裁圖時別張圖的邊緣（框線、
      旁邊的標籤）蓋白；貼著圖上緣的內文（「…關係圖？」）留在內文。文字繞圖（圖在右邊、字在
      左邊窄窄一欄）：這幾行的右邊界是圖的左邊，整題的敘述接成一段，圖放在敘述後面、選項前面。
      PDF 把「10 V/m」整串當一個字、上標 6 另外畫在中間時，從上標的位置切開（10⁶ V/m）；
      空白字不自己成行（不然會搶走旁邊的上標 10⁻⁸）；有分數的行行距比較大，仍接成同一段
    - 修改這支檔案後的驗證方法：在執行中的測試實例用 Blob 載入新的 `pdf-import.mjs`
      （`import(URL.createObjectURL(new Blob([src])))`），不用重新打包；跟舊版轉同一批 PDF，
      比較圖片 / 表格 / 字數與純文字差異
- 合併原卷（`resources/docs/exam-merge.js`，文書「檔案 → 合併原卷 PDF（題目、圖 + 這份詳解）…」）：AI 小幫手寫的
  詳解 .md 只有答案和解析、放不進圖；原卷的題目與圖要從 PDF 轉。在詳解文件裡選一份原卷 PDF（辦公裡的 PDF 清單，
  或從電腦選），PDF 現場轉成題目 + 圖，跟詳解依題號合併成一份**新文件**（原本的詳解文件不動）：每題「題目 → 圖 →
  選項 →【詳解】→ 詳解內容」。切題：段落開頭是題號（`6.`、`6、`、`第 6 題`、`Q6`），題號要接得上（下一題，或最多
  跳過兩題；從 1 重新開始 = 新的一大題，所以第二部分「一、二、」各自從 1 編號也對得上，題目的身分是「第幾次
  重新編號 + 題號」）；`第 3-4 題為題組` 的短文 / 圖放在第 3 題前面；大題標題（一、二、、第○部分、說明框）不屬於上
  一題。詳解開頭的說明、標題、結尾的聊天（「已為您…製作完畢」）不放進來；沒對到題目的詳解接在最後面、原卷沒有
  詳解的題照樣留著；詳解第一段只是把題目又寫一遍（第二部分的子題）就拿掉。合併好跳摘要（N 題對到、哪幾題沒對到）。
  Gemini Canvas 抓下來的 md 換行有意義：整行粗體（`**1. 答案：(E)**`）、整行數學式各自一段，黏在一起的
  `$A$$B$` 拆成兩行，清單後面沒縮排的中文行是新的一段，畫面上的「直接編輯或用 Gemini 編輯」提示拿掉
- 用 AI 產生解析（`resources/docs/exam-ai.js`；辦公首頁 PDF 的「開啟 PDF」對話框 / ⋮ 選單「用 AI 產生解析」、文書「檔案 →
  用 AI 產生解析」）：選考卷 PDF → 對話框說明會把 PDF 送到 AI小幫手（Gemini）、可貼官方答案（選填，AI 只負責解釋）→ 一鍵完成：
  PDF 在頁面裡轉成題目 + 圖，同時後端 `ai_panel.rs` 的 `solve`（透過 `office_api.rs` 的 `ai-solve` 工作，頁面輪詢 `status`）
  打開 AI 對話窗、開新對話、在 Gemini 網頁裡跑 `SOLVE_SCRIPT`：模擬貼上 PDF（不行再試拖放、檔案輸入框；看附件有沒有出現）→
  等上傳完 → 放進提問（要求「N. 答案：(X)」格式、LaTeX 算式、不要 Canvas / 檔案 / 開場白）→ 按送出 → 等寫完（「停止」鈕消失後字數
  連續 5 秒不變；從頭到尾沒看到停止鈕就等 25 秒）→ 讀最後一則回答的 HTML，`sanitizeAiHtml`（`ai-html.js`，Gemini 的 KaTeX 節點只
  取 LaTeX）清理後依題號跟原卷合併（`exam-merge.js`）。題目多、回答被截斷時，自動在同一個對話請它「接著寫」缺的題，最多 3 輪。
  失敗（找不到輸入框 / 附不上 PDF / 送不出去 / 逾時 / 回答裡沒有題號）會說明原因，詳細步驟與 Gemini 頁面按鈕 / 元件的名稱記在
  `gdrive.log`（「AI 自動解析（Gemini 頁面）」，沒有內容，Gemini 改版時用它調整選擇器）。測試：環境變數 `VPN_TEST_GEMINI_URL`
  （只有帶 `--remote-debugging-port` 的測試實例吃）可以把 Gemini 換成本機的假頁面
- 文書的版面與排版（`resources/docs/editor-layout.js`），比照 Google 文件 / Word：
  - 字級：− / ＋（Ctrl+Shift+, / .）一次加減 1 點，也能直接打 1–400；沒選字時設的
    字級套到接著打的字。試算表（預設 10pt，工具列新加的字級）、簡報也是一次 1
  - 格式刷（工具列 🖌、「格式」選單）：按一下套用一次，按兩下可以連續套用，Esc 結束。
    文書複製粗斜底刪、上下標、文字顏色、螢光筆、字型、字級、段落樣式（標題 / 內文）、
    對齊；試算表照來源範圍的排列重複套上去（只點一格就套成跟來源一樣大），點欄 / 列
    標題整欄整列套用；簡報複製文字方塊的字級、顏色、字型、粗斜、對齊、填滿（圖形只有
    填滿；沒自己設顏色、字型的跟著主題，套到另一種文字上時改成看到的樣子）
  - 文件大綱（查看 → 顯示文件大綱，Ctrl+Alt+A）：列出 H1–H3 和所在頁碼，點了跳過去；
    整節上移 / 下移、升降階層、改名都能復原
  - 目錄（插入 → 目錄）：附頁碼（點點引線）或附連結兩種，標題改了自動更新；點目錄出現
    的小工具列可以更新、改樣式、移除
  - 頁面設定（檔案 → 頁面設定）：A4 / A3 / A5 / B4 / B5 / Letter / Legal、直式 / 橫式、
    邊界（一般、窄、適中、寬、公文）
  - 分頁顯示（預設，查看 → 分頁顯示）：跟 Word 的整頁模式一樣，一頁一張紙，每一頁都有自己
    的頁首、頁尾、頁碼。做法是把編輯區排成一欄、固定高度、會換列的多欄版面（CSS
    `column-height` + `column-wrap`，Chromium 154 有），每一列就是一頁的內容區——瀏覽器
    切欄跟列印用同一套分頁規則（孤行寡行、表格列不拆、標題不放頁底、分頁符號），所以畫面上
    每一頁的內容跟印出來的 PDF 一樣（測試逐頁比對過）。白紙畫在編輯區後面，內容多一頁時
    同一個畫格就補上。關掉分頁顯示是一整張連續的紙，用排版模擬畫分頁線
  - 頁首、頁尾、頁碼（插入 → 頁碼 / 頁首和頁尾）：靠左 / 置中 / 靠右，頁碼格式有
    「第 N 頁」「第 N 頁，共 M 頁」「N / M」「- N -」「Page N of M」，可以首頁不顯示；
    分頁符號（Ctrl+Enter）
  - 列印（Ctrl+P）與「另存新檔 → PDF」：用 CEF 的 `print_to_pdf`（`office_api.rs`
    的 print-pdf），頁首頁尾頁碼是 CSS `@page` 的邊框區，目錄的頁碼跟印出來的一樣。
    列印是把 PDF 開在新分頁預覽，從那裡印或下載；存 PDF 在 Mac 上跳系統存檔視窗
  - 存成 .docx / .odt / .rtf 都帶著這些（紙張、邊界、頁首頁尾的 PAGE / NUMPAGES
    欄位、首頁不同、目錄——Word 的 TOC 欄位 / ODF 的 table-of-content、分頁符號、
    標題書籤），開回來也讀得到；LibreOffice 開過確認一致。PDF 轉文件時也會抓紙張、
    邊界和頁首、頁碼格式。RTF 順便修了：英數跟 `\u` 中文字的順序、目錄欄位內容漏出來
- 簡報（`resources/docs/slides.*`）的主題與效果，比照 Google 簡報 / PowerPoint：
  - 主題（整份簡報的背景、配色、字型、裝飾圖形）12 種：簡約、簡約深色、商務藍、繽紛
    漸層、海洋、森林、暖陽、科技、典雅（宋體）、粉彩（圓體）、公務（標楷體）、空白；
    「標題 / 章節 / 大數字 / 引言」這類大字頁有另一套封面樣式。另有三份附範例內容的
    範本（工作報告、專案提案、產品介紹）。新簡報打開時右側先出現主題面板
  - 版面配置 8 種（標題、章節標題、標題與內容、兩欄、只有標題、大數字、引言、空白）。
    換主題時，預留位置的文字跟著換顏色、字型；還在預設位置的跟著搬，自己拖過的不動
  - 轉場（淡入淡出、推入、滑入、擦去、圓形展開、縮放、翻轉、立方體，可調速度、套用到
    全部）與物件進場動畫（淡入、浮現、四個方向飛入、縮放、彈跳、旋轉、擦去；「按一下時
    / 與上一個同時 / 在上一個之後」、延遲、順序），用 Web Animations API 播放，編輯時
    可以預覽，簡報模式按一下播下一個動畫
  - 匯出 .pptx：主題背景（漸層、裝飾）畫成背景圖（JPEG，同一種背景只存一份），轉場、
    動畫寫成 PowerPoint 的 `<p:transition>`、`<p:timing>`（翻轉、立方體是 PowerPoint
    2010 的效果，舊版退回淡入淡出），用 LibreOffice 匯入確認讀得到
  - 主題以前做的簡報維持原本的樣子（「空白」主題），套主題時會猜哪個是標題 / 副標
  - **匯入 .pptx**（`src/shared/office/pptx.rs`，只讀；檔案管理點 .pptx，或簡報頁「檔案 → 匯入 PowerPoint」）：
    自己讀 zip + XML，不靠 LibreOffice。讀：投影片順序與大小（寬度對到 960，4:3 保持比例、左右置中，
    字級同比例縮放）、背景（純色 / 漸層 / 圖片）、母片與版面配置上的裝飾圖形、文字方塊與佔位符
    （位置、字型、字級、顏色、對齊、行距、項目符號 / 編號，照 PowerPoint 的繼承順序疊起來：投影片 →
    版面配置 → 母片 → 簡報預設，顏色走主題色 + `clrMap`）、圖形（方塊、圓角、橢圓、填色、外框、旋轉、翻轉；
    常見的預設圖形——箭頭、V 形、三角形、菱形、平行四邊形、梯形、五 / 六 / 八角形、星形、加號、甜甜圈、括號——
    跟自由繪製的外形（`custGeom`：直線、貝茲曲線、弧線）都變成 SVG 路徑，公式照 presetShapeDefinitions，
    讀得到調整值）、圖片（含裁切、橢圓裁切）、表格、群組（含縮放）、直線、備忘稿、轉場。編輯器的文字方塊一個只有一種
    字型 / 字級 / 顏色，所以字級差很多的段落會拆成由上往下疊的幾個方塊。沒辦法匯入的（圖表、SmartArt、
    嵌入物件、EMF / WMF 圖片、物件動畫、陰影特效）用灰色方塊標出位置或略過，開啟時跳說明對話框
    （`?note=`）。原檔不會被更動，也沒有「儲存回原檔」：編輯器能表達的東西比 PowerPoint 少，寫回去會把
    原本的內容弄丟；要帶走用「匯出 .pptx」
  - 匯入的簡報用到這些選用欄位（`slides.js` 最上面有完整說明）：文字的 `valign`（`align-content`）、`lh`
    （行高倍數）；任何元素的 `rot`（CSS `rotate`，不用 `transform`，才不會被動畫蓋掉）、`ln` / `lnw`
    （外框）、`r`（圓角）、`path`（其他外形：SVG 路徑字串 `M L C Q Z`，座標是方塊寬高的 0~1 倍，用 inline SVG
    畫、外框用 `non-scaling-stroke`；匯出 .pptx 時寫成 PowerPoint 的自訂外形）；圖片的 `crop`、`clip`；`fill` 可以是 `transparent` 或 CSS 漸層；投影片的 `bg`
    可以是 CSS 漸層或 `url(…)`。匯出 .pptx 時都轉得回去（漸層 / 圖片背景畫成背景圖、裁切在 canvas 上裁好、
    圖片內嵌）
  - 簡報的「插入 → 圖片」跟文書一樣可以**從電腦選擇**（或輸入網址）：選到的圖片（先縮小）用 `POST vpndoc://app/api/slides-asset` 傳給 Rust
    （`docs.rs` 的 `store_uploaded_slide_image`：看檔案開頭判斷是 PNG / JPEG / GIF / WebP / BMP，不信 Content-Type；**SVG 不收**，上傳的檔案不可信；上限
    12 MB），存成下面說的簡報圖片，簡報裡只放網址；照長寬比放在投影片中間（最大 640×380）
  - 圖片不放進簡報 JSON（編輯器每次修改都存一份復原紀錄，圖片放進去會變成幾十份）：存成
    `slide-assets/<sha256>.<副檔名>`（內容相同的只有一份），簡報裡只放
    `vpndoc://app/api/slides-asset?h=<sha256>`（內容跟網址一對一，所以回應可以一直快取）。刪除簡報後沒人用
    的圖片會被清掉（簡報跟它的修改紀錄都掃過；一小時內存的不動，匯入是先存圖片再建簡報，清理剛好跑在中間
    不能刪）。匯出 .pptx / .html 時再把圖片內嵌回去
  - 檔案是不可信的：zip 有大小 / 數量上限（單一項目 120 MB、總量 600 MB、2 萬個項目、600 張投影片、每張
    800 個物件）、XML 不准有 DTD（沒有實體展開）、群組巢狀有層數上限，壞掉的部分只會讓那個物件被略過、
    不會整份失敗。`pptx.rs` 裡有 19 個單元測試（DTD / 實體、125 MB 的壓縮炸彈、路徑跳脫、座標對應、群組、
    轉場、表格、備忘稿、圖形路徑……；`cargo test` 整個專案要下載 CEF，所以是把檔案放進只有 `zip` / `roxmltree` /
    `serde_json` / `sha2` 的小專案跑）
- 左側圖示列可以自訂（左下角「自訂」，或 ⋮ → 自訂左側圖示列；頁面是
  `resources/chrome/customize.*`）：拖曳排順序、開關顯示 / 隱藏、自己加按鈕（名稱、網址、
  emoji 或一兩個字當圖示；點了就開那個網址，同一個網站已經開著就切過去）、恢復預設。在左側
  按鈕上也能直接拖曳排序、按右鍵隱藏。內建按鈕除了辦公、聊天、影片、遠端，還有「效能」
  （工作管理員）和預設隱藏的設定、擴充功能。設定存在 `settings.json` 的 `sidebar`（Rust 端
  檢查過才存，見 `settings.rs`），清單跟著外框狀態一起推給每個視窗（`window_state.rs` 的
  push），改了每個視窗馬上換；按鈕多到放不下時可以捲動
- 工作管理員（左側「效能」、⋮ 選單、macOS 選單列「視窗」；`src/shared/taskmgr.rs` +
  `resources/chrome/taskmgr.*`）：記憶體、CPU、網路三張卡片，各有最近兩分鐘的走勢圖，每 2 秒
  更新；最上面一句話說負擔輕 / 中等 / 偏重和原因（例如最耗記憶體的分頁、最耗 CPU 的程序）。
  - 記憶體：瀏覽器全部程序加起來直接問作業系統（macOS 的 phys_footprint，跟「活動監視器」
    一樣；Linux 用 PSS），跟整台電腦的已使用 / 可用、記憶體壓力、交換空間比
  - CPU：瀏覽器佔全部核心的比例、整台電腦的 CPU 使用率（兩條線畫在一起）、平均負載、
    最耗 CPU 的程序
  - 網路：瀏覽器的下載（流入）/ 上傳（流出）速度、開這頁以來的累計；整台電腦的速度讀網路
    介面的累計位元組（macOS 用 sysctl NET_RT_IFLIST2 的 64 位元計數器，Linux 讀
    /proc/net/dev），實體網路跟 VPN 通道（utun、ppp、tun、wg……）分開算，免得 VPN 的
    流量算兩次。macOS 沒有公開 API 能查單一程序的網路量，所以頁面開著的時候在背景跑系統
    內建的 `nettop`（每秒一筆、只給差值），加總這個瀏覽器所有程序。注意幾點：nettop 的
    輸出接到管線會整塊緩衝，所以給它一個虛擬終端機（openpty）；exec 前把從瀏覽器繼承來的
    檔案全部關掉（不然它會拿著瀏覽器的連線、占住埠），並把終端機設成它的控制終端，瀏覽器
    不管怎麼結束它都會收到 SIGHUP 跟著結束；頁面 20 秒沒在看就停掉。Linux 只顯示整台電腦的
  - 各分頁與程序：每個分頁、GPU、網路服務等程序的記憶體、CPU（CEF 的 TaskManager，跟
    Chrome 的工作管理員同一套；同一個程序的分頁併成一列），可以點「切換」跳到那個分頁。
    注意：cef-rs 包好的 `task_info()` 不會把結果寫回來，要直接呼叫 C 函式；TaskManager 物件
    要一直拿著才會持續取樣（頁面沒在看 20 秒就放掉）
- **內建聊天（本地聊天）**（`resources/chrome/messenger.{html,css,js}`、`messenger-data.js`，入口：左側「聊天」→ 聊天服務
  選擇頁的「內建聊天」、右鍵選單、設定的「預設聊天」；`chat.rs` 的 `MESSENGER_URL`）：像 LINE 的聊天畫面，是
  瀏覽器自己的頁面。**以前的 vpnadmin 網頁版聊天已經拿掉**（選擇頁、右鍵選單、設定、文件的「開聊天」都改開這個；舊設定值
  `builtin` 自動當成 `messenger`）。**API 已經接上**：登入（`/ping`、`POST /auth/login`、Google）、`/auth/me`、`/auth/logout`，
  以及對話、訊息、傳送、已讀、收回、靜音、隱藏 / 離開、聯絡人、建立聊天室、邀請 / 移出成員、相簿、個人資料、端對端加密與裝置核准、
  即時更新（見下面「訊息 API」）。身分驗證公司用的是 **Google**（API 文件 2.2 節）：App 不整合 Google SDK、也不用自己申請 OAuth client ID（只允許
  @steps.com.tw 的帳號、第一次登入自動建立帳號、管理員可以關閉 Google 登入），流程是四步：①開一個分頁到
  `https://<伺服器>/auth/google?client=app` ②使用者在那個分頁登入 Google（這個瀏覽器的 Google 通常已經登入）③伺服器把分頁導向
  `messageapp://oauth-callback?code=<交換碼>`（手機 App 註冊的 deep link；失敗時帶錯誤）④App 拿交換碼打
  `POST /api/auth/exchange-code {code}` 換 token（90 秒有效、只能用一次）。我們是 CEF 瀏覽器、不是手機 App，所以第 ③ 步由
  `src/shared/messenger_auth.rs` 在瀏覽器裡攔下來：`on_before_browse`、`on_resource_redirect`（伺服器 302）、未知協定的請求
  （頁面用 JS 導向）三條路都接，第一個到的算；**只認自己開的那個登入分頁**、同一時間只有一個登入在等，別的分頁導向
  `messageapp://…` 一律丟掉（不然惡意網站可以塞它自己的交換碼，讓使用者登入成別人的帳號）。結果存在 Rust 裡，聊天頁輪詢
  `api/messenger-auth`（`start` / `poll` / `cancel`），登入分頁自動關掉、回到聊天頁。`client=app` 這個參數是探出來的（伺服器把
  `client` 的值原樣當成 Google 的 `state`，其他參數名稱 `state` 都是隨機 40 字元），文件摘要沒寫第一步的網址，要核對。
  **測試站目前不能用 Google 登入**（測試站的 Google 回呼網址設成了正式站、兩站帳號和 token 各自獨立）：選測試站按 Google 登入只會說明
  原因、不會開分頁；要測 Google 登入請全程用正式站，測試站用 Email 登入。失敗情況（網域不符、Google 登入被關閉、使用者取消、交換碼
  過期）各有中文說明
  - **訊息 API**（`messenger-live.js`，依 API 文件第 3、4、6 節和網頁版 `messaging.js` / `messaging-crypto.js`）：
    - 對話 `GET /messaging`（隱藏的不列出；加密對話的預覽文件沒給明文，顯示「🔒 加密訊息」）、`GET /messaging/{id}`（成員、已讀位置，
      打開聊天室時才載入）、訊息 `GET …/messages?before_id=`（一次 50 則，往前翻頁）、傳送 `POST …/messages`（文字用 JSON；圖片 `image`
      ≤ 5 MB、其他 `file` ≤ 20 MB 用 multipart，圖片超過 5 MB 改當檔案傳；上傳進度用 XHR）、`…/messages/read`、`…/recall`、`/mute`、`/hide`、
      `/participants`（邀請 / 離開 / 移出）、`/messaging/users/all`、相簿 `…/messages/media`、`POST /auth/me`（姓名、大頭貼）。
      語音訊息可以播放、位置訊息顯示成地圖連結、通話記錄顯示成一行；列表和訊息的格式用「寬鬆解析」（陣列或包在 `data` 裡都收）。
      **置頂只存在這台電腦**（伺服器沒有這個功能，`localStorage` 的 `messenger.pins.<伺服器>.<帳號>`）
    - **端對端加密**（`messenger-crypto.js`，演算法跟網頁版相同才能互通）：每台裝置一組 ECDH P-256 金鑰（私鑰 non-extractable，存在這個頁面
      專屬的 IndexedDB，按「伺服器 + 帳號」分開，登出不刪）、每個對話一把 AES-256-GCM 金鑰（`key_version`）、wrap = ECDH 推導的 AES-GCM、
      `wrapped_key = base64(iv).base64(密文)`。帳號的第一台裝置直接 active，**之後的（包括這台電腦）是 pending，要由已核准的裝置按核准**：
      pending 時讀得到明文訊息、讀不到加密訊息（顯示「🔒 這台裝置還沒核准」），而且**不能在加密的聊天室傳訊息**（不會偷偷改傳明文，網頁版小視窗會）。
      「設定 → 裝置與加密」可以看帳號底下所有裝置、核准（重新包裝每個對話的每個版本的金鑰給新裝置）、拒絕、撤銷、重新申請。
      對話還沒有金鑰（加密上線前的舊對話）時，第一次傳訊息現場產生並 `POST /keys` 補發；對方沒有任何加密裝置就維持明文。
      邀請成員時依文件產生新版本金鑰（+1）、包給所有現有成員和新成員的每台裝置
    - **即時更新**（`messenger-rt.js`，Reverb / Pusher 協定，不用 pusher-js）：`GET /broadcast-config` → 連 `wss://host:port/app/key` →
      訂閱 `private-App.Models.User.{id}`（所有新訊息）和目前聊天室的 `presence-conversation.{id}`（已讀、編輯、收回）；事件
      `message.sent` / `message.updated` / `conversation.read` / `call.ringing`（來電只跳提示，內建聊天不能通話）。斷線 1 → 30 秒重連、
      重連後補抓漏掉的；即時連線一直連不上時 15 秒更新一次（平常兩分鐘一次當保險）。**沒有「輸入中」提示**（文件沒有這個事件）
    - **頻道授權要請瀏覽器本體代打**：`POST /broadcasting/auth` 在網站根目錄、不在 `/api` 底下，伺服器沒有替它送 CORS 標頭（預檢回 200 但沒有
      `access-control-*`，實測過），頁面直接打會被擋。所以先試直接 `fetch`（哪天伺服器開了 CORS 就會通），失敗就改 `messenger_auth.rs` 的
      `channel-auth`：用 **curl**（`.deb` 已經依賴 curl）在背景執行緒打，頁面輪詢結果；網址只准自家伺服器的 `/broadcasting/auth`，
      token 等欄位只准簡單字元、從 stdin 給 curl 的設定檔（不會出現在程序列表）。這個請求不走瀏覽器的代理 / 連線出口設定
    - **貼圖**（API 文件第 7 節；`messenger-live.js` 的 `api.stickers` / `sendSticker`、`messenger.js` 的貼圖面板與商店）：輸入框旁的貼圖鈕開面板（`GET /messaging/sticker-packs`
      已下載的貼圖包，上面一排貼圖包、左邊 ＋ 是商店），**點一張就直接送出**（`POST …/messages {sticker_id}`，JSON；帶著回覆的話一起送 `reply_to_message_id`）；
      貼圖畫成沒有泡泡的大圖（128 px）、可以回覆、轉傳（用 `sticker_id` 重送）、傳送失敗可以重送。貼圖商店（`GET …/sticker-packs/store?category=&sort=popular|new`）：
      熱門 / 最新、分類（商店的資料有 `category` 才顯示）、「下載」（`POST …/{id}/download`，冪等）；沒下載的貼圖包的貼圖傳送會被伺服器 403，畫面說「還沒有下載」。
      商店的回應格式文件沒寫細（`cover_url`、`downloaded`、`category`、`sticker_count` 這些欄位名稱是照常見寫法猜的，用寬鬆解析）。示範資料有 4 個貼圖包可以玩
    - 圖片、檔案的網址：公開的直接 `<img>` 載入；要帶 token 才讀得到的（`/api/…`）載入失敗後改用 `Authorization` 抓成 blob（只對伺服器自己的網址帶 token）。
      下載：先用 token 抓 blob 存檔，抓不到（伺服器沒開 CORS）就請瀏覽器本體下載（`messenger_auth.rs` 的 `download`，只准 https）
    - **沒做**：輸入中、通話、編輯訊息、表情回應、跨單位聊天（文件沒有說明格式，這個頁籤顯示「還沒接上」）、推播。
      正式站目前沒有照片 / 檔案列表和修改個人資料的 API（`POST /auth/me` 回 405），畫面標示「這個伺服器還沒有」；伺服器只能改姓名和大頭貼，
      暱稱 / 狀態 / 電話 / Email 畫面上是灰的
    - **驗證範圍**：全部都只對「照 API 文件寫的假伺服器」測過（含用 Node WebCrypto 扮演對方和另一台裝置，驗證加解密、金鑰包裝互通），
      **沒有對真的伺服器、真的帳號測過**（登入真伺服器要使用者自己操作）；回應格式文件沒寫死的地方用寬鬆解析，實際有出入時看頁面的 console
  - 登入前先選伺服器：正式站（`portal.steps.tw/api`）/ 測試站（`vpnadmin.steps.tw/api`），旁邊的燈是真的打 `/api/ping` 量出來的
    （`/ping` 一分鐘只能 10 次：30 秒內量過的不重量、429 當成「通的」）；Google 登入或 Email + 密碼；只存 token
    （`localStorage` 的 `messenger.token.<伺服器>`，內建頁面專用的網域，一般網站讀不到）、不存密碼；頁面重新打開時用存的 token 問
    `/auth/me`，有效就直接進去，401 就回登入畫面；「網頁版已登入就直接帶入」那一列已經有畫面，偵測本身（`detectWebLogin`）還沒接。
    請求直接從頁面用 `fetch` 打（`/api` 底下的 CORS 開放 `authorization` 標頭，實測 preflight 通過）
    登入頁最下面「先看看畫面（示範資料）」會多出第三個「示範資料」伺服器，不連線、任何帳密都能進
  - 登入後三欄：功能列（個人資料、聊天〔未讀總數〕、聯絡人、跨單位、設定、登出）、清單（搜尋、全部 / 未讀 / 群組、置頂、靜音、
    未讀數、輸入中、右鍵選單）、對話（日期分隔、未讀分隔線、群組顯示名字、已讀 / 已讀 N、輸入中的三個點、回覆引用、@提及、
    連結、照片九宮格與大圖檢視、影片、檔案卡片與上傳進度、收回、轉傳、傳送中 / 失敗重送、訊息搜尋 ⌘F、表情面板、貼上 / 拖曳 / 選檔案
    附件、輸入法組字中不會誤送出）；右邊可展開聊天室資訊（成員、照片、檔案、連結、靜音、置頂、離開）；新增聊天 / 建立群組；
    聯絡人（依部門）；跨單位聯絡人；設定（個人資料、通知、聊天、外觀〔淺 / 深 / 跟隨系統、文字大小〕、伺服器與帳號、關於）；
    窄視窗清單與對話二選一。只在測試站才有的 API（跨單位聯絡人、照片與檔案列表、修改個人資料）在正式站畫面上標示「這個伺服器還沒有」
  - 資料層是 `Api`（`messenger-data.js` 最上面有資料格式的說明）：`DemoApi` 能玩所有畫面（收訊息、已讀、輸入中、傳檔、輸入
    `/fail` 看傳送失敗）；`LiveApi`（`messenger-data.js` 管連線和登入、`messenger-live.js` 管訊息）接上的見上面，沒做的丟 `NotConnected`。測試用：`localStorage['messenger.apiOverride']`（JSON，例如 `{"test":"http://127.0.0.1:9777/api"}`）可以把伺服器位址換成假伺服器——只能用 localStorage，
    **不能**改成讀網址參數（任何網頁都能把分頁導到 `vpndoc://app/messenger.html?…`，那樣就能把登入導去別的伺服器騙密碼）預覽用網址：`messenger.html?demo=1&view=settings&tab=server`
  - 測試：用 CDP 逐一操作畫面（登入、清單、開對話、資訊欄、搜尋、傳送 / 失敗 / 重送 / 回覆 / 附件 / 表情、大圖、收回、輸入中、新增群組、
    聯絡人、跨單位、設定各頁、深色、窄視窗、登出）77 項；另外用假伺服器（照文件的通用規則：Bearer、422 的 `errors`、401）測真的 API 流程
    （Email 登入、錯誤訊息、token 存取、重新開頁面自動登入、過期、登出，以及 Google 登入：302 / JS 導向 deep link、四種失敗、交換碼只能用一次、
    取消、使用者關掉登入分頁、別的分頁送來的 deep link 被丟掉、沒有在等時的 deep link）46 項；訊息 API 另外用假伺服器（含 WebSocket 的 Pusher 協定、
    Node WebCrypto 的「對方」和「我的另一台裝置」）測 121 項（含貼圖：面板、商店、下載、傳送、403、失敗重送、收到對方貼圖、轉傳）：列表 / 預覽 / 未讀、裝置註冊與 pending 的限制、核准後解密、加密送出（對方裝置解得開）、
    回覆、即時新訊息 / 已讀 / 收回 / 編輯、附件（含超過上限）、需要登入的圖片、相簿、建立聊天室（有無加密裝置）、邀請 / 移出成員、靜音 / 置頂 / 離開、
    個人資料、裝置頁核准（另一台裝置解得開舊訊息）、撤銷、斷線重連補抓、伺服器刪掉裝置後重新註冊、即時連線連不上時的定時更新；視窗沒有焦點時新訊息照樣畫出來、但留著未讀
- 右下角的 Gemini 對話窗（介面上的名稱是「**AI小幫手**」：左側按鈕、⋮ 選單、對話窗標題列、右鍵選單的「用 AI小幫手解釋 / 翻譯 /
  摘要」、提示訊息都這樣叫；程式碼、註解、這份說明裡講的是它背後的服務 Gemini）（`src/shared/ai_panel.rs` + `resources/chrome/aipanel.*`）：每個視窗
  右下角常駐一顆圓形按鈕，點了在它上面跳出對話窗（浮在網頁上、不擠壓網頁），再點一次收起來；
  左上角可以拖曳調大小（預設 360×460，最大不會超出網頁區）。裡面直接是 Gemini 網頁版，用使用者
  在瀏覽器登入的 Google 帳號——公司 Google Workspace 的 Gemini 直接能用，不需要 API 金鑰。
  - 標題列：問這一頁（把目前分頁的文字連同標題、網址放進輸入框，使用者看過再自己送出）、
    套用到文件（把 Gemini 最新一則回答放進目前「辦公」文件的游標位置，有選取就取代；回答先
    清理成段落、標題、粗斜體、清單、表格、程式碼、http(s) 連結、**內嵌的圖片**，script、事件屬性、按鈕、遠端網址的圖片、SVG
    全部拿掉；Ctrl+Z 可以復原。**回答裡的圖片會一起帶進文件**：Gemini 頁面裡的腳本（`LAST_ANSWER_SCRIPT`，非同步，`Runtime.evaluate` 要 `awaitPromise`）
    把每張圖讀成 data URL——圖片網址常常是 `blob:` 或要在 Gemini 頁面才讀得到，貼進文件就不能用了；順序：已經是 data: → `fetch`（先不帶、再帶 cookie）→
    已載入的圖畫到 canvas；最長邊超過 1600 px 或 1.5 MB 的縮小；小於 48 px 的（網站小圖示、頭像）和「來源」清單裡的不算內容；最多 30 張、總共 24 MB；
    讀不到的（跨網域又沒有 CORS）丟掉，提示會說「有 N 張圖片讀不到」。文件編輯器只收 `data:image/png|jpeg|gif|webp|bmp;base64`；
    「存進辦公」沒有檔案連結時存成新文件的路徑（辦公的 HTML 匯入）一樣會帶圖片。只有圖片、沒有文字的回答也能貼）。**你上傳給 AI小幫手的圖片也會一併放進文件**
    （在你那一則提問裡、不在回答裡，所以要另外讀：同一支腳本往前看最近 3 則提問〔`user-query`〕，找到有圖片的那一則為止，圖片依 `user-query` 和 `model-response`
    在頁面上的順序判斷；Google 圖片網址尾巴的 `=s200` 之類縮圖大小會先改成 `=s1600` 要大一點的；同一個網址只放一次；一張一個段落、**接在回答後面**，
    檔名當替代文字；設定頁「AI小幫手」可以關，`ai_skip_uploads`）。提問裡有圖片但一張都沒帶進來時，把那些 `<img>` 的形狀（網址類型、寬度、class、父元素，不含網址）
    記到 `gdrive.log`，才知道 Gemini 的上傳預覽長什麼樣。圖片大小未知（還沒載入 / 載入失敗）時看畫面上佔的位置，兩邊都小於 48 px 才算小圖示——
    不然讀不到的圖片會被當成小圖示靜靜丟掉）、新對話、在分頁中開啟、關閉
  - **Markdown（.md）與數學式**（`resources/docs/markdown.js`，純 JS、瀏覽器和 Node 都能跑，單元測試用 Node 直接跑）：
    - AI 產生的 .md 在辦公首頁顯示成「Markdown」，點開（或「存進辦公」下載到 .md）就用文書開、**排好版**：標題、粗斜體、刪除線、清單（巢狀、待辦）、表格（對齊）、引用、程式碼、連結；原始 HTML（`<script>`、`onerror`）一律當文字，不執行；YAML front matter 拿掉；中文換行不加空白；「$5 和 $10」的金額不是數學式。第一次開才轉（`office_api.rs` 的 `md-target` 建一份連到原檔的文書，頁面 `?importmd=` 讀原檔、轉好存起來），之後直接開那一份，原檔不動；「儲存回原檔」（`save-back-md`）把文件轉回 Markdown 蓋回 .md
    - **數學式**（`$…$`、`$$…$$`、`\(…\)`、`\[…\]`、`\begin{align}…`）：轉成**可讀的 Unicode 符號 + 上下標**——希臘字母、∑ ∫ √ ∞ ∈ ℝ ≤ → 等符號、`x²`（`<sup>`）、`∑ᵢ₌₁ⁿ`（`<sub>`/`<sup>`）、分數 `(a+b)/(c+d)`、根號、`\mathbb`、重音、`\left…\right`、`cases`/`aligned`；關係符號、運算符號兩側和逗號後面加間距（上下標裡不加）。這樣文書、Word、PDF 裡都是一般文字 + 上下標，不是 LaTeX 原始碼。每個數學式是 `<span class="math" data-latex="原始 LaTeX" contenteditable="false">`（獨立一行的是置中的 `<p class="math-block">`），匯出 Markdown 時還原成 `$…$` / `$$…$$`
    - **限制**：不是排版好的公式（沒有上下疊的分數、大括號跨列、矩陣方框）：分數寫成 `(a+b)/(c+d)`、矩陣寫成 `(a, b; c, d)`；匯出 Word 是一般文字 + 上下標，不是 Word 的公式物件。要真正的排版公式要另外做（MathML / 圖片）
    - AI 助手回答的「貼到文件」：Gemini 的數學式（KaTeX，節點裡同一個式子有好幾份）只取 LaTeX（`data-math` 或 `annotation[encoding="application/x-tex"]`）轉成上面的格式，不會再出現重複、亂掉的文字（**Gemini 真實頁面的結構沒看過，測試用模擬的節點**）
    - 文書的「檔案 → 另存新檔 → Markdown」本來就有，現在數學式、上下標會還原
    - **新增 .md**：辦公首頁「＋ 新增 → Markdown 文件（.md）」（和右鍵選單）先問檔名，建一個空的 .md 檔（放進檔案管理、類型 Markdown）+ 一份連到它的文書（`md-new`，`source_auto = true`）。
      之後文件**每次存檔自動同步到 .md**（`autoSaveMarkdown`，文件轉成 Markdown，數學式還原成 LaTeX；清空內容 .md 也變空的）。匯入別人的 .md 不會自動同步（只有手動「儲存回原檔」）。
      **不蓋掉別人的修改**：`source_hash` 記最後一次寫進 / 讀自 .md 的 sha256；同步前比對，.md 被別的程式改過就不寫、畫面說明一次；
      「檔案 → 從原檔重新載入」把 .md 現在的內容讀進文件（不會當場又寫回去、並更新指紋），「檔案 → 儲存回原檔」是明確的覆蓋、之後恢復自動同步
  - **上傳到 Google 雲端硬碟**（`src/shared/gdrive_sync.rs` + `resources/docs/cloud.js`；跟下面的「存進辦公」方向相反）：
    - 設定頁「Google 雲端硬碟同步」：連線 / 中斷連線、雲端資料夾名稱（預設「辦公文件」，改名會改雲端既有的那個資料夾）、轉成 Google 文件 / 試算表 / 簡報（預設會轉，取消就保持 .docx / .xlsx / .pptx）、編輯後自動更新（預設不會）、進階（自己的用戶端 ID / 密鑰）
    - 連線：OAuth 2.0 loopback + PKCE（S256），開一個分頁到 Google 的同意畫面，Google 導回 `http://127.0.0.1:<隨機埠>/`，一次性小伺服器收下（檢查 `state`、`Host`；其他路徑 404；結果頁的文字都跳脫）；權限只要 `drive.file`，同意畫面可以只勾部分權限，沒勾到會說明。Google 擋內建瀏覽器時，設定頁有「改用系統的瀏覽器連線」。token 存在 `<資料夾>/gdrive-token.json`（0600；refresh token 不送到網頁、不寫進記錄）。用戶端 ID 換了就要重新連線；長期憑證被撤銷（`invalid_grant`）會清掉並要求重新連線
    - 上傳：Drive 的 resumable upload（先送中繼資料、再送本體），網路用系統的 curl（設定從 stdin 給）。第一次建立、之後再按**更新同一個檔案**（辦公資料夾的 `gdrive-sync.json`：本機文件 → 雲端檔案代號，記著帳號，換 Google 帳號就重來）；雲端的資料夾照辦公的資料夾結構建（用名稱找得到就不重複建）；本機搬了資料夾，雲端檔案跟著搬（`addParents` / `removeParents`）；雲端的資料夾、檔案被刪掉會重建，被丟進垃圾桶會另外建一份；401 換一次 token 再試，429 / 5xx 重試
    - 檔案由網頁產生：文書 → `Office.buildModel` → Rust 寫 .docx；試算表（SheetJS）、簡報（PptxGenJS）→ base64；上傳的檔案（kind=file）後端直接讀本機的檔案。入口：三種編輯器的「檔案」選單、辦公首頁檔案 / 資料夾的右鍵、左側「上傳到雲端硬碟」（只傳新的跟上傳之後改過的；多個先確認，顯示進度，失敗會列原因）。辦公首頁代跑文書 / 試算表 / 簡報時，開隱藏的 iframe（`?gsync=1`，不存檔、不更新「最近開啟」），編輯器頁面載入完自己上傳，再 postMessage 回報
    - **只有「上傳」**：雲端上改的不會回來（要的話用「存進辦公」拿回來）；刪除本機文件不會刪雲端的檔案；沒有衝突處理（以最後一次上傳為準）
    - 測試：測試實例可以用環境變數 `VPN_TEST_GDRIVE_BASE=http://127.0.0.1:<埠>` 把 Google 換成本機的假伺服器（只有帶 `--remote-debugging-port` 的實例吃）；測試實例要加 `--use-mock-keychain`，不然啟動時會讀「Chromium Safe Storage」、在使用者畫面跳出鑰匙圈詢問
  - 存進辦公（`src/shared/gdrive.rs`、`gdrive_link.rs`）：Gemini 讀取、改寫、產生檔案之後，按「存進辦公」把檔案拿回來。
    找檔案的順序：①最新一則回答裡（任何元素的任何屬性、文字裡寫出來的連結，含 shadow DOM；Gemini 的檔案卡片不一定是
    `<a>`）②目前開著的 Google 分頁網址 ③整頁（含內嵌資料）**最後出現**的 Google 檔案 / 下載連結。認得的：Google 文件 / 試算表 / 簡報
    （算出匯出網址：→ docx / xlsx / pptx）、雲端硬碟的檔案（原檔）、**Gemini 產生的檔案**（`contribution.usercontent.google.com/download?…`，
    網址帶 `filename=`）。用瀏覽器自己的下載功能（`BrowserHost::start_download`，帶著登入的 cookie，不用 API 金鑰）下載；下載處理器認出是這邊發起的
    （網址比對），存到暫存資料夾、不放進「下載項目」。**格式以內容為準**：檔名沒有副檔名、不對（簡報被標成 .docx）都照壓縮檔裡的內容
    （`ppt/presentation.xml`、`word/document.xml`、`xl/workbook.xml`、ODF 的 mimetype）改過來，看不出來才用連結告訴我們的格式。
    轉進辦公：docx / odt / rtf → 文書、xlsx / ods / csv → 試算表、pptx → 簡報，**存好直接在前景新分頁打開、切過去**；其他（pdf、圖片、舊的 .ppt / .odp 等）
    → 辦公首頁「我的檔案」。一次最多 5 個檔案，全部有結果才顯示一則總結。Google 回傳登入網頁（沒登入、沒權限、檔案太大）、連不上、超過 40 秒沒開始，都有說明。
    **Gemini 產生的檔案卡片**（`<generated-file>`，程式碼執行做出來的 Word / Excel / 簡報…）：下載網址只存在 Gemini 的 JS 裡、DOM 讀不到，
    所以找不到連結時由 Gemini 自己下載：先「待命」（`gdrive.rs` 的 `Pending` 沒有網址、只認領 Gemini 那一頁發起的下載），再在 Gemini 網頁裡按卡片上的下載鈕
    （`ai_panel.rs` 的 `CLICK_FILES_SCRIPT`，用 `Runtime.evaluate` 的 `userGesture` 當成使用者按的），收到的檔案照上面的流程轉進辦公；
    Chromium 的「自動下載」限制只放行第一個沒有真人操作的下載，所以按之前先對 gemini.google.com 開放（`RequestContext::set_content_setting`，只這個網站）。
    按了 40 秒內沒收到檔案會說明。**Canvas 預覽**（`<immersive-entry-chip>`）不是檔案：說明要先在 Canvas 裡匯出（例如匯出到 Google 簡報 / 文件）、把那份開在分頁裡再按，不存成文件。
    **找不到任何檔案連結**時：回答很長（≥ 200 字）或不是在講檔案 → 把這則回答本身存成辦公裡的一份新文件（用辦公匯入網頁的同一套轉換，只留標題、段落、清單、
    表格、連結、粗斜體；標題是第一行；一樣直接打開）；回答很短又在講檔案（「您的 Word 文件已順利製作完成」）→ 不存（存一句話沒有用，以前簡報會因此變成一份只有那句話的「文件」），
    說明找不到檔案連結。提示：成功的 5 秒自己消失、不擋滑鼠；錯誤的 20 秒，有「✕」、點一下也會關掉。每一步記在 `~/.local/share/vpn-cef-browser/gdrive.log`
    （連結只留形狀、代號遮掉），找不到檔案時另外記「頁面指紋」（回答裡的標籤、屬性名稱、件數，沒有內容），之後才知道 Gemini 的檔案卡片長什麼樣。網址解析（含多帳號
    `/u/1/`、Google 轉址包起來的連結、發佈連結 / 資料夾 / 表單不算、網域偽裝、下載網址的檔名不能帶路徑）和內容判斷格式有單元測試。測試
    用：帶 `--remote-debugging-port` 的測試實例吃環境變數 `VPN_TEST_GOOGLE_BASE`，把 Google 換成
    本機的假伺服器（平常使用的 App 不吃這個變數）；端對端測試 14 項（直接開在前景、檔案卡片、整頁找、簡報格式、提示）+ 舊的多檔匯入 / 備援回歸測試
  - 日誌（`println!` 會在 stdout 被關掉、管線斷了、磁碟滿了時 panic，而 CEF 的回呼是 `extern "C"`，panic 會讓整個 App abort；測試時真的發生過）：
    在 CEF 回呼裡跑的程式碼改用 `shared/mod.rs` 的 `say!`（印不出去就算了）
  - 網頁上選取文字按右鍵：用 Gemini 解釋 / 翻譯 / 摘要（放進對話窗並送出）
  - 對話窗第一次打開才建，收起來 10 分鐘沒再開就整個放掉（Gemini 一頁約 250–400 MB）。平常
    從左側的 Gemini 按鈕、⋮ 選單開。右下角的浮動圓形按鈕**預設不放**（在「自訂左側圖示列」頁
    打開）：它是浮在視窗上的原生元件，有使用者回報裝了之後整個 App 收不到輸入（網址列打不了字、
    左側點不動），我們這邊重現不了，原因沒確定，所以先改成要自己開
  - 做法注意：按鈕是 CEF Views 的 overlay（`Window::add_overlay_view`，停靠右下角、不拿鍵盤
    焦點），原生 LabelButton 放 PNG（不另開網頁程序）。對話窗是無邊框的子視窗
    （`WindowDelegate::parent_window` 回主視窗，跟著主視窗移動），位置照網頁區的螢幕座標算，
    不會超出網頁區。不要把對話窗做成「可以拿鍵盤焦點」的 overlay：macOS 上打開之後主視窗就
    收不到滑鼠、鍵盤（整個像卡住）。改大小之後要過一下再通知 Browser 重算大小（was_resized）；
    任何地方都不要握著 `WindowState::ai` 的鎖去呼叫 CEF（有些呼叫會同步回呼、push 也要這把鎖）。讀 Gemini 的回答用 DevTools 的
    Runtime.evaluate（BrowserHost::execute_dev_tools_method + 觀察者），Google 的網頁有
    Trusted Types，注入的程式不能用 innerHTML 塞字串
- 輸入診斷（暫時的，`src/mac/mod.rs` 的 `debug_input`）：App 開啟後的前兩分鐘，把視窗焦點狀態
  （App 有沒有啟用、誰是鍵盤焦點視窗）每秒、滑鼠按下 / 鍵盤按下的事件（種類、送到哪個視窗、
  點擊位置）記到 `~/.local/share/vpn-cef-browser/debug-input.log`；主視窗上幾個位置最上層是
  哪種元件（正常都是 `RenderWidgetHostViewCocoa`）。**不記按了什麼鍵、打了什麼字**，也不會傳到
  任何地方。App 啟用中卻沒有任何鍵盤焦點視窗時，會自動把最大的可見視窗設成焦點。用來查「在開發的
  機器上重現不了、使用者那邊整個沒反應」這類問題，查完就拿掉
- macOS 鑰匙圈提示與簽章（`packaging/build-macos.sh`）：Chromium 把加密 cookie 的金鑰放在 macOS
  鑰匙圈（Safe Storage），鑰匙圈認的是程式的**簽章身分**。原本用臨時簽章（ad-hoc），身分要求是
  程式內容的雜湊值（`cdhash`），每次編譯都不同，所以每個新版本第一次開都會跳「要不要讓這個 App
  使用鑰匙圈」，輸入登入密碼按「永遠允許」才會繼續（提示出現的時候瀏覽器在等你，看起來像卡住；
  舊版授權還會一個個累積在項目的存取清單裡）。現在打包固定用同一張憑證簽（預設找這台電腦上第一個
  有效的「Apple Development: …」，也可以用 `SIGN_ID` 環境變數或 `packaging/sign-id.local`
  指定），身分要求變成「識別碼 + 蘋果簽發的這張憑證」，所有版本都一樣，只會問最後一次。驗證過：
  同一個憑證簽的兩個不同版本，後一版讀得到前一版建立的鑰匙圈項目、不跳提示。**不要隨便換憑證或
  改回臨時簽章**，換了就會再問一次。憑證到期（2027 年 9–10 月）換新的同名憑證，身分要求不變
- 連線出口（虛擬國外 IP，`src/shared/exits.rs`、`socks_relay.rs`、`tor.rs` + `resources/chrome/exits.*`，
  ⋮ 選單的「連線出口」、網址列右邊的 🌐 按鈕）：讓**網頁**的流量經由國外的代理出去，網站依 IP 判斷國家
  時看到的是代理那一端的。這個 App 自己的連線（遠端連線、安裝擴充功能）不經過出口。
  - 出口 = 自己填的代理（SOCKS5 / HTTP / HTTPS，可有帳號密碼）或內建 Tor。設定存 `exits.json`
    （權限 0600，密碼不會傳給網頁）。套用方式是 CEF 的 `RequestContext::set_preference("proxy",
    {mode: fixed_servers, server, bypass_list})` + `webrtc.ip_handling_policy =
    disable_non_proxied_udp`（不然網頁用 WebRTC 就查得到真 IP）。全域 context 在
    `on_context_initialized`、每個無痕視窗的 context 在 `open_window` 建好時套用，之後改了用
    `apply_all()` 全部重套。**代理連不上就是連不上，不會偷偷直連**：選了出口卻用不了（Tor 還沒連好、
    設定壞了）時，代理指到一個不會回應的本機埠（`socks5://127.0.0.1:9`），錯誤頁會說是出口的問題。
  - 預設不走出口的網站：`steps.tw`（公司的網站，走國外出口常常連不上）；清單可改。Chromium 的
    `<local>` 規則讓本機位址一律直連；清單裡的網域連同底下所有網站都直連（程式會同時給
    `網域` 跟 `*.網域` 兩條規則）。
  - SOCKS5 要帳號密碼：Chromium 不支援，所以在本機開一個不用密碼的 SOCKS5 入口（`socks_relay.rs`，
    127.0.0.1、隨機埠，只支援 CONNECT、網域交給上游解析，不在本機查 DNS），由它帶帳密（RFC 1929）
    去連真正的代理。HTTP / HTTPS 代理的帳密走 `RequestHandler::auth_credentials`。注意：Chrome 引導
    的 CEF 預設把認證詢問交給 Chrome 自己的登入視窗（Alloy 風格沒有，直接當成取消，
    `auth_credentials` 根本不會被叫到，網頁顯示 `ERR_INVALID_AUTH_CREDENTIALS`），要在
    `on_before_command_line_processing` 加 `--disable-chrome-login-prompt` 才會交給我們。帳密不對時
    Chromium 會一直重問，所以同一個網址 10 秒內問到第 3 次就放棄（錯誤頁講「不接受帳號密碼」）。
  - CEF 的坑：`close_all_connections` / `clear_http_auth_credentials` **一定要給 callback**（傳 None
    會直接當機：CEF 沒檢查 null）；切換出口或改帳密後要關掉現有連線、清掉代理的認證快取，不然網頁的
    keep-alive 連線還在走舊的出口、舊的密碼還被拿來用。`set_preference` 的 `error` 參數不能給空的
    `CefString`（cef-rs 會把空字串變成 NULL 指標，CEF 看到就回失敗，而且錯誤訊息是空的）。
    `webrtc.multiple_routes_enabled`、`webrtc.nonproxied_udp_enabled` 在這版 CEF 不存在（設不了）。
  - 設定頁：目前網站看到的 IP（頁面自己 `fetch` api.country.is 等公開服務，所以走的路徑跟其他網頁一
    樣）、出口清單（點選切換、測試連線、編輯、刪除）、新增表單、不走出口的網站。「測試連線」在背景執行緒
    連到代理、認證、要求連到一個目標就關掉（不傳資料），頁面輪詢結果——不要在 IO 執行緒上做，會卡住所有
    分頁的請求。
  - 內建 Tor：**Tor 本體不跟 App 一起發**。使用者在設定頁按「下載並安裝」，才從 torproject.org 用 `curl`
    （只准 https）下載官方的 Tor Expert Bundle，用寫死在 `tor.rs` 的 SHA-256 核對（來源是 Tor 官方簽署的
    `sha256sums-signed-build.txt`；PGP 簽章這台電腦沒有 gpg，沒驗），只解出 tor、libevent、geoip、授權文字，
    macOS 用臨時簽章簽一次（官方的 tor 沒簽章，Apple Silicon 不簽不能執行），放在
    `~/.local/share/vpn-cef-browser/tor/<版本>/`。不隨 App 散布是因為這個 tor 是 GPLv3 的組建。要升級 Tor：
    改 `tor.rs` 的 `BUNDLE_VERSION`、`TOR_VERSION` 跟 `BUILDS` 裡各平台的檔案大小與 SHA-256
    （`https://dist.torproject.org/torbrowser/<版本>/sha256sums-signed-build.txt`）。支援 macOS（arm64、
    x86_64）跟 Linux x86_64（**Linux 沒有實機測過**）。
  - Tor 的啟動：自己產生 torrc（`SocksPort auto`、`ControlPort auto`，只綁 127.0.0.1，cookie 認證，資料夾
    0700），從 `ControlPortWriteToFile` 讀控制埠，`TAKEOWNERSHIP`——App 結束或被強制結束，控制連線一斷
    Tor 就跟著結束，不會留下孤兒程序（有測 `kill -9`）。連線進度讀 `status/bootstrap-phase`，工具列顯示
    「🧅 Tor 連線中 45%」；連好之前網頁是連不上的（不會直連）。App 重開時，上次用的是 Tor 就自動連。
    選出口國家：`SETCONF ExitNodes={jp} StrictNodes=1` + `SIGNAL NEWNYM`；能選哪些國家是從共識
    （`GETINFO ns/all`）數有 Exit 旗標的節點、用 Tor 的 GeoIP 查國家（約 3000 個節點，一批 100 個指令
    pipeline）。**注意**：Tor 選的是「節點所在的國家」（OR 位址），少數節點實際對外的 IP 在別的國家，所以
    網站看到的 IP 國家偶爾跟選的不同，要按「換一條線路」再試；一般的查 IP 服務對 Tor 出口只回 `T1`（
    Cloudflare 對 Tor 出口的標記），所以 Tor 的 IP 國家用 Tor 自己的 GeoIP 查（`tor-ip-country`）。
    測試用：帶 `--remote-debugging-port` 的測試實例吃環境變數 `VPN_TOR_TRACE=1`，把送給 Tor 控制埠的指令印出來。
  - 不是 Tor Browser：只是把網頁的流量從 Tor 出去換 IP，瀏覽器本身沒有防指紋的處理，**不保證匿名**；很多
    網站（Cloudflare 後面的）會擋或一直要驗證 Tor 出口；速度比一般連線慢。代理的主人看得到你連到哪些網站。
  - 這個功能的自動化測試用本機的假代理（HTTP、HTTP 帳密、SOCKS5 帳密、SOCKS5，各自扮演「網站」回
    `served-by:<代理>`，這樣看得出請求走了哪條路）+ `--host-resolver-rules` 把假網域指到本機。驗證過：
    直連 / 各種代理 / 帳密 / 不經過出口 / 本機直連 / 壞出口不直連 / 切回直連 / 無痕視窗 / WebRTC 無候選位址 /
    重開機後第一個請求就走出口 / 密碼改錯不無限重試。
- 瀏覽記錄（`src/shared/history.rs` + `resources/chrome/history.*`，⋮ 選單的「歷史紀錄」、macOS 選單列
  「歷史記錄 → 顯示全部歷史記錄」、⌘Y；Linux / Windows 是 Ctrl+H）。**不放左側圖示列**（使用者要求）。
  - 記錄：`on_address_change`（主框架網址確定時，單頁應用程式換網址、網頁還沒載完也算）記一筆（網址、
    時間），標題由 `on_title_change` 補上（`WindowState::set_title_for_browser`）。只記分頁裡的
    http / https / file；**無痕視窗不記**；內建頁面（vpndoc://）、擴充功能、錯誤頁（data:）、
    about:blank 都不記。同一個網頁（不看 # 後面）30 秒內再出現算同一筆（重新整理、錨點跳轉）。
  - 存在 `history.json`（0600，全在記憶體，改了等 0.8 秒合併寫檔），保留 90 天、最多 10 萬筆。
  - 歷史紀錄頁：依日期分組、搜尋（空白隔開的每個字都要出現在標題或網址裡）、勾選刪除、單筆 ✕、
    「⋯」→ 刪除這個網站的所有記錄、「清除瀏覽記錄…」（過去一小時 / 今天 / 7 天 / 4 週 / 全部時間）。
    網址、標題都是網頁給的，一律用 textContent；file:// 的項目由瀏覽器開（內建頁面不能自己導到
    本機檔案）。API 是 `vpndoc://app/api/browse-history`（`api/history` 是文件的修改紀錄，不同東西——
    一開始撞名差點把文件的版本紀錄擋掉）。
  - `query_param`（docs_provider.rs）改成自己解碼：CEF 的 `uridecode` 用 NORMAL 規則不解 `%20`、
    `%2F`，也不處理 `+`，搜尋字、有空白或斜線的檔案路徑（影片縮圖）都會壞掉。
- 測試用視窗：帶 `--remote-debugging-port` 啟動的視窗（自動化測試用）頂端會有紅色警示條、外框一圈
  紅框，平常開 App 不會有。為了避免把測試視窗當成平常用的 App 去操作
- 內建頁面 / API 的保護（`docs_provider.rs`、`simple_handler/mod.rs`）：
  - API 只准內建頁面呼叫（發起者不是 `vpndoc://app` 就 403）；內建頁面也不准被別的網站放進 iframe / object
    （點擊劫持：把內建頁面透明地蓋在誘餌按鈕上）——`resource_request_handler` 擋掉發起者是外面網站的子框架請求，
    HTML 回應再帶 `X-Frame-Options: SAMEORIGIN` 跟 `frame-ancestors 'self'`；上傳的 SVG 回應帶
    `Content-Security-Policy: sandbox`（在分頁裡開不能執行裡面的 script）、HTML 一律當純文字
  - **踩過**：cef-rs 附的 `StreamResourceHandler` 的 header 參數是壞的——`CefStringMultimap::clone()` 複製的是
    一個空殼、不是指標，傳給 CEF 的是亂指的位址，fetch 看到的回應 header 只剩 Content-Type，上面那些 header
    **一個都沒送出去**（網站真的能用 iframe 嵌入內建頁面、上傳的 SVG 在分頁裡開真的會執行 script，都實測重現
    過）。改成自己實作的 `BytesHandler`：`Response::set_header_by_name` 一個一個設，文字回應用
    `Response::set_charset("utf-8")`。驗證：fetch 讀得到 `cache-control`、`x-frame-options`、
    `content-security-policy`；外面網站的 iframe / object / embed 載入內建頁面得到 403
- 內建字型：標楷體（DFKai-SB）是華康的商業字型，授權不允許另外散布，所以內建
  國發會的**全字庫正楷體**（TW-Kai，政府資料開放授權條款第 1 版，要註明出處，
  見 `resources/fonts/LICENSE-TW-Kai.txt`）。`common.css` / `editor.css` 用
  `@font-face` 把它登記成「標楷體」「DFKai-SB」「BiauKai」：電腦上有真的標楷體
  就用 `local()` 的，沒有才用內建的。字型檔 37 MB 不放進 git，打包腳本缺檔時
  會跑 `packaging/fetch-fonts.sh` 下載；開發時也可以自己先跑一次
- 遠端連線（`remote.rs`、`rdp.rs` + `resources/chrome/remote.*` / `terminal.*` /
  `rdp.*`，左側「遠端」）：連線清單存 `remote.json`，**不存密碼**。
  - Linux 主機（SSH）/ 本機終端機：在 PTY（portable-pty）裡跑系統的 `ssh` 或登入
    shell，畫面是 xterm.js（`resources/vendor/`）
  - 分頁內的 Windows 遠端桌面用 IronRDP（MIT / Apache-2.0）。交涉時 NLA（CredSSP）
    跟 TLS 都提供：Windows 會選 NLA；不支援 NLA 的（例如 Linux 的 xrdp）用 TLS，
    帳密在 TLS 裡送、自動登入。舊式的「標準 RDP 安全性」（RC4、無法確認主機身分）
    IronRDP 不支援，連線頁會說明怎麼在主機上開 TLS（xrdp：`adduser xrdp ssl-cert`、
    `security_layer=negotiate`），或改用 Windows App / Remmina。主機憑證幾乎都是
    自簽，用「第一次信任、之後比對 SHA-256 指紋」，變了會先擋下來問
  - 每筆 RDP 可以設解析度（符合分頁 / Retina 高解析度 / 固定大小）、色彩深度
    （15～32 位元）、畫質（最佳畫質 / 平衡 / 省頻寬：開多少視覺效果、允不允許
    有損壓縮），連線前也能臨時改；「用 Windows App 開啟」時一起寫進 .rdp 檔
    （Linux 是 Remmina 的設定檔）。整體資料壓縮（MPPC）沒開：IronRDP 只解得開
    快速路徑的封包，主機壓了一般路徑的封包就會出錯
  - 網頁跟 Rust 之間走 127.0.0.1 的 WebSocket 橋接（tungstenite）：一次性工作階段
    + 隨機通行碼，只收 `Origin: vpndoc://app`。Chromium 的「區域網路存取」保護要
    先問使用者，Alloy 沒有詢問視窗、請求會一直卡著，所以 `SimplePermissionHandler`
    只對內建頁面自動允許
  - 連上後主機馬上主動中斷（MCS Disconnect Provider Ultimatum）會顯示「主機結束了
    這次連線」；xrdp 最常見的是桌面環境啟動失敗（`/var/log/xrdp-sesman.log` 會寫
    「Window manager … exited quickly」）。解不開的單一封包會略過，工具列出現 ⚠，
    點了可以複製詳細資訊
  - 還沒有：剪貼簿同步、聲音、遠端游標形狀、連線中改解析度。Mac 的 ⌘ 當 Ctrl 送

## 已知問題 / 待辦

- 已有多分頁（＋/✕/Ctrl+T/Ctrl+W、彈出視窗變分頁）、憑證檢視、本機文件
  編輯器、書籤、下載、瀏覽記錄；還沒有的：網址列輸入時的建議（歷史 / 書籤）、分頁拖曳排序、
  分頁太多時的溢位處理（目前只會一直往右排）
- 簡報匯入（見上面「匯入 .pptx」）還沒有的：舊格式 .ppt、OpenDocument 的 .odp、圖表與 SmartArt（用灰色
  方塊標出位置）、物件動畫、沒列在上面的預設圖形（波浪、雲朵、對話框的尾巴……變成方塊或橢圓）、文字裡的部分格式（一個方塊只有一種字型 / 字級 / 顏色）、超連結、陰影與特效；
  沒有「儲存回原檔」（要帶走用「匯出 .pptx」）
- Alloy 風格缺的 Chrome 內建 UI 目前只補了載入錯誤頁；JS 對話框
  （alert/confirm）、權限提示、檔案選擇、列印等還沒接
- 視窗一開始顯示會是 10x10 的小方塊，即使用了 `WindowDelegate::
  initial_bounds`（建立「之前」就告訴 CEF 要多大）也沒用，要等外部
  （視窗管理員、或像我們測試時用 `xdotool windowsize` 手動）觸發一次
  resize 事件之後才會變成正確大小。這跟 Tauri 版踩過的「視窗管理器
  協商」問題是同一類，高度懷疑是這台測試機（xrdp 遠端桌面）特有的
  限制，還沒在走正常本機顯示/其他遠端桌面方案的機器上驗證過是否也
  會發生
- 視窗大小變動時 `on_window_bounds_changed` 會對每個分頁的 Browser 呼叫
  `was_resized()`（Alloy 風格不會自己做），分頁從隱藏切成可見時也會
- **「畫面一片白、點什麼都沒反應」的真正根因已經找到並修掉了，跟
  xrdp / GPU 都無關**（之前 README 把它歸咎於 xrdp 虛擬桌面，是誤判；
  在本機主控台的 GNOME Wayland 上重現了一模一樣的症狀之後，用
  `--remote-debugging-port` + gdb 才抓到）。一共是四個疊在一起的 bug：
  1. **IO 執行緒死鎖**：`on_before_resource_load` 握著 cef-rs
     `ResourceManager` 的 Mutex 去呼叫它，而它內部的
     `ResourceManagerRequest::send_request` 又回頭鎖同一個 Mutex。第一個
     請求（首頁導航）就把 IO 執行緒卡死，之後所有網路請求、瀏覽器行程跟
     renderer 之間的 IPC 全部停擺——所以頁面載不出來、輸入也沒反應，但
     UI 執行緒其實還活著（工具列畫得出來）。修法是不用那層包裝，
     `vpndoc://` 請求直接在 `resource_handler()` 回應（見 `docs_provider.rs` / `simple_handler/mod.rs` 的
     說明；後來連 `StreamResourceHandler` 也換成自己的 `BytesHandler`，原因見上面「內建頁面 / API 的保護」）。
  2. **cef-rs 巨集的預設回傳值是 CANCEL**：`wrap_*!` 對沒實作的回呼回
     `Default::default()`，而 `cef_return_value_t` 第一個值是 `RV_CANCEL`。
     `ResourceRequestHandler::on_before_resource_load` 一定要明確回
     `CONTINUE`，否則請求直接被取消。
  3. **自訂 scheme 只在瀏覽器行程註冊**：`execute_process` 原本傳 `None`
     當 App（照抄 cefsimple），renderer/zygote 不知道 `vpndoc` 是
     STANDARD scheme，主框架導航拿到 200 回應之後在 commit 階段被取消
     （`net::ERR_ABORTED`）。所有行程都要傳同一個 App。
  4. **mime type 不能帶 `; charset=`**：`CefResponse::SetMimeType` 收到
     `text/html; charset=utf-8` 會讓 Chromium 退回內容嗅探，整頁 HTML 被
     當純文字顯示；mime type 只放純型別，charset 用 `Response::set_charset`。

  另外網址列 Enter 沒反應是因為 Linux/Windows 上 Views Textfield 的按鍵
  事件型別是 `RAWKEYDOWN` 不是 `KEYDOWN`。修完之後在本機主控台
  （GNOME 46 Wayland，透過 Xwayland）實測：視窗一開就是正確的
  1280x800、網頁正常載入渲染、工具列按鈕/網址列 Enter/側邊欄「文書」
  都能用、文件編輯器可以新增/存檔。

  實測時要注意輸入法：這台 GNOME 的 ibus 預設是注音（chewing），在
  網址列打英文會變成注音組字、Enter 會被拿去送出組字；切到英文
  （`ibus engine xkb:us::eng`）就正常。
- xrdp 遠端桌面環境下的驗證還是沒做過（上面的 bug 修掉之後沒再回去
  試），不排除 xrdp 本身另有問題，但目前沒有證據。

- 網站要求 HTTP 基本驗證（401，例如路由器、NAS、有設 htpasswd 的網站）會直接失敗（`ERR_INVALID_AUTH_CREDENTIALS`），
  沒有登入視窗：Alloy 風格沒有 Chrome 的登入視窗，我們的 `auth_credentials` 目前只回答連線出口的代理。要支援的話
  得自己做一個詢問帳密的視窗，再 `AuthCallback::cont`。
- 開發注意：改大型 JS 檔時不要用「從函式 A 切到標記 B」的方式整段取代：標記之間
  可能夾著別的函式，語法檢查（`node --check`）看不出來，執行時才
  ReferenceError。用函式自己的收尾 `}` 定界，並斷言被取代的範圍沒有
  吞掉別的函式。
