From f4a275de92ce7793cd0e9a8e5d0f38e853b9be0b Mon Sep 17 00:00:00 2001 From: uc-hoba Date: Wed, 10 Jun 2026 10:23:24 +0800 Subject: [PATCH] chore(vscode): add formatter and organize imports settings --- .vscode/settings.json | 9 + biome.json | 5 + docs/architecture.md | 403 ++++++++++++++++++++++++++++++++++++++++++ next.config.ts | 2 +- package.json | 2 +- postcss.config.mjs | 2 +- src/app/layout.tsx | 18 +- src/app/page.tsx | 10 +- 8 files changed, 434 insertions(+), 17 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 docs/architecture.md diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..481210c --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,9 @@ +{ + "editor.defaultFormatter": "biomejs.biome", + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.organizeImports.biome": "explicit", + "source.fixAll.biome": "explicit" + }, + "css.lint.unknownAtRules": "ignore" +} diff --git a/biome.json b/biome.json index 41b3b95..0b14472 100644 --- a/biome.json +++ b/biome.json @@ -27,6 +27,11 @@ "react": "recommended" } }, + "javascript": { + "formatter": { + "quoteStyle": "single" + } + }, "assist": { "actions": { "source": { diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..711cf9b --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,403 @@ +# 展件互動系統 — 開發架構文件(Windows 10 原生版) + +> 三組電腦+螢幕、三台 iPad,使用者透過 iPad 操控螢幕內容,流程結束後上傳圖片至客戶 FTPS,並以 QR Code 顯示對應圖片網址供民眾掃描。 +> +> 部署環境:Host 為 **Windows 10**(較舊機器,不使用 Docker),所有服務以原生方式安裝。 + +--- + +## 1. 系統總覽 + +### 1.1 硬體拓撲 + +| 角色 | 數量 | 跑什麼 | 網路 | +|---|---|---|---| +| Host 主機 | 1 | Caddy + Next.js(pm2)+ Mosquitto(Windows Service)+ 一個 Unity client | 有線連 AP/switch(建議) | +| Unity 主機 | 2 | 各一個 Unity client | 有線(建議) | +| iPad | 3 | PWA(加到主畫面、Single App Mode 鎖機) | WiFi | +| 螢幕 | 3 | 由各自的 Unity client 輸出 | — | + +> Host 主機(Win10)同時跑 broker、Caddy、Next.js、以及第一組的 Unity client;另外兩台只跑 Unity client。 + +### 1.2 服務管理方式(Win10) + +| 服務 | 啟動方式 | 開機自啟 | +|---|---|---| +| Mosquitto | Windows Service | 服務自動啟動(最先就緒) | +| Next.js(standalone) | pm2 | startup 資料夾的 bat | +| Caddy | pm2 | startup 資料夾的 bat | + +- Mosquitto 走獨立 Windows Service,作為最底層依賴最先起來。 +- Next.js 與 Caddy 由 pm2 管理,開機透過「startup 資料夾的 bat + Windows 自動登入」拉起。 +- **務必設定 Windows 自動登入**(`netplwiz` 取消「使用者必須輸入密碼」),否則機器重開停在登入畫面時 bat 不會執行、服務不會啟動。 + +### 1.3 資料流(單一 station) + +``` +iPad (PWA) --- MQTT over wss ---> Caddy (TLS 443) --- ws ---> Mosquitto (localhost:9001) + | + | MQTT TCP 1883 + v + Unity client (subscribe) + | + 使用者操作互動內容 ─────────────────────────────────────────────────┘ + | + 流程結束 ── iPad 觸發 ──> Next.js Server Action ── basic-ftp ──> 客戶 FTPS + | + 上傳成功 + 驗證 URL 可達 + | + publish 最終 URL 至 station topic + | + ┌────────────────────────┴───────────────────┐ + v v + iPad 顯示 QR Code Unity 螢幕顯示 QR Code +``` + +### 1.4 技術選型摘要 + +- **螢幕內容**:Unity(原生 MQTT TCP client) +- **iPad 介面**:Next.js + PWA(**純 PWA,不走 Apple Developer / IPA**),zustand 保存裝置設定 +- **訊息中介**:Mosquitto(Windows Service),同時開 1883(TCP,給 Unity)與 9001(WebSocket,經 Caddy 給 iPad) +- **反向代理 / TLS**:Caddy(原生安裝,pm2 管理,手動掛 mkcert 憑證,**關閉自動 HTTPS**) +- **圖片上傳**:Next.js Server Action + `basic-ftp`(Node runtime) +- **程序管理**:pm2(Next.js + Caddy) + +--- + +## 2. 為什麼是純 PWA(決策紀錄) + +在「iOS + 永不碰 Apple Developer 簽證 + 展場 Kiosk」三個約束下,PWA 是唯一解: + +| 方案 | 是否產生 .ipa | 簽證到期問題 | 結論 | +|---|---|---|---| +| 原生 IPA | 是 | 每年到期、需重簽 | ✗ 排除 | +| Capacitor(WebView 殼) | 是 | 每年到期 | ✗ 排除 | +| Expo / React Native | 是(EAS Build) | 每年到期 | ✗ 排除 | +| **純 PWA** | **否** | **無此問題** | ✓ 採用 | + +關鍵釐清: + +- **Guided Access(引導使用模式)是 iOS 系統層級功能,鎖的是前景 app,不限原生或 Safari**。因此 PWA 一樣能被鎖。 +- 展場長展期、無人看管,建議用 **Apple Configurator 2 的 Single App Mode / Autonomous Single App Mode** 做到「開機自動鎖在這個 PWA、免人工」。**此功能不需要 Apple Developer 帳號**(Configurator 是免費 Mac 工具,針對自有裝置),與簽證無關。 +- Expo Go 不可用於正式佈署(開發沙盒、功能受限);EAS Build 產出的仍是標準需簽證的 app。 + +--- + +## 3. 網路與 TLS + +### 3.1 為什麼需要 TLS + +PWA 要在 secure context 下運作,且 `wss://`(MQTT over WebSocket)也要求加密連線。區網內無公網域名,無法用 Let's Encrypt,故採 **mkcert 自簽 + iPad 安裝 RootCA**。 + +### 3.2 mkcert 在 Windows + +- 安裝後 `mkcert -install` 會將 RootCA 裝進 Windows 憑證庫。 +- 產 leaf 給 Caddy 用:`mkcert 192.168.1.50 host.local`(**SAN 必須包含連線用的 IP**)。 +- **CAROOT 路徑在 `%LOCALAPPDATA%\mkcert`**,內含 `rootCA.pem` 與 `rootCA-key.pem`。 +- **`rootCA-key.pem` 是整個系統的命脈**:重簽 leaf 靠它,遺失會導致重簽產生新 Root、iPad 全部要重裝。務必異地備份。 + +### 3.3 憑證效期 + +- **leaf 憑證(`cert.pem` / `key.pem`)**:mkcert v1.4.4 起效期約 2 年多。到期只需在 host 重簽,**iPad 完全不用動**(iPad 信任的是 RootCA,非 leaf)。 +- **RootCA**:效期約 10 年。到期才需重新在每台 iPad 安裝(屆時設備早已汰換)。 +- 重簽時 SAN 必須與原本一致(同組 IP / 主機名),否則 PWA 連線會因 SAN 不符報錯。 + +### 3.4 iPad 安裝 RootCA 流程(易漏) + +1. 將 `rootCA.pem` 傳到 iPad(AirDrop / 郵件 / 網頁下載)並安裝描述檔。 +2. **設定 → 一般 → VPN 與裝置管理** 安裝描述檔。 +3. **設定 → 一般 → 關於本機 → 憑證信任設定** 手動「啟用完整信任」。**只裝描述檔不夠,這步必做。** + +### 3.5 固定 IP + +Host 必須使用固定 IP(DHCP 保留或靜態)。iPad 端以 zustand 保存 host IP,且憑證 SAN 綁定 IP,host 換 IP 會導致設定與憑證同時失效。 + +### 3.6 Windows 防火牆 + +Windows Defender 防火牆需手動放行對外 port: + +- `443`(Caddy,對 iPad WiFi 開放) +- `1883`(Mosquitto TCP,對另外兩台 Unity 主機的區網 IP 開放) +- `9001` 僅 localhost 內部使用(Caddy 反代),**不需對外開放** + +Caddy 與 Mosquitto 首次啟動時 Windows 可能跳防火牆詢問,選允許。 + +### 3.7 WiFi 即時性建議 + +- 專用 AP、專用 SSID,不與公眾 WiFi 混用。 +- 控制訊號用 QoS 0、payload 最小化,高頻操作(連續拖曳)在 iPad 端 throttle/debounce 後再 publish。 +- Host 與 Unity 主機盡量走有線,只讓 iPad 走 WiFi。 + +--- + +## 4. MQTT 設計 + +### 4.1 連線方式 + +| 來源 | 協定 | 目標 | TLS | +|---|---|---|---| +| iPad(PWA / MQTT.js) | MQTT over WebSocket | `wss:///mqtt` → Caddy → Mosquitto localhost:9001 | 是(Caddy 收 TLS) | +| Unity client | MQTT over TCP | `:1883` | 否(區網內 TCP) | + +> Caddy 與 Mosquitto 同在一台 host,Caddy 直接反代 `localhost:9001`,無 Docker 跨網路問題。TLS 集中在 Caddy;Mosquitto 走明文 ws / tcp(封閉區網內)。 + +### 4.2 Topic 命名規範 + +採 `station/{stationId}/...` 命名空間,`stationId` 為 `1` / `2` / `3`。 + +| Topic | 方向 | QoS | Retained | 說明 | +|---|---|---|---|---| +| `station/{id}/control` | iPad → Unity | 0 | 否 | 即時控制訊號(操作、移動、選取) | +| `station/{id}/state` | Host → all | 1 | 是 | 流程狀態(single source of truth),重連後可立即取得當前狀態 | +| `station/{id}/command` | iPad → Host | 1 | 否 | 流程指令(如「開始上傳」) | +| `station/{id}/result` | Host → all | 1 | 是 | 最終圖片 URL,iPad 與 Unity 皆訂閱以顯示 QR | +| `station/{id}/error` | Host → all | 1 | 否 | 明確的錯誤狀態(帶錯誤碼/原因) | +| `station/{id}/status/ipad` | iPad LWT | 1 | 是 | iPad 上下線(Last Will) | +| `station/{id}/status/unity` | Unity LWT | 1 | 是 | Unity 上下線(Last Will) | + +### 4.3 即時性與穩定性原則 + +- **QoS 分級**:即時控制 QoS 0(降延遲);狀態轉換 / 結果 / 錯誤 QoS 1(確保送達)。 +- **Retained + LWT**:用 LWT 偵測斷線;retained message 讓裝置重連後立即取得最新狀態與結果。 +- **狀態 single source of truth**:流程狀態以 Host 為準(`station/{id}/state`),不可只存在各 client 記憶體。任一 client 重開後須能由 retained state 接回原進度。 + +### 4.4 iPad 與螢幕的配對 + +- iPad 以 zustand persist 保存 `{ hostIp, stationId }`。 +- iPad 須提供「重新設定」入口(因 iOS 可能在系統清理時清除 PWA storage,設定遺失需可重設)。 +- 配對機制建議:首次開啟顯示設定頁手動輸入 / 選擇 stationId 與 host IP;之後自動沿用。 + +--- + +## 5. 圖片上傳與 QR 顯示 + +### 5.1 流程 + +1. 流程結束,iPad 透過 `station/{id}/command` 或直接呼叫 Next.js Server Action 觸發上傳。 +2. Server Action 用 `basic-ftp` 上傳圖片至客戶 FTPS(已驗證可用)。 +3. **上傳成功後,對「對外 HTTP(S) URL」做一次 HEAD 驗證可達**,再產生 QR。 +4. Host 將最終 URL publish 到 `station/{id}/result`(retained, QoS 1)。 +5. iPad 與 Unity 各自訂閱 `result`,**任一端皆可渲染 QR**。URL 的產生與驗證集中在 Host 一處,新增螢幕顯示為零成本。 + +### 5.2 注意事項 + +- **「上傳成功」≠「URL 可被掃描存取」**:FTPS 是上傳協定,掃描走 HTTP(S)。需與客戶確認上傳路徑對應的對外 URL 規則與 web server。 +- **上傳到可讀之間可能有延遲**:故步驟 3 先 HEAD 驗證,避免掃到 404;必要時加重試。 +- **FTPS passive mode 與防火牆**:確認 passive port range 未被擋。 +- **Server Action runtime**:`basic-ftp` 是 Node API,須跑在 Node runtime(standalone 預設即是,勿標為 edge)。 +- **並發控制**:三台可能同時送,上傳建議以 queue 串接而非無限制並發,避免打爆 FTPS 連線數。 +- **Windows 路徑**:暫存圖片路徑等請用 `path.join` 或 Windows 路徑,勿寫死 POSIX 斜線。 + +### 5.3 失敗處理 + +- 失敗時 Host 透過 `station/{id}/error` 廣播明確錯誤狀態(錯誤碼/原因),iPad 與 Unity 兩端皆顯示失敗,維持 single source of truth。 +- 提供**自動重試(數次)+ 手動重試按鈕**,讓現場工作人員可一鍵重送。 +- 考慮失敗時**本地暫存圖片、事後補傳**,避免使用者該張圖片遺失。 + +--- + +## 6. 部署(Win10 原生) + +### 6.1 安裝清單 + +| 元件 | 安裝方式 | +|---|---| +| Node.js 20 LTS | 官方 Windows installer(Win10 可正常運作) | +| pm2 | `npm i -g pm2` | +| Caddy | 下載 Windows 版 `caddy.exe`,放固定路徑(如 `C:\caddy\`) | +| Mosquitto | 官方 Windows installer,安裝時註冊為 Windows Service | +| mkcert | 下載 Windows 版執行檔,`mkcert -install` | + +### 6.2 目錄建議 + +``` +C:\exhibit\ + web\ # Next.js 專案 + .next\standalone\ # build 產物,pm2 在此跑 server.js + caddy\ + caddy.exe + Caddyfile + certs\ + cert.pem + key.pem + scripts\ + start-services.bat # 放進 startup 資料夾 +``` + +### 6.3 啟動順序與依賴 + +- Mosquitto(Windows Service)開機自動啟動,最先就緒。 +- Next.js 與 Caddy 由 bat 啟動;兩者彼此無依賴,皆連 Mosquitto。 +- bat 開頭可加短暫等待,確保 Mosquitto service 已就緒。 + +--- + +## 7. 範本檔案 + +### 7.1 `Caddyfile` + +```caddyfile +{ + # 關閉自動 HTTPS:區網自簽憑證,不走 ACME + auto_https off +} + +# 以 host IP 作為 site address +https://192.168.1.50 { + # 手動指定 mkcert 產出的憑證(Windows 路徑) + tls C:\caddy\certs\cert.pem C:\caddy\certs\key.pem + + # MQTT over WebSocket → 同機 Mosquitto 9001 + @mqtt path /mqtt* + handle @mqtt { + reverse_proxy localhost:9001 + } + + # 其餘流量 → Next.js + handle { + reverse_proxy localhost:3000 + } +} +``` + +> Caddy 會偵測憑證檔變動並嘗試熱載;重簽後通常只需覆寫 `cert.pem` / `key.pem`。實際行為請以你使用的 Caddy 版本實測確認,必要時 `caddy reload`。 + +### 7.2 `mosquitto.conf`(Windows Service) + +```conf +# TCP listener — 給區網內的 Unity client +listener 1883 0.0.0.0 +protocol mqtt + +# WebSocket listener — 由同機 Caddy 反代給 iPad(僅需 localhost) +listener 9001 localhost +protocol websockets + +# 封閉區網環境,允許匿名連線 +allow_anonymous true + +# 持久化(保留 retained message 與 session) +persistence true +persistence_location C:\Program Files\mosquitto\data\ + +# 日誌 +log_dest file C:\Program Files\mosquitto\mosquitto.log +log_type error +log_type warning +log_type notice +``` + +> 9001 綁 `localhost` 即可(只給同機 Caddy 反代用,不需對外)。1883 綁 `0.0.0.0` 讓區網內 Unity 主機連得到。修改 conf 後需重啟 Mosquitto service 生效。 + +### 7.3 Next.js standalone 設定 + +`next.config.js`: + +```js +/** @type {import('next').NextConfig} */ +const nextConfig = { + output: 'standalone', +}; + +module.exports = nextConfig; +``` + +Build 後處理(standalone 不含靜態資源,須手動複製): + +```bat +:: 在專案根目錄 build 後執行 +xcopy /E /I /Y public .next\standalone\public +xcopy /E /I /Y .next\static .next\standalone\.next\static +``` + +> standalone 產物跑的是 `node server.js`,**不是** `next start`。`public\` 與 `.next\static` 漏複製會導致靜態資源 404。 + +### 7.4 啟動 bat:`C:\exhibit\scripts\start-services.bat` + +放進 startup 資料夾(`Win + R` → `shell:startup`),搭配 Windows 自動登入。 + +```bat +@echo off +:: 等待 Mosquitto service 就緒 +timeout /t 5 /nobreak + +:: 清掉前次殘留的 pm2 程序,確保乾淨啟動 +pm2 delete all + +:: 啟動 Next.js standalone +cd /d C:\exhibit\web\.next\standalone +pm2 start server.js --name nextjs + +:: 啟動 Caddy(用 caddy run 讓 pm2 接管,勿用 caddy start) +pm2 start "C:\caddy\caddy.exe" --name caddy -- run --config C:\caddy\Caddyfile + +:: 儲存目前程序清單 +pm2 save +``` + +環境變數(FTPS 帳密、對外 URL)建議用 pm2 ecosystem 檔注入,避免寫死於程式碼: + +`C:\exhibit\web\.next\standalone\ecosystem.config.js` + +```js +module.exports = { + apps: [ + { + name: 'nextjs', + script: 'server.js', + env: { + NODE_ENV: 'production', + PORT: '3000', + FTPS_HOST: '', + FTPS_USER: '', + FTPS_PASSWORD: '', + FTPS_SECURE: 'true', + PUBLIC_IMAGE_BASE_URL: '<向客戶確認後填入>', + }, + }, + ], +}; +``` + +若改用 ecosystem 檔,bat 中 Next.js 那行改為: + +```bat +cd /d C:\exhibit\web\.next\standalone +pm2 start ecosystem.config.js +``` + +--- + +## 8. 開工檢查清單 + +部署現場最容易出問題的順序:**WiFi 延遲 > iPad 憑證信任那步 > 開機服務未自動拉起**。 + +- [ ] Host 設為固定 IP,憑證 SAN 包含此 IP +- [ ] mkcert CAROOT(`%LOCALAPPDATA%\mkcert`,含 `rootCA-key.pem`)已備份至異地 +- [ ] 三台 iPad 已安裝 RootCA **且啟用完整信任** +- [ ] 三台 iPad 設定 Single App Mode(Apple Configurator)並測試開機自動鎖機 +- [ ] iPad 自動鎖定設為「永不」+ PWA 端 Wake Lock 實測 +- [ ] PWA manifest `display: standalone`、防呆 CSS(`overscroll-behavior`、`user-select`、`touch-action`) +- [ ] PWA service worker 更新策略:能偵測新版並刷新,避免跑到舊 code +- [ ] zustand persist 設定(hostIp / stationId)+「重新設定」入口 +- [ ] Mosquitto 已註冊為 Windows Service 且設為自動啟動 +- [ ] **Windows 自動登入已設定**(`netplwiz`),bat 才會在開機後執行 +- [ ] start-services.bat 已放入 startup 資料夾 +- [ ] pm2 跑 Caddy 用 `caddy run`(非 `caddy start`) +- [ ] Next.js standalone 已複製 `public\` 與 `.next\static` +- [ ] Windows 防火牆放行 443、1883(對 Unity 主機) +- [ ] 與客戶確認 FTPS 上傳路徑對應的對外 HTTP(S) URL 規則 +- [ ] 與客戶確認 FTPS passive port range 未被防火牆擋 +- [ ] 上傳後 HEAD 驗證 URL 可達再產生 QR +- [ ] 上傳失敗的自動重試 + 手動重試 + 本地暫存補傳 +- [ ] **實際重新開機測試**:確認 Mosquitto / Next.js / Caddy 三者皆自動起來、iPad 連得上 + +--- + +## 9. 待釐清事項 + +1. **FTPS 上傳路徑 → 對外 URL 對應規則**(最高優先,影響 QR 內容是否可掃) +2. **iPad 與 station 的配對機制細節**(手動輸入 / QR 綁定 / URL 參數) +3. **QR 最終顯示位置**(iPad 先做,預期螢幕也需顯示 — 架構已支援零成本擴充) +4. Caddy 版本對憑證檔變動的熱載行為(決定重簽後是否需手動 reload) +5. Host 機器的 Win10 build 版本(確認 Node 20 相容性;過舊則退 Node 18) diff --git a/next.config.ts b/next.config.ts index e9ffa30..5e891cf 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,4 +1,4 @@ -import type { NextConfig } from "next"; +import type { NextConfig } from 'next'; const nextConfig: NextConfig = { /* config options here */ diff --git a/package.json b/package.json index 06b3b6c..b4c0528 100644 --- a/package.json +++ b/package.json @@ -31,4 +31,4 @@ "sharp", "unrs-resolver" ] -} +} diff --git a/postcss.config.mjs b/postcss.config.mjs index 61e3684..297374d 100644 --- a/postcss.config.mjs +++ b/postcss.config.mjs @@ -1,6 +1,6 @@ const config = { plugins: { - "@tailwindcss/postcss": {}, + '@tailwindcss/postcss': {}, }, }; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 976eb90..a173809 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,20 +1,20 @@ -import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; -import "./globals.css"; +import type { Metadata } from 'next'; +import { Geist, Geist_Mono } from 'next/font/google'; +import './globals.css'; const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], + variable: '--font-geist-sans', + subsets: ['latin'], }); const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], + variable: '--font-geist-mono', + subsets: ['latin'], }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: 'Create Next App', + description: 'Generated by create next app', }; export default function RootLayout({ diff --git a/src/app/page.tsx b/src/app/page.tsx index 3f36f7c..8b31f77 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,4 +1,4 @@ -import Image from "next/image"; +import Image from 'next/image'; export default function Home() { return ( @@ -17,20 +17,20 @@ export default function Home() { To get started, edit the page.tsx file.

- Looking for a starting point or more instructions? Head over to{" "} + Looking for a starting point or more instructions? Head over to{' '} Templates - {" "} - or the{" "} + {' '} + or the{' '} Learning - {" "} + {' '} center.