From 2bfb2c6575bf1d49ea757e7ed23a48a5baea2498 Mon Sep 17 00:00:00 2001 From: ETWen Date: Wed, 3 Jun 2026 17:09:55 +0800 Subject: [PATCH] feat: initial release v0.1.0 - KKTerm-style UI: Activity Rail, Connection Sidebar, Tabbed Workspace - Serial Port connection (System.IO.Ports) - SSH connection (SSH.NET, password/key/keyboard-interactive) - Local Shell via Windows ConPTY (PowerShell/Bash/Cmd) - VT100/ANSI terminal rendering (owner-drawn, double-buffered) - TTL script engine: send, sendln, wait, pause, timeout, if/while, messagebox - Group execution: waitall, sendlnall, sendlngroup (Barrier sync) - Run All / Run Group toolbar buttons - SFTP file browser sidebar (SSH.NET SftpClient) - PDU control via SNMP (pduconnect/pductrl commands) - Settings: Terminal prefs, Shell config, PDU monitoring - About page with changelog - AppSettings persisted to %LocalAppData%\ETTerms\ - Dark theme throughout (KKTerm purple accent) --- .gitignore | 16 + ARCHITECTURE.md | 535 ++++++++++++++ CLAUDE.md | 61 ++ ETTerms.slnx | 5 + README.md | 43 ++ docs/runbooks/troubleshooting.md | 107 +++ docs/ttl-script-reference.md | 146 ++++ src/ETTerms/App/AboutView.cs | 152 ++++ src/ETTerms/App/ActivityRail.cs | 117 ++++ src/ETTerms/App/ConnectionSidebar.cs | 663 ++++++++++++++++++ .../App/Dialogs/ConnectionEditDialog.cs | 208 ++++++ src/ETTerms/App/Dialogs/DarkDialog.cs | 65 ++ src/ETTerms/App/Dialogs/TextPromptDialog.cs | 41 ++ src/ETTerms/App/MainForm.Designer.cs | 33 + src/ETTerms/App/MainForm.cs | 81 +++ src/ETTerms/App/SettingsView.cs | 294 ++++++++ src/ETTerms/App/Theme.cs | 30 + src/ETTerms/App/Workspace/WorkspaceView.cs | 413 +++++++++++ src/ETTerms/Connections/Connection.cs | 60 ++ src/ETTerms/Connections/ConnectionStore.cs | 115 +++ src/ETTerms/Connections/CredentialVault.cs | 85 +++ src/ETTerms/ETTerms.csproj | 27 + src/ETTerms/Infrastructure/AppLogger.cs | 107 +++ src/ETTerms/Infrastructure/AppSettings.cs | 85 +++ src/ETTerms/Infrastructure/NativeTheme.cs | 27 + src/ETTerms/Program.cs | 23 + src/ETTerms/Scripting/GroupSyncContext.cs | 23 + src/ETTerms/Scripting/Pdu/PduController.cs | 73 ++ src/ETTerms/Scripting/ScriptRunner.cs | 90 +++ src/ETTerms/Scripting/TTLInterpreter.cs | 565 +++++++++++++++ src/ETTerms/Sessions/HostKeyStore.cs | 39 ++ src/ETTerms/Sessions/ISessionChannel.cs | 23 + src/ETTerms/Sessions/SerialChannel.cs | 84 +++ src/ETTerms/Sessions/SessionManager.cs | 39 ++ src/ETTerms/Sessions/SessionPage.cs | 191 +++++ src/ETTerms/Sessions/ShellChannel.cs | 185 +++++ src/ETTerms/Sessions/SshChannel.cs | 133 ++++ src/ETTerms/Terminal/AnsiParser.cs | 217 ++++++ src/ETTerms/Terminal/ScreenBuffer.cs | 263 +++++++ src/ETTerms/Terminal/TerminalInput.cs | 44 ++ src/ETTerms/Terminal/TerminalProfile.cs | 13 + src/ETTerms/Terminal/TerminalView.cs | 242 +++++++ tools/scripts/test-echo.ttl | 22 + tools/scripts/test-group.ttl | 17 + tools/scripts/test-loop.ttl | 17 + tools/scripts/test-ssh.ttl | 19 + 46 files changed, 5838 insertions(+) create mode 100644 .gitignore create mode 100644 ARCHITECTURE.md create mode 100644 CLAUDE.md create mode 100644 ETTerms.slnx create mode 100644 README.md create mode 100644 docs/runbooks/troubleshooting.md create mode 100644 docs/ttl-script-reference.md create mode 100644 src/ETTerms/App/AboutView.cs create mode 100644 src/ETTerms/App/ActivityRail.cs create mode 100644 src/ETTerms/App/ConnectionSidebar.cs create mode 100644 src/ETTerms/App/Dialogs/ConnectionEditDialog.cs create mode 100644 src/ETTerms/App/Dialogs/DarkDialog.cs create mode 100644 src/ETTerms/App/Dialogs/TextPromptDialog.cs create mode 100644 src/ETTerms/App/MainForm.Designer.cs create mode 100644 src/ETTerms/App/MainForm.cs create mode 100644 src/ETTerms/App/SettingsView.cs create mode 100644 src/ETTerms/App/Theme.cs create mode 100644 src/ETTerms/App/Workspace/WorkspaceView.cs create mode 100644 src/ETTerms/Connections/Connection.cs create mode 100644 src/ETTerms/Connections/ConnectionStore.cs create mode 100644 src/ETTerms/Connections/CredentialVault.cs create mode 100644 src/ETTerms/ETTerms.csproj create mode 100644 src/ETTerms/Infrastructure/AppLogger.cs create mode 100644 src/ETTerms/Infrastructure/AppSettings.cs create mode 100644 src/ETTerms/Infrastructure/NativeTheme.cs create mode 100644 src/ETTerms/Program.cs create mode 100644 src/ETTerms/Scripting/GroupSyncContext.cs create mode 100644 src/ETTerms/Scripting/Pdu/PduController.cs create mode 100644 src/ETTerms/Scripting/ScriptRunner.cs create mode 100644 src/ETTerms/Scripting/TTLInterpreter.cs create mode 100644 src/ETTerms/Sessions/HostKeyStore.cs create mode 100644 src/ETTerms/Sessions/ISessionChannel.cs create mode 100644 src/ETTerms/Sessions/SerialChannel.cs create mode 100644 src/ETTerms/Sessions/SessionManager.cs create mode 100644 src/ETTerms/Sessions/SessionPage.cs create mode 100644 src/ETTerms/Sessions/ShellChannel.cs create mode 100644 src/ETTerms/Sessions/SshChannel.cs create mode 100644 src/ETTerms/Terminal/AnsiParser.cs create mode 100644 src/ETTerms/Terminal/ScreenBuffer.cs create mode 100644 src/ETTerms/Terminal/TerminalInput.cs create mode 100644 src/ETTerms/Terminal/TerminalProfile.cs create mode 100644 src/ETTerms/Terminal/TerminalView.cs create mode 100644 tools/scripts/test-echo.ttl create mode 100644 tools/scripts/test-group.ttl create mode 100644 tools/scripts/test-loop.ttl create mode 100644 tools/scripts/test-ssh.ttl diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..54d9050 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# ─── .NET / WinForms build 產物 ─────────────────────────────── +bin/ +obj/ +*.user +publish/ +[Dd]ebug/ +[Rr]elease/ +.vs/ + +# ─── 本機資料庫 / 設定(執行期產生)─────────────────────────── +*.sqlite +*.sqlite-shm +*.sqlite-wal + +# ─── AI 協作素材 / 參考專案不入 git ────────────────────────── +For_AI/ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..94c78ba --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,535 @@ +# ETTerms — Architecture + +> A native Windows terminal workspace for **SSH** and **Serial Port** connections, +> with a TeraTerm-compatible **TTL scripting engine**. +> UI inspired by **KKTerm** (activity rail + tabbed session workspace + saved-connection sidebar); +> script engine ported and extended from **MyTeraTerm** (`TTLInterpreter`). + +--- + +## Overview + +ETTerms 是一個給工程師 / 韌體 / 硬體驗證人員用的**單一視窗終端機工作台**。它把日常會用到的兩種連線——**SSH**(連伺服器 / 嵌入式 Linux)與 **Serial Port**(連 UART / console / 開發板)——收進同一個 WinForms 視窗裡,用分頁(Tab)管理多條連線,左側有可存檔的連線清單(Saved Connections)。 + +核心差異化價值是**腳本自動化**:沿用並擴充 MyTeraTerm 既有的 **TTL(Tera Term Language)直譯器**,讓使用者既有的 `.ttl` 腳本(`send` / `sendln` / `wait` / `if` / `while` / `logopen` / `pductrl`…)可以直接重用,直接驅動原生 SSH / Serial channel,做到登入自動化、批次下命令、log 收集、PDU 電源控制等。 + +開發策略採「**GUI 先行**」:先把 KKTerm 風格的視窗外殼與分頁工作區做出來(可見、可切換、可關閉),再逐步把 Serial → SSH → VT100 渲染 → 腳本引擎一層層補上。 + +**目標使用者:** 單機桌面使用者(無多人 / 無登入系統)。所有連線資料存在本機 SQLite,密碼存在 Windows Credential Manager,不上雲、不回傳。 + +--- + +## Tech Stack + +| Layer | Technology | 備註 | +|-------|-----------|------| +| UI Framework | **C# .NET 8 WinForms**(`net8.0-windows`) | 與 MyTeraTerm 同框架;本機已裝 .NET 8 Desktop Runtime + SDK 9/10(net8 targeting pack 自動還原)| +| SSH | **SSH.NET**(`Renci.SshNet`) | 原生 SSH,支援 password / key / keyboard-interactive | +| Serial | **System.IO.Ports** | 沿用 MyTeraTerm `ComPortBridge` 經驗 | +| Terminal 渲染 | **自繪 VT100 / ANSI 控制項**(owner-drawn `Control`) | 解析 ANSI escape,雙緩衝繪字格 | +| Script 引擎 | **TTLInterpreterLib**(從 MyTeraTerm 移植 + 擴充) | 改為驅動 `ISessionChannel` 而非 com0com bridge | +| 連線儲存 | **SQLite**(`Microsoft.Data.Sqlite`) | 取代 KKTerm 的 SQLite store;存連線 metadata | +| 祕密儲存 | **Windows Credential Manager**(DPAPI / CredMan) | 連線密碼、SSH key passphrase,不落地明碼 | +| PDU 控制(選用) | **SnmpSharpNet** | 沿用 MyTeraTerm PDU 控制(`pductrl` / `pduconnect`) | +| 日誌 | 自製 **AppLogger**(從 MyTeraTerm 移植) | 檔案 + Debug 雙輸出 | +| 打包 | `dotnet publish` + (選用)Inno Setup / MSIX | 單機安裝,current-user | + +> **與舊版 MyTeraTerm 的關鍵差異:** 舊版是把真正的 `ttermpro.exe`(TeraTerm)嵌進 Panel,靠 **com0com 虛擬 COM 對**攔截 serial 來跑腳本。ETTerms 改走**全原生**:SSH.NET 做 SSH、`System.IO.Ports` 做 serial、自繪 VT100 控制項做終端機畫面,**不再依賴外部 TeraTerm exe,也不再需要 com0com**。腳本引擎從「驅動 com0com bridge」改成「驅動原生 `ISessionChannel`」。 + +--- + +## Architecture Diagram + +``` +┌──────────────────────────────────────────────────────────────────────┐ +│ MainForm (WinForms Shell) │ +│ │ +│ ┌────────────┐ ┌──────────────────┐ ┌────────────────────────────┐ │ +│ │ Activity │ │ Connection │ │ Tabbed Workspace │ │ +│ │ Rail │ │ Sidebar │ │ (一個 Tab = 一條 Session) │ │ +│ │ (左側圖示) │ │ (Saved │ │ │ │ +│ │ │ │ Connections) │ │ ┌──────────────────────┐ │ │ +│ │ ▣ Terminal │ │ ▸ SSH: srv-01 │ │ │ TerminalView │ │ │ +│ │ ▣ Scripts │ │ ▸ SSH: nas │ │ │ (自繪 VT100 控制項) │ │ │ +│ │ ▣ Settings │ │ ▸ COM3 @115200 │ │ │ │ │ │ +│ │ │ │ ▸ COM7 @9600 │ │ │ bytes ↑↓ │ │ │ +│ └────────────┘ └──────────────────┘ │ └──────────┬───────────┘ │ │ +│ └─────────────┼──────────────┘ │ +└─────────────────────────────────────────────────────┼─────────────────┘ + │ + ┌────────────────────────────┼───────────────┐ + │ ISessionChannel (抽象) │ │ + │ ├─ SshChannel (SSH.NET) ─┘ │ + │ └─ SerialChannel (System.IO.Ports) │ + └──────────────┬──────────────────────────────┘ + │ Write(bytes) / DataReceived(bytes) + ┌──────────────┴──────────────────────────────┐ + │ ScriptEngine (TTLInterpreter, ported) │ + │ send / sendln / wait / if / while / │ + │ logopen / messagebox / pductrl ... │ + └──────────────┬──────────────────────────────┘ + │ (選用) + ┌──────┴───────┐ + │ SNMP PDU │ (SnmpSharpNet) + └──────────────┘ +``` + +**資料流核心抽象:** 所有連線都實作 `ISessionChannel`(`Write(byte[])` + `event DataReceived`)。`TerminalView` 與 `ScriptEngine` 都只認得這個抽象,因此 SSH 與 Serial 對上層完全一致——這是讓「同一套腳本引擎驅動兩種連線」的關鍵設計。 + +--- + +## Project Structure + +``` +ETTerms/ +│ +├── CLAUDE.md # 專案記憶 & 給 Claude 的指令 +├── README.md # 快速上手、打包說明 +├── ARCHITECTURE.md # 本文件 +├── .gitignore # 含 For_AI/ 與 build 產物規則 +├── ETTerms.sln +│ +├── docs/ +│ ├── architecture.md # (本文件副本 / 連結) +│ ├── ttl-script-reference.md # TTL 指令對照表(移植自 MyTeraTerm) +│ ├── decisions/ # ADR:為何走原生而非嵌 TeraTerm +│ └── runbooks/ # 操作手冊、常見問題(無實際密碼) +│ +├── .claude/ +│ ├── settings.json +│ └── skills/ +│ +├── tools/ +│ ├── scripts/ # 建置 / 打包腳本 +│ └── prompts/ +│ +├── For_AI/ # 🚫 gitignored — AI 協作素材 +│ ├── KKTerm-main/ # UI 參考專案(Tauri/React 工作台) +│ └── MyTeraTerm/ # Script 參考專案(舊版 WinForms) +│ +└── src/ + └── ETTerms/ # 主應用程式(WinForms) + │ + ├── Program.cs # 進入點 + ├── ETTerms.csproj # net8.0-windows, UseWindowsForms + │ + ├── App/ # ── 視窗外殼 (UI Shell) ── + │ ├── MainForm.cs # 主視窗:Rail + Sidebar + Workspace(含深色標題列 DWM) + │ ├── MainForm.Designer.cs + │ ├── Theme.cs # 全域深色配色 (KKTerm 風格) + │ ├── ActivityRail.cs # 左側圖示列 (Terminal/Scripts/Settings) + │ ├── ConnectionSidebar.cs# 仿 KKTerm 可編輯資料夾樹(搜尋/CRUD/拖曳分類) + │ ├── Dialogs/ # ── 深色對話框 ── + │ │ ├── DarkDialog.cs # 對話框基底(深色 + DWM 標題列) + │ │ ├── TextPromptDialog.cs # 單行輸入(資料夾命名 / 改名) + │ │ └── ConnectionEditDialog.cs # 連線新增/編輯(名稱+類型+詳細,Phase 2 擴充) + │ └── Workspace/ # ── 可平鋪 (tiling) 的工作區 ── + │ ├── WorkspaceView.cs # 工具列(grid 預設) + split 樹管理 + active pane 路由 + │ ├── PaneControl.cs # 單一格子:自繪迷你分頁列 + 內容 + +↔↕✕ 動作鈕 + │ └── ProportionalSplit.cs# 按比例縮放的 SplitContainer (巢狀組成任意佈局) + │ + ├── Terminal/ # ── 終端機渲染 ── + │ ├── TerminalView.cs # 自繪 VT100 控制項 (owner-drawn) + │ ├── AnsiParser.cs # ANSI/VT100 escape 解析狀態機 + │ ├── ScreenBuffer.cs # 字格緩衝 (rows×cols, 屬性/顏色) + │ └── TerminalInput.cs # 鍵盤 → byte 序列 (含特殊鍵) + │ + ├── Sessions/ # ── 連線抽象 ── + │ ├── ISessionChannel.cs # Write(byte[]) + event DataReceived + │ ├── SshChannel.cs # SSH.NET 實作 (ShellStream) + │ ├── SerialChannel.cs # System.IO.Ports 實作 + │ ├── ShellChannel.cs # Windows ConPTY 本機 Shell + │ ├── SessionPage.cs # 一個分頁 = TerminalView + Channel + 狀態 + │ └── SessionManager.cs # 開 / 關 / 列舉所有 active session + │ + ├── Connections/ # ── 連線資料 ── + │ ├── Connection.cs # 連線 metadata 模型 + │ ├── ConnectionStore.cs # SQLite CRUD + │ └── CredentialVault.cs # Windows Credential Manager 封裝 + │ + ├── Scripting/ # ── TTL 腳本引擎(移植 + 擴充)── + │ ├── TTLInterpreter.cs # 主直譯器 (port 自 MyTeraTerm) + │ ├── ScriptRunner.cs # 非同步執行 + 取消 + 進度事件 + │ ├── GroupSyncContext.cs # Group 同步 (Barrier):waitall / sendlnall / sendlngroup + │ └── Pdu/ + │ └── PduController.cs# SnmpSharpNet PDU 控制 (pductrl / pduconnect) + │ + └── Infrastructure/ + ├── AppLogger.cs # 日誌 (port 自 MyTeraTerm) + ├── AppSettings.cs # 使用者偏好 (JSON, %LocalAppData%\ETTerms\settings.json) + └── NativeTheme.cs # 深色標題列 (DWM) +``` + +> **`For_AI/` 內含兩份參考專案**:`KKTerm-main`(UI 參考,Tauri+React 的 Windows 工作台)與 `MyTeraTerm`(Script 參考,舊版嵌 TeraTerm 的 WinForms)。整個 `For_AI/` 已 gitignore,僅供開發時對照,不進 repo。 + +> **無 `secret/` 資料夾:** ETTerms 是桌面單機 App,**沒有 DB 密碼 / 連線字串 / API key 之類的伺服端祕密,也無 compile-time secret**。連線密碼一律存 Windows Credential Manager(不落地明碼),因此不需要 `secret/` 集中管理機制。 + +--- + +## Data Models + +連線 metadata 存在本機 SQLite(`%LocalAppData%\ETTerms\ettermsdb.sqlite`)。**密碼 / passphrase 不存在這裡**,只存一個指向 Windows Credential Manager 的 `CredentialKey`。 + +```csharp +public enum ConnectionType +{ + Ssh = 0, + Serial = 1 +} + +// 主連線模型 +public class Connection +{ + public Guid Id { get; set; } + public string Name { get; set; } = ""; // 顯示名稱,例:srv-01 / COM3 board + public ConnectionType Type { get; set; } + public int SortOrder { get; set; } // sidebar 排序 + public string? GroupName { get; set; } // 選用:分組 (folder) + public DateTime LastUsedUtc { get; set; } + + // SSH 專用(Type == Ssh 時有效) + public SshSettings? Ssh { get; set; } + // Serial 專用(Type == Serial 時有效) + public SerialSettings? Serial { get; set; } + + // 指向 Windows Credential Manager 的 key,例:"ETTerms/{Id}" + // 明碼密碼絕不存進 SQLite + public string? CredentialKey { get; set; } +} + +public class SshSettings +{ + public string Host { get; set; } = ""; + public int Port { get; set; } = 22; + public string Username { get; set; } = ""; + public SshAuthMethod AuthMethod { get; set; } // Password / PrivateKey / KeyboardInteractive + public string? PrivateKeyPath { get; set; } // key 檔路徑(passphrase 走 CredentialVault) +} + +public enum SshAuthMethod { Password = 0, PrivateKey = 1, KeyboardInteractive = 2 } + +public class SerialSettings +{ + public string PortName { get; set; } = "COM1"; // COM3, COM7... + public int BaudRate { get; set; } = 115200; + public int DataBits { get; set; } = 8; + public Parity Parity { get; set; } = Parity.None; // System.IO.Ports.Parity + public StopBits StopBits { get; set; } = StopBits.One; + public Handshake Handshake { get; set; } = Handshake.None; + public string NewLine { get; set; } = "\r\n"; // 送出換行序列 +} + +// 終端機偏好(存 AppSettings,非每連線) +public class TerminalProfile +{ + public string FontFamily { get; set; } = "Cascadia Mono"; + public float FontSize { get; set; } = 11f; + public int Cols { get; set; } = 80; + public int Rows { get; set; } = 24; + public string Theme { get; set; } = "dark"; // 配色名稱 + public int ScrollbackLines { get; set; } = 5000; +} +``` + +**SQLite Schema(單表即可起步):** + +| 欄位 | 型別 | 說明 | +|------|------|------| +| `Id` | TEXT (GUID) | 主鍵 | +| `Name` | TEXT | 顯示名稱 | +| `Type` | INTEGER | 0=Ssh, 1=Serial | +| `SortOrder` | INTEGER | sidebar 排序 | +| `GroupName` | TEXT NULL | 分組 | +| `LastUsedUtc` | TEXT | ISO8601 | +| `SettingsJson` | TEXT | `SshSettings` / `SerialSettings` 序列化 | +| `CredentialKey` | TEXT NULL | Credential Manager 索引鍵 | + +--- + +## Authentication & Authorization + +**不適用。** ETTerms 是單機桌面工具,沒有使用者帳號 / 登入 / 角色系統。 + +唯一相關的「認證」是**對外連線時的 SSH 認證**(password / private key / keyboard-interactive),其憑證透過 **Windows Credential Manager** 儲存與讀取,由 `CredentialVault.cs` 封裝。詳見 [Security Considerations](#security-considerations)。 + +--- + +## Key Pages / Features + +ETTerms 是單視窗多分頁,沒有「路由」,以下以**功能面板**為單位描述。 + +### Activity Rail(左側圖示列) +- 切換主檢視:**Terminal**(連線工作區)/ **Scripts**(腳本編輯與執行)/ **Settings**(偏好設定) +- 仿 KKTerm 的 ActivityRail,hover 顯示 tooltip + +### Connection Sidebar(Saved Connections,仿 KKTerm 資料夾樹) +- **使用者可自建資料夾**,把連線分類組織成任意層的目錄樹(資料夾可巢狀) +- 每個資料夾顯示**連線數量徽章**、可展開 / 收合(工具列有「全部展開 / 收合」) +- **搜尋框**:依名稱 / 主機即時過濾,命中的分支自動展開 +- 圖示區分 SSH / Serial,顯示主機或 COM port + baud +- 操作(右鍵選單 + 工具列按鈕):新增資料夾 / 新增連線 / 重新命名 / 刪除 +- **拖曳分類**:把連線或資料夾拖進別的資料夾(禁止拖進自己的子孫) +- 雙擊連線 → 在 **active pane** 開啟;「快速連線」→ 不存檔的 ad-hoc 連線 +- Phase 1 資料存記憶體;**Phase 2 換成 `ConnectionStore`(SQLite)持久化** + +### Workspace(可平鋪 tiling 的工作區) +- 工作區可分割成多個 **pane(格子)**,自由排列(仿 KKTerm grid): + - **Grid 預設**:工具列一鍵切 `1×1 / 1×2 / 2×1 / 2×2 / 2×3 / 3×3` + - **手動分割**:每個 pane 右上角 `↔`(左右分割)/ `↕`(上下分割),拖格線(`ProportionalSplit`)按比例調整大小 + - 關閉某格時,兄弟節點自動補位(collapse split);保留至少一格 +- **每個 pane 內可再開多條連線分頁**(pane 自己的迷你 tab strip) +- **active pane** 以強調色外框標示;側欄雙擊連線會開進 active pane +- 一條連線分頁 = `SessionPage`(`TerminalView` + `ISessionChannel`,Phase 3+ 接上) + +### Terminal View(自繪 VT100) +- 接收 channel bytes → `AnsiParser` → `ScreenBuffer` → 繪製 +- 鍵盤輸入 → `TerminalInput` → byte 序列 → channel +- 支援:scrollback、選取 / 複製、貼上、字型 / 配色(從 `TerminalProfile`) + +### Script Editor / Runner(Scripts 檢視) +- 載入 / 編輯 `.ttl` 腳本(語法沿用 MyTeraTerm) +- 對「目前 active session」執行腳本;顯示執行進度(檔名 / 行號 / 當前指令) +- 可取消執行(`Cancel()`);`logopen` 將輸出寫檔 +- 選用:PDU 控制指令(`pductrl` / `pduconnect`)走 SNMP + +### Group 同步執行 +- 分頁可透過**右鍵 Tab** → 設為 Group 1 / 2 / 3(或取消),cell footer 顯示 `[Group1-A]` 標籤 +- Toolbar 的 `▶ Group1` / `▶ Group2` / `▶ Group3` 按鈕對整個 Group 同時跑同一份 `.ttl` 腳本 +- Group 模式支援同步指令:`waitall`(全員 wait 到關鍵字再繼續)、`sendlnall`(全員到齊後各自 sendln)、`sendlngroup`(指定 member 才送) +- `▶ Run All` 和分頁 `▶ Script` 會拒絕含 Group 指令的腳本(彈 Warning) +- 同步機制使用 `System.Threading.Barrier`(`GroupSyncContext`),確保成員在同步點等齊 + +### Settings +- 終端機字型 / 字級 / 配色 / scrollback 行數 +- 預設換行序列、編碼 +- 視窗位置記憶 + +--- + +## Data Flow + +**SSH session 端到端:** + +``` +使用者雙擊 Sidebar 連線 + → SessionManager.Open(connection) + → CredentialVault.Get(connection.CredentialKey) 讀密碼 + → new SshChannel(SshSettings, credential) + → SSH.NET SshClient.Connect() + → ShellStream 建立 + → new SessionPage(TerminalView, channel) 加入 WorkspaceTabs + +執行期雙向資料流: + 鍵盤 → TerminalInput → bytes → SshChannel.Write() → ShellStream + ShellStream → SshChannel.DataReceived(bytes) → TerminalView + → AnsiParser → ScreenBuffer → Invalidate() → 繪製 +``` + +**腳本驅動流(與互動式共用同一 channel):** + +``` +ScriptRunner.RunAsync(scriptText, activeChannel) + → TTLInterpreter(channel) + send/sendln → channel.Write(bytes) + wait "xxx" → 監聽 channel.DataReceived,比對緩衝直到 match 或 timeout + logopen/write→ StreamWriter 寫檔 + pductrl → PduController(SNMP set)→ PDU + if/while → 依 result / 變數做流程控制 + → StatusChanged 事件 → UI 顯示「檔名 第N行 指令」 +``` + +**Serial session:** 與 SSH 相同,只是 `ISessionChannel` 換成 `SerialChannel`(`System.IO.Ports.SerialPort` 的 `DataReceived` / `Write`)。上層 `TerminalView` 與 `TTLInterpreter` 完全不需改動——這正是 `ISessionChannel` 抽象的價值。 + +--- + +## Key Constraints & Business Rules + +1. **單機、無雲:** 所有連線 metadata 存本機 SQLite,密碼存 Windows Credential Manager,不回傳任何遙測。 +2. **密碼絕不落地明碼:** SQLite 只存 `CredentialKey`,實際密碼 / passphrase 一律走 Credential Manager。 +3. **一條連線一個分頁:** 同一連線可開多個分頁(各自獨立 session),但每個分頁綁定一個 `ISessionChannel`。 +4. **腳本對 active session 執行:** TTL 腳本只作用在目前選定的分頁 channel,不會跨分頁亂送。例外:**Group 模式**下 `waitall` / `sendlnall` / `sendlngroup` 可跨同 Group 成員同步。 +5. **腳本可取消:** 長時間 `wait` / `while` 必須能被使用者中止(`isCancelled` 旗標 + `OperationCanceledException`)。 +6. **Serial port 互斥:** 一個 COM port 同時只能被一個 session 開啟;開啟前需檢查可用性。 +7. **VT 相容性以常見情境為準:** VT100 / 常見 ANSI 序列優先;冷門 escape 可後補,不阻塞 GUI 進度。 +8. **GUI 先行:** Phase 1–2 必須先讓視窗外殼 + 分頁 + 假連線可見可操作,再接真實 channel。 +9. **不依賴外部 exe:** 不嵌 TeraTerm、不需 com0com;全原生 .NET 元件。 +10. **UI 不可被 channel I/O 阻塞:** channel 讀寫在背景,UI 更新一律 `Invoke` 回 UI thread。 + +--- + +## Security Considerations + +- **密碼儲存:** 一律使用 **Windows Credential Manager**(透過 `CredentialVault.cs`)。SQLite 內只存索引 `CredentialKey`,無明碼。SSH private key passphrase 同理。 +- **SSH host key 驗證:** 首次連線顯示 host key 指紋供使用者確認(trust-on-first-use),記錄已信任的指紋,之後比對;指紋不符要警告。 +- **私鑰檔保護:** private key 路徑存設定,但不複製 key 內容進 repo / SQLite。 +- **輸入處理:** 終端機輸入直接透傳給遠端,不做 shell 注入解讀(本來就是終端機);但 UI 載入腳本檔時要防路徑穿越 / 過大檔。 +- **日誌不含密碼:** `AppLogger` 與 `logopen` 輸出不可寫入密碼 / passphrase;連線資訊只記主機 / port,不記 credential。 +- **無 `secret/` 資料夾:** ETTerms 無伺服端祕密 / DB 密碼 / compile-time secret,連線密碼一律走 Windows Credential Manager,因此不設 `secret/` 集中目錄,也不需要 publish 類腳本。若日後做 Release 程式碼簽章,簽章 `.pfx` 請放在 repo 外並以環境變數 / CI secret 傳入。 +- **設定檔權限:** SQLite 與設定檔放在 `%LocalAppData%\ETTerms\`,跟隨使用者帳號 ACL。 + +--- + +## Build & Setup Steps + +```powershell +# 0. 前置:.NET 8 SDK(或 SDK 9/10 + .NET 8 Desktop Runtime)。確認: +dotnet --list-sdks +dotnet --list-runtimes | findstr WindowsDesktop # 需有 8.0.x + +# 1. 建立方案與專案(Phase 1) +cd F:\10_AI\ETTerms +dotnet new sln -n ETTerms +dotnet new winforms -n ETTerms -o src\ETTerms -f net8.0 +dotnet sln add src\ETTerms\ETTerms.csproj + +# 2. 安裝 NuGet 套件 +cd src\ETTerms +dotnet add package SSH.NET # 原生 SSH +dotnet add package System.IO.Ports # Serial +dotnet add package Microsoft.Data.Sqlite # 連線 metadata +dotnet add package SnmpSharpNet # 選用:PDU 控制 +# Windows Credential Manager:用 CredentialManagement 套件或 P/Invoke advapi32 + +# 3. 設定 csproj(手動確認) +# net8.0-windows +# true +# enable + +# 4. 建置與執行 +cd F:\10_AI\ETTerms +dotnet build +dotnet run --project src\ETTerms\ETTerms.csproj + +# 5. 打包(後期 Phase) +dotnet publish src\ETTerms\ETTerms.csproj -c Release -r win-x64 --self-contained false +# 後續可用 Inno Setup / MSIX 做安裝程式 +``` + +--- + +## Development Phases + +> 依使用者要求「**前面先把 GUI 做出來,後面再慢慢補功能**」排序: +> Phase 1–2 先把 KKTerm 風格的視窗外殼、分頁、假連線清單做到「看得到、點得動」, +> Phase 3 起才接真實 Serial / SSH channel,最後補腳本引擎與打包。 + +### Phase 1 — 專案骨架 + UI Shell(工作量:S)✅ 已完成 +**目標:** 專案能 `dotnet run` 啟動,KKTerm 風格的主視窗外殼可見:左側 Activity Rail、連線 Sidebar、右側分頁工作區(先放假分頁)。 +**包含:** +- [x] `dotnet new winforms` 建立 `src/ETTerms`,設定 `net8.0-windows` / `UseWindowsForms` / `Nullable` +- [x] `MainForm.cs` + `MainForm.Designer.cs`:三欄佈局(Rail / Sidebar / Workspace) +- [x] `ActivityRail.cs`:Terminal / Scripts / Settings 三個圖示按鈕(hover tooltip) +- [x] `ConnectionSidebar.cs`:用假資料填 TreeView(SSH / Serial 圖示)+資料夾樹 / 搜尋 / CRUD / 拖曳分類(提前實作) +- [x] `Workspace/WorkspaceView.cs`(取代原訂 `WorkspaceTabs.cs`):可平鋪 tiling 工作區(grid 預設 + 手動分割 + pane 內迷你分頁),提前實作 Future Extension 的分割畫面 +- [x] `AppLogger.cs`:從 MyTeraTerm 移植日誌 +- [x] 深色主題:`Theme.cs` + `NativeTheme.ApplyDarkTitleBar`(DWM 深色標題列)+ 深色對話框基底 +**驗收條件:** ✅ `dotnet build` 通過(0 警告 0 錯誤);主視窗出現,可在 Rail 切檢視、Sidebar 看到假連線、可開關分頁與分割工作區。 + +### Phase 2 — 連線資料 + Sidebar 真資料(工作量:M)✅ 已完成 +**目標:** 連線清單改吃 SQLite 真資料,可新增 / 編輯 / 刪除連線,密碼存進 Credential Manager。 +**包含:** +- [x] `Connection.cs`:`Connection` / `SshSettings` / `SerialSettings` / `ConnectionType` / `SshAuthMethod` 模型 +- [x] `ConnectionStore.cs`:SQLite CRUD(`%LocalAppData%\ETTerms\ettermsdb.sqlite` 自動建表,`SettingsJson` 存 Ssh/Serial) +- [x] `CredentialVault.cs`:Windows Credential Manager 讀寫封裝(advapi32 P/Invoke,`CredWrite`/`CredRead`/`CredDelete`) +- [x] 連線編輯對話框:依類型切換 SSH(host/port/user/auth/key/password)與 Serial(COM/baud/databits/parity/stopbits/handshake)兩種表單 +- [x] Sidebar 綁定 `ConnectionStore`,支援新增 / 編輯 / 刪除 / 拖曳分組 / `SortOrder` 排序持久化(資料夾路徑存於 `GroupName`) +**驗收條件:** ✅ 新增一條 SSH 與一條 Serial 連線、重啟 App 後仍在;密碼存在 Credential Manager(SQLite 內看不到明碼)。 + +> **實作備註:** 資料夾以連線的 `GroupName`(`/`-join 路徑)持久化;含連線的資料夾會在重啟後由路徑重建,**空資料夾為 session-only**(不另設 folders 表)。密碼一律經 `CredentialVault` 寫入 Credential Manager,SQLite 僅存 `CredentialKey`(`ETTerms/{Id}`)。 + +### Phase 3 — Serial Port Session(工作量:M)✅ 已完成 +**目標:** 雙擊 Serial 連線可開分頁、收發資料;先用最陽春的文字框驗證資料流。 +**包含:** +- [x] `Sessions/ISessionChannel.cs`:`Write(byte[])` + `event Action DataReceived` + `Open()` / `Close()`(: IDisposable) +- [x] `Sessions/SerialChannel.cs`:`System.IO.Ports.SerialPort` 實作(開啟前向 SessionManager 占用 COM port、互斥、錯誤釋放) +- [x] `Sessions/SessionPage.cs` + `Sessions/SessionManager.cs`:分頁內容(綁 channel)+ 全域 session 登錄 / COM 互斥 / 列舉 +- [x] 暫用 `RichTextBox`(ReadOnly,靠遠端 echo 顯示)打通「鍵盤→送出、收到→顯示」 +- [x] UI 跨執行緒安全:channel 在 handle 建立後才 Open,`DataReceived` 經 `BeginInvoke` 回 UI thread +- [x] 連線資料流改打通:`ConnectionActivated` 改傳完整 `Connection`,`WorkspaceView.BuildPage` 依類型建 `SessionPage`(Serial) 或佔位(SSH);關分頁 / 關 pane 經 `SessionPage.Dispose` 釋放 COM port +**驗收條件:** ✅ 編譯通過(0 警告 0 錯誤)。接一塊開發板 / com0com loopback,打字送得出去、回傳看得到,關分頁能正確釋放 COM port(需實機 `dotnet run` 驗證硬體收發)。 + +### Phase 4 — SSH Session(工作量:M)✅ 已完成 +**目標:** 雙擊 SSH 連線可登入遠端、互動式 shell 可用。 +**包含:** +- [x] `Sessions/SshChannel.cs`:SSH.NET `SshClient` + `ShellStream`(背景執行緒 Connect 避免凍 UI),實作 `ISessionChannel` +- [x] 三種認證:password / private key(passphrase 走 `CredentialVault`)/ keyboard-interactive +- [x] Host key 指紋 TOFU:`Sessions/HostKeyStore.cs`(`known_hosts.txt`)首次記錄 SHA256、之後比對、不符中止並警告 +- [x] 連線錯誤(逾時 / 認證失敗 / 斷線)寫入終端機輸出 + `AppLogger` +- [x] PTY resize:反射呼叫 ShellStream 底層 `_channel.SendWindowChangeRequest`(SSH.NET 未公開此 API) +**驗收條件:** ✅ 編譯通過(0/0)。用 password 與 key 兩種方式各登入一台 SSH 主機,能跑 `ls` / `top` 等互動命令(需實機驗證)。 + +> **套件:** SSH.NET 2024.2.0(含 BouncyCastle.Cryptography 2.4.0)。 + +### Phase 5 — VT100 終端機控制項(工作量:L)✅ 已完成 +**目標:** 用自繪 VT100 控制項取代陽春文字框,正確顯示顏色 / 游標 / 清屏等 ANSI 行為。 +**包含:** +- [x] `Terminal/ScreenBuffer.cs`:rows×cols 字格(`Cell{Ch,Fg,Bg,Attr}`,含 Bold/Underline/Inverse)+ scrollback + 滾動區(DECSTBM)+ alt screen +- [x] `Terminal/AnsiParser.cs`:狀態機(Ground/Esc/CSI/OSC)—游標移動、SGR(16 色 / 256 色 / truecolor)、清屏 / 清行、scroll region、insert/delete line/char、alt screen(47/1047/1049)、DECTCEM(?25)/DECAWM(?7)/DECCKM(?1)+ `Palette` +- [x] `Terminal/TerminalView.cs`:owner-drawn 雙緩衝、run 合併繪製、滾輪 scrollback、選取 / 複製(Ctrl+C、右鍵)/ 貼上(Ctrl+V、Shift+Insert、右鍵) +- [x] `Terminal/TerminalInput.cs`:鍵盤 → byte 序列(方向鍵含 appCursor、Fn、Home/End/PgUp/PgDn、Enter/Tab/Backspace/Esc) +- [x] 套用 `Terminal/TerminalProfile`(字型 / 字級 / scrollback 行數);resize 由 `OnSizeChanged` 算 cols/rows 並 `Resized` 事件通知 channel(PTY size) +**驗收條件:** ✅ 編譯通過(0/0)。在 SSH 跑 `vim` / `htop` / `top` 顏色與版面正常、視窗 resize 通知遠端(需實機驗證)。 + +### Phase 6 — TTL 腳本引擎移植 + 執行 UI(工作量:L) +**目標:** 移植 MyTeraTerm 的 `TTLInterpreter`,改為驅動 `ISessionChannel`,可對 active session 跑 `.ttl` 腳本。 +**包含:** +- [x] `TTLInterpreter.cs`:從 MyTeraTerm 移植,建構子改吃 `ISessionChannel`(取代 `ComPortBridge`) +- [x] 指令覆蓋:`send` / `sendln` / `pause` / `wait` / `timeout` / `flushrecv` / `logopen` / `logwrite` / `logclose` / `messagebox` / `if`-`elseif`-`else`-`endif` / `while`-`endwhile` / 變數指派 +- [x] `ScriptRunner.cs`:`async` 執行 + 取消(`Cancel()`)+ `StatusChanged`(檔名 / 行號 / 指令)事件 +- [x] Group 同步指令:`waitall` / `sendlnall` / `sendlngroup` + `GroupSyncContext`(Barrier 同步) +- [x] `▶ Run All`(對所有 Serial 個別跑)+ `▶ Group1/2/3`(Group 同步跑) +- [x] `▶ Script` / `▶ Run All` 拒絕含 Group 指令的腳本(彈 Warning) +- [x] `docs/ttl-script-reference.md`:指令對照表(含 Group 指令) +- [ ] SSH session 完整驗收(SSH 自動登入 + 下命令 + `logopen` 收 log) +**驗收條件:** Serial 已驗收通過。SSH 尚待實機驗證:載入一支 `.ttl`(SSH 自動登入 + 下命令 + `logopen` 收 log),對 active session 跑完並產生 log 檔,中途可按停止中止。 + +### Phase 7 — Settings + About 頁面(工作量:S)✅ 已完成 +**目標:** Activity Rail 加入 Settings / About 獨立頁面,提供偏好設定入口與版本紀錄。 +**包含:** +- [x] `ActivityRail` 新增 Settings / About 兩個 view(三圖示:▤ / ⚙ / ℹ) +- [x] `MainForm` view 切換邏輯(Terminal / Settings / About 互斥顯示) +- [x] `SettingsView.cs`:偏好設定頁面骨架(Phase 8 擴充內容) +- [x] `AboutView.cs`:左側 App 資訊 + Developer + Tech Stack 卡片,右側 Changelog timeline +- [x] Changelog 資料結構,新版本只需加一筆 `ChangelogEntry` +**驗收條件:** ✅ 點 Activity Rail 可切換三個頁面;About 頁正確顯示版本、作者、changelog。 + +### Phase 8 — PDU 控制 + Settings 擴充 + Shell + SFTP(工作量:M)✅ 已完成 +**目標:** 補上 SNMP PDU 控制、偏好設定持久化、本機 Shell(ConPTY)、SFTP 瀏覽器。 +**包含:** +- [x] `Scripting/Pdu/PduController.cs`:SnmpSharpNet 實作,接 `TTLInterpreter` 的 `pductrl` / `pduconnect` +- [x] `Infrastructure/AppSettings.cs`:終端機偏好 / Shell 設定 / 視窗位置記憶(JSON 存 `%LocalAppData%\ETTerms\`) +- [x] Settings 檢視 UI:Terminal 分頁(字型 / 配色 / scrollback / 預設換行 / Shell 設定 + 即時預覽)+ PDU 分頁(連線 / 狀態監控) +- [x] `Sessions/ShellChannel.cs`:Windows ConPTY 本機 Shell(PowerShell / Bash / Cmd),完整 PTY 支援 +- [x] Sidebar SFTP 分頁:SSH SFTP 檔案瀏覽器(連線 / 導航 / 目錄列表) +- [x] `docs/runbooks/troubleshooting.md`:常見問題(COM / SSH / TTL / PDU) +- [ ] `dotnet publish` + Inno Setup / MSIX 安裝程式(待使用者指示) +**驗收條件:** ✅ PDU 指令可用;Settings 重啟保留;Local Shell 行為正常(ConPTY);SFTP 可瀏覽遠端目錄。打包待後續。 + +--- + +## Future Extensions + +這個版本**不做、但未來可能加**: + +- ~~**SFTP 檔案瀏覽**~~(✅ 已於 Phase 8 實作:sidebar SFTP 分頁) +- **Telnet** session 類型(補一個 `TelnetChannel : ISessionChannel`) +- **RDP / VNC** 分頁(KKTerm 用 mstscax.dll;ETTerms 可後期評估) +- **tmux 自動 attach**(SSH 斷線後自動回貼,仿 KKTerm) +- ~~分割畫面 / 多 pane~~(✅ 已於 Phase 1 提前實作:`WorkspaceView` + `PaneControl` + `ProportionalSplit`) +- **拖曳 pane 重新排列**(目前可分割 / 關閉 / 調大小,但還不能把 pane 拖到別處;未來可加 drag-drop 重排) +- **佈局記憶**(記住上次的 grid / split 佈局,重啟還原) +- **全新腳本語言或內嵌 Lua / C# scripting**(目前先沿用 TTL;未來可加第二引擎) +- **連線分組 / 標籤 / 搜尋** 強化 +- **跨平台**(WinForms 綁 Windows;若要跨平台需評估 Avalonia / MAUI 重寫 UI 層,但 `Sessions` / `Scripting` 層因抽象良好可重用) +- **macOS / Linux 終端機渲染** 改用 WebView2 + xterm.js(若要與 KKTerm 視覺對齊) + +--- + +## Reference Projects(`For_AI/`) + +| 專案 | 角色 | 取用重點 | +|------|------|---------| +| `KKTerm-main` | **UI 參考** | Activity Rail + 分頁工作區 + Saved Connections sidebar 的版面與互動;SQLite 存連線、Credential Manager 存密碼的 local-first 思路 | +| `MyTeraTerm` | **Script 參考** | `lib/TTLInterpreter.cs` 的 TTL 指令實作(直接移植)、`AppLogger.cs` 日誌、PDU/SNMP 控制(`pductrl`/`pduconnect`);`ComPortBridge.cs` 的 serial 經驗 | + +> 注意:KKTerm 是 Tauri/React,**不**直接複製程式碼,只取 UI/UX 與資料架構概念;ETTerms 是純 WinForms。MyTeraTerm 的 `TTLInterpreter` 則可大段移植,主要改建構子從 `ComPortBridge` 換成 `ISessionChannel`。 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b8034bf --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,61 @@ +# CLAUDE.md — ETTerms + +> 給 Claude 的專案記憶與指令入口。詳細架構見 [ARCHITECTURE.md](ARCHITECTURE.md)。 + +## 專案簡介 + +ETTerms 是一個 **C# .NET 8 WinForms** 的原生 Windows 終端機工作台,主打 **SSH** 與 **Serial Port** 兩種連線,並沿用 / 擴充 MyTeraTerm 的 **TTL 腳本引擎**做自動化。UI 參考 KKTerm(Activity Rail + 分頁工作區 + Saved Connections sidebar)。單機、無雲、無登入系統。 + +**開發策略:GUI 先行** — 先把視窗外殼 + 分頁 + 連線清單做出來,再逐步補 Serial → SSH → VT100 → 腳本引擎 → Settings/About → PDU/Shell/SFTP。 + +**進度:** Phase 1–5 ✅、Phase 6 ✅(TTL 引擎 + Group 同步,SSH 待驗收)、Phase 7 ✅(Settings/About)、Phase 8 ✅(PDU + Shell/ConPTY + SFTP + Settings 擴充)。打包待指示。 + +## 技術棧 + +- **UI:** C# .NET 8 WinForms(`net8.0-windows`, `UseWindowsForms`, `Nullable=enable`) +- **SSH:** SSH.NET(`Renci.SshNet`)— Shell + SFTP +- **Serial:** `System.IO.Ports` +- **Local Shell:** Windows ConPTY(`CreatePseudoConsole`)— PowerShell / Bash / Cmd +- **終端機渲染:** 自繪 VT100 / ANSI 控制項(owner-drawn) +- **腳本:** `TTLInterpreter`(從 `For_AI/MyTeraTerm` 移植,改驅動 `ISessionChannel`) +- **連線儲存:** SQLite(`Microsoft.Data.Sqlite`) +- **密碼儲存:** Windows Credential Manager(不落地明碼) +- **PDU:** SnmpSharpNet(iPoMan II/III via SNMP) +- **設定持久化:** JSON → `%LocalAppData%\ETTerms\settings.json` + +## 常用指令 + +```powershell +# 建置 / 執行 +dotnet build +dotnet run --project src\ETTerms\ETTerms.csproj + +# 加套件 +dotnet add src\ETTerms package SSH.NET + +# 打包 +dotnet publish src\ETTerms\ETTerms.csproj -c Release -r win-x64 --self-contained false +``` + +## 開發慣例 + +- **命名:** PascalCase 類別 / 方法,`_camelCase` 私有欄位;檔名 = 類別名。 +- **分層:** UI(`App/`)只認 `ISessionChannel` 抽象,不直接相依 SSH.NET / SerialPort。 +- **執行緒:** channel I/O 在背景;所有 UI 更新一律 `Control.Invoke` 回 UI thread。 +- **commit:** 走 Conventional Commits(`feat:` / `fix:` / `refactor:` …)。 +- **參考專案不改:** `For_AI/KKTerm-main`、`For_AI/MyTeraTerm` 只讀對照,不在 repo 內修改。 + +## 注意事項 / 禁止事項 + +- 🚫 **密碼絕不寫進 SQLite / 程式碼 / log**,一律走 Windows Credential Manager。 +- 🚫 **不嵌 TeraTerm、不依賴 com0com** —— ETTerms 走全原生(這是與舊版 MyTeraTerm 的關鍵差異)。 +- 🚫 不要把 `For_AI/` 內容 commit 進 git。 +- ⚠️ Serial COM port 同時只能被一個 session 開啟,開啟前檢查可用性。 +- ⚠️ VT 相容性以常見情境(VT100 / 常見 ANSI)為主,冷門 escape 後補,不阻塞 GUI 進度。 +- ⚠️ 本專案**無伺服端祕密 / 無 DB 密碼 / 無 EC2 / 無 VM**,因此不套用 AWS / VirtualBox 部署流程。 +- ⚠️ Group 同步指令(`waitall` / `sendlnall` / `sendlngroup`)**只能在 Run Group 模式**使用;`▶ Script` 和 `▶ Run All` 須拒絕含這些指令的腳本。 + +## 資料夾用途 + +- **`For_AI/`** — AI 協作素材與**參考專案**(`KKTerm-main` UI 參考、`MyTeraTerm` Script 參考)。整個資料夾 gitignored,僅供開發對照。 +- 本專案**無 `secret/` 資料夾**:沒有伺服端祕密 / DB 密碼 / compile-time secret,連線密碼一律走 Windows Credential Manager。 diff --git a/ETTerms.slnx b/ETTerms.slnx new file mode 100644 index 0000000..e6d6051 --- /dev/null +++ b/ETTerms.slnx @@ -0,0 +1,5 @@ + + + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..6ab7a1b --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +# ETTerms + +原生 Windows 終端機工作台(C# .NET 8 WinForms),支援 **SSH** 與 **Serial Port** 連線, +並內建從 MyTeraTerm 移植的 **TTL 腳本引擎**做自動化。單機、無雲、無登入系統。 + +詳細架構見 [ARCHITECTURE.md](ARCHITECTURE.md)。 + +## 建置 / 執行 + +```powershell +dotnet build +dotnet run --project src\ETTerms\ETTerms.csproj +``` + +## TTL 腳本 + +在任一連線分頁頂部的腳本列按 **▶ Script** 載入 `.ttl` 對該連線執行;左側狀態列即時顯示 +執行到的行號與指令,**■ Stop** 可中止。完整語法與範例見 +[docs/ttl-script-reference.md](docs/ttl-script-reference.md),範例腳本在 `tools/scripts/`。 + +### 目前支援的指令 + +| 指令 | 說明 | +|------|------| +| `send '文字'` | 送出文字(不加換行) | +| `sendln '文字'` | 送出文字並附加 `\r\n` | +| `wait '字串'` | 一直等到接收緩衝出現該字串才往下(預設無限等待,可按 Stop 取消);命中後 `result=1` | +| `flushrecv` | 清空接收緩衝區 | +| `pause 秒數` | 暫停 N 秒(可被 Stop 中止) | +| `timeout = 秒數` | 設定 `wait` 逾時秒數;`0`=無限等待,`N>0` 超時會中止腳本並報錯 | +| `if … then` / `elseif … then` / `else` / `endif` | 條件分支(可巢狀) | +| `while …` / `endwhile` | 迴圈(可巢狀,可被 Stop 中止) | +| `名稱 = 值` | 變數指派;支援 `+ - * /` 整數運算與字串;內建變數 `result` | +| `logopen '檔名'` | 開啟 log 檔(覆寫) | +| `logwrite '文字'` | 寫一行到 log | +| `logclose` | 關閉 log(腳本結束自動關閉) | +| `messagebox '訊息'` | 跳出對話框(會顯示在最上層) | +| `; 註解` | 行內註解(`;` 之後到行尾) | +| `:label` | 標籤行(會被略過) | + +**條件運算子**(`if` / `elseif` / `while`):`>=` `<=` `>` `<` `==` `!=` `=`,或無運算子(非零為真)。 + +> `pductrl` / `pduconnect`(SNMP PDU 控制)屬 Phase 7,目前尚未支援。 diff --git a/docs/runbooks/troubleshooting.md b/docs/runbooks/troubleshooting.md new file mode 100644 index 0000000..af65cb9 --- /dev/null +++ b/docs/runbooks/troubleshooting.md @@ -0,0 +1,107 @@ +# ETTerms — Troubleshooting Runbooks + +## Serial Port Issues + +### COM port is busy / cannot open + +**Symptom:** "Access to the port 'COMx' is denied" or "The port is already in use." + +**Cause:** Another application (or another ETTerms tab) already has the port open. + +**Fix:** +1. Check if another terminal (PuTTY, TeraTerm, Device Manager) has the port open — close it. +2. In ETTerms, only one session per COM port is allowed. Close the existing tab first. +3. If the port is stuck, unplug/replug the USB-Serial adapter. + +### COM port not showing in Quick Connect + +**Symptom:** The port exists in Device Manager but ETTerms doesn't list it. + +**Fix:** +1. Close and reopen the Quick Connect dialog (it scans on open). +2. Check Device Manager → Ports (COM & LPT) for the actual COM number. +3. Some USB adapters need drivers (FTDI, CH340, CP2102). + +--- + +## SSH Issues + +### Host key fingerprint changed + +**Symptom:** "Host key mismatch" warning when connecting. + +**Cause:** The remote server was reinstalled or its SSH keys were regenerated. + +**Fix:** +1. Verify with the server admin that the key change is legitimate. +2. Delete the old fingerprint from `%LocalAppData%\ETTerms\known_hosts.json`. +3. Reconnect — ETTerms will prompt to trust the new key. + +### SSH connection timeout + +**Symptom:** Connection hangs for 30+ seconds then fails. + +**Fix:** +1. Verify the host is reachable: `ping ` from cmd. +2. Check if SSH port (default 22) is open: `Test-NetConnection -Port 22` in PowerShell. +3. Firewall / VPN may be blocking. Try from a different network. + +--- + +## Script (TTL) Issues + +### Script hangs on `wait` + +**Symptom:** Script shows "wait 'xxx'" indefinitely. + +**Cause:** The expected keyword never appears in the output stream. + +**Fix:** +1. Press **■ Stop** to cancel the script. +2. Check that the wait keyword matches exactly (case-sensitive, including spaces). +3. Use `timeout = 10` at the top of your script to auto-fail after 10 seconds instead of waiting forever. +4. Use `flushrecv` before `wait` if there might be stale data in the buffer. + +### Script error: "'waitall' can only be used in Group execution mode" + +**Symptom:** Script stops immediately with this error. + +**Cause:** You used `waitall`, `sendlnall`, or `sendlngroup` commands but ran the script via **▶ Script** (per-tab) or **▶ Run All** instead of **▶ Group**. + +**Fix:** +1. Right-click the session tabs → assign them to a Group (1/2/3). +2. Use the **▶ Group1/2/3** buttons in the toolbar to run the script. + +--- + +## PDU Issues + +### pduconnect fails + +**Symptom:** `[pduconnect] failed to connect to ` + +**Fix:** +1. Ping the PDU IP from your machine. +2. Verify the PDU is an iPoMan II/III model (SNMP v1, community "private"). +3. Check that SNMP port 161/UDP is not blocked by firewall. + +### pductrl returns FAILED + +**Symptom:** `[pductrl] device X port Y ON → FAILED` + +**Fix:** +1. Ensure you called `pduconnect` first for that device number. +2. Verify the port number is valid (1–12). +3. Check PDU web interface to confirm port is not locked. + +--- + +## General + +### Settings not persisting + +**Fix:** Settings are saved to `%LocalAppData%\ETTerms\settings.json`. Check that the folder is writable. If the file is corrupted, delete it and restart ETTerms (defaults will be recreated). + +### Window position not remembered + +**Fix:** Window position saves on close. If ETTerms is killed (Task Manager), position won't be saved. Close normally via the X button. diff --git a/docs/ttl-script-reference.md b/docs/ttl-script-reference.md new file mode 100644 index 0000000..77f8e5a --- /dev/null +++ b/docs/ttl-script-reference.md @@ -0,0 +1,146 @@ +# TTL Script Reference — ETTerms + +> ETTerms 的 TTL(Tera Term Language)腳本引擎移植自 MyTeraTerm,改為驅動原生 +> `ISessionChannel`(SSH / Serial 皆可)。在 **Scripts 檢視**載入 `.ttl` 腳本,對 +> **目前 active session** 執行(先在 Terminal 檢視開好連線)。 + +執行於背景執行緒,可隨時按 **Stop** 中止;`wait` / `pause` 期間皆可取消。 + +--- + +## 語法規則 + +- 一行一個指令;前後空白會被去除。 +- 註解:`;` 之後到行尾視為註解。 +- 字串引號 `'...'` 或 `"..."` 皆可,送出時引號會被去除。 +- 變數以 `名稱 = 值` 指派;名稱須符合 `^[a-zA-Z_][a-zA-Z0-9_]*$`。 +- 內建變數 `result`:`wait` 命中為 `1`、逾時為 `0`。 +- 換行:`sendln` 自動附加 `\r\n`。 + +--- + +## 指令對照表 + +| 指令 | 語法 | 說明 | +|------|------|------| +| `send` | `send '文字'` | 送出文字(不加換行)到 active session。 | +| `sendln` | `sendln '文字'` | 送出文字並附加 `\r\n`。 | +| `wait` | `wait '字串'` | **一直等到**接收緩衝出現指定字串才往下;預設無限等待(可按 Stop 取消)。命中後 `result=1` 並清掉緩衝中該字串(含)之前的內容。 | +| `pause` | `pause 秒數` | 暫停指定秒數(可被 Stop 中止)。 | +| `timeout` | `timeout = 秒數` | 設定 `wait` 的逾時秒數。預設 `0`=無限等待;若設為 `N>0`,`wait` 超過 N 秒仍未命中會**中止整個腳本並報錯**(不會略過 `wait` 往下做)。 | +| `flushrecv` | `flushrecv` | 清空接收緩衝區。 | +| `logopen` | `logopen '檔名'` | 開啟 log 檔(覆寫);`send` 與 `logwrite` 會寫入。 | +| `logwrite` | `logwrite '文字'` | 寫一行到 log 檔。 | +| `logclose` | `logclose` | 關閉 log 檔(腳本結束時自動關閉)。 | +| `messagebox` | `messagebox '訊息'` | 跳出訊息對話框。 | +| `if` / `elseif` / `else` / `endif` | 見下 | 條件分支(可巢狀)。 | +| `while` / `endwhile` | 見下 | 迴圈(可巢狀,可被 Stop 中止)。 | +| `名稱 = 值` | `idx = 0` | 變數指派,支援 `+ - * /` 整數運算與字串。 | + +> **注意:** PDU 控制指令(`pductrl` / `pduconnect`)屬 Phase 7,本階段尚未提供。 + +--- + +## Group 同步指令 + +以下指令**只能在 Run Group 模式**下使用(toolbar 的 `▶ Group1` / `▶ Group2` / `▶ Group3`)。 +若在 `▶ Script`(分頁個別執行)或 `▶ Run All` 使用,會跳 Warning 並拒絕執行。 + +Group 內的成員依加入順序編為 **A, B, C, D...**,顯示在 cell footer(如 `[Group1-A]`)。 + +| 指令 | 語法 | 說明 | +|------|------|------| +| `waitall` | `waitall '字串'` | 各成員各自 `wait` 到指定字串出現後,再等其他成員也完成,全員到齊才繼續下一步。 | +| `sendlnall` | `sendlnall '文字'` | 等所有成員到達此行後,每人各自對自己的 channel `sendln` 同一段文字。 | +| `sendlngroup` | `sendlngroup A '文字'` | 只有指定 member(A/B/C...)會 `sendln`,其他成員跳過。用於 Group 內各設備需送不同指令的情境。 | + +### Group 範例:同步升級多台交換機 + +```ttl +; Group1 有 A=SW1, B=SW2, C=SW3 +; 每台各自等到 prompt 再同步 +waitall '#' + +; 各送不同指令 +sendlngroup A 'copy tftp://10.0.0.1/sw1.bin flash:' +sendlngroup B 'copy tftp://10.0.0.1/sw2.bin flash:' +sendlngroup C 'copy tftp://10.0.0.1/sw3.bin flash:' + +; 等所有人下載完畢 +waitall '#' + +; 全員一起 reload +sendlnall 'reload' +waitall 'confirm' +sendlnall 'y' +``` + +### Group 設定方式 + +1. 在分頁 Tab 上**右鍵** → 選擇 `Group 1` / `Group 2` / `Group 3`(或 `No Group` 取消) +2. 設定後 cell footer 會顯示 `[Group1-A]`、`[Group1-B]` 等標籤 +3. 點 toolbar 的 `▶ Group1` 載入 `.ttl` 檔,Group 內所有成員平行執行同一份腳本 + +--- + +## 條件運算子 + +`if` / `elseif` / `while` 條件支援: + +| 運算子 | 類型 | 範例 | +|--------|------|------| +| `>=` `<=` `>` `<` | 數值 | `if idx >= 3 then` | +| `==` `!=` | 字串 / 數值 | `if result == 1 then` | +| `=` | 數值或字串相等 | `if result = 0 then` | +| (無運算子) | 非零為真 | `if result then` | + +`then` 關鍵字可省略。 + +--- + +## 範例:SSH 自動登入 + 收 log + +```ttl +; 等提示字元,逐步登入並收集輸出 +timeout = 15 + +wait 'login:' +sendln 'admin' +wait 'Password:' +sendln 'secret' +wait '$' + +logopen 'session.log' +sendln 'uname -a' +wait '$' +sendln 'uptime' +wait '$' +logclose + +messagebox 'Done' +``` + +## 範例:while 迴圈下命令 + +```ttl +idx = 0 +while idx < 5 + sendln 'echo loop' + wait '$' + idx = idx + 1 +endwhile +``` + +## 範例:if / elseif / else + +```ttl +sendln 'whoami' +wait '$' +if result == 1 then + logwrite 'prompt matched' +elseif result == 0 then + logwrite 'timed out' +else + logwrite 'unknown' +endif +``` diff --git a/src/ETTerms/App/AboutView.cs b/src/ETTerms/App/AboutView.cs new file mode 100644 index 0000000..81da377 --- /dev/null +++ b/src/ETTerms/App/AboutView.cs @@ -0,0 +1,152 @@ +using System.Drawing; +using System.Reflection; +using System.Windows.Forms; + +namespace ETTerms.App; + +/// About page: app info (left) + changelog (right). +public sealed class AboutView : UserControl +{ + public AboutView() + { + Dock = DockStyle.Fill; + BackColor = Theme.WorkspaceBack; + AutoScroll = true; + + var version = Assembly.GetExecutingAssembly().GetName().Version; + var versionStr = version != null ? $"{version.Major}.{version.Minor}.{version.Build}" : "0.1.0"; + + // ── Main layout: left fixed + right scrollable ── + var left = new FlowLayoutPanel + { + Dock = DockStyle.Left, Width = 380, FlowDirection = FlowDirection.TopDown, + WrapContents = false, AutoScroll = false, Padding = new Padding(20), + BackColor = Theme.WorkspaceBack + }; + + // App card + left.Controls.Add(MakeCard(340, 140, p => + { + p.Controls.Add(MakeLine($"ETTerms", Theme.UiFontBold, Theme.Accent, ContentAlignment.MiddleCenter)); + p.Controls.Add(MakeLine($"v{versionStr}", Theme.UiFont, Theme.TextDim, ContentAlignment.MiddleCenter)); + p.Controls.Add(MakeLine("", Theme.UiFont, Theme.TextDim, ContentAlignment.MiddleCenter)); + p.Controls.Add(MakeLine("A native Windows terminal workspace", Theme.UiFont, Theme.Text, ContentAlignment.MiddleCenter)); + p.Controls.Add(MakeLine("for SSH & Serial Port connections,", Theme.UiFont, Theme.Text, ContentAlignment.MiddleCenter)); + p.Controls.Add(MakeLine("with TTL scripting engine.", Theme.UiFont, Theme.Text, ContentAlignment.MiddleCenter)); + })); + + // Author card + left.Controls.Add(MakeCard(340, 110, p => + { + p.Controls.Add(MakeLine("👤 Developer", Theme.UiFontBold, Theme.Text)); + p.Controls.Add(MakeRow("Name", "ET Wen")); + p.Controls.Add(MakeRow("Email", "eric441151893@gmail.com")); + p.Controls.Add(MakeRow("GitHub", "github.com/ETWen")); + })); + + // Tech stack card + left.Controls.Add(MakeCard(340, 120, p => + { + p.Controls.Add(MakeLine("🔧 Tech Stack", Theme.UiFontBold, Theme.Text)); + p.Controls.Add(MakeLine(" .NET 8 · WinForms · C#", Theme.UiFont, Theme.TextDim)); + p.Controls.Add(MakeLine(" SSH.NET · System.IO.Ports", Theme.UiFont, Theme.TextDim)); + p.Controls.Add(MakeLine(" SQLite · Windows Credential Manager", Theme.UiFont, Theme.TextDim)); + p.Controls.Add(MakeLine(" TTL Script Engine (ported from MyTeraTerm)", Theme.UiFont, Theme.TextDim)); + })); + + // ═══ RIGHT PANEL — Changelog (scrollable) ═══ + var right = new FlowLayoutPanel + { + Dock = DockStyle.Fill, FlowDirection = FlowDirection.TopDown, + WrapContents = false, AutoScroll = true, Padding = new Padding(10, 20, 20, 20), + BackColor = Theme.WorkspaceBack + }; + + right.Controls.Add(MakeLine("📋 Changelog", Theme.UiFontBold, Theme.Text)); + right.Controls.Add(MakeSpacer(8)); + + foreach (var entry in Changelog) + { + var header = new Label + { + AutoSize = false, Width = 500, Height = 22, + Text = $"● v{entry.Version} · {entry.Title} ({entry.Date:yyyy-MM-dd})", + ForeColor = Theme.Accent, Font = Theme.UiFontBold, + Margin = new Padding(0, 8, 0, 2) + }; + right.Controls.Add(header); + foreach (var change in entry.Changes) + { + right.Controls.Add(new Label + { + AutoSize = false, Width = 500, Height = 20, + Text = $" • {change}", + ForeColor = Theme.Text, Font = Theme.UiFont, + Margin = new Padding(0) + }); + } + } + + Controls.Add(right); // Fill first + Controls.Add(left); // Left + } + + // ── Helpers ── + + private static Panel MakeCard(int width, int height, Action build) + { + var card = new Panel + { + Width = width, Height = height, Margin = new Padding(0, 0, 0, 12), + BackColor = Theme.TabBack, Padding = new Padding(12) + }; + var flow = new FlowLayoutPanel + { + Dock = DockStyle.Fill, FlowDirection = FlowDirection.TopDown, + WrapContents = false, BackColor = Theme.TabBack, AutoSize = false + }; + build(flow); + card.Controls.Add(flow); + return card; + } + + private static Label MakeLine(string text, Font font, Color color, ContentAlignment align = ContentAlignment.MiddleLeft) + { + return new Label + { + AutoSize = false, Width = 310, Height = 20, + Text = text, Font = font, ForeColor = color, + TextAlign = align, Margin = new Padding(0) + }; + } + + private static Panel MakeRow(string label, string value) + { + var row = new Panel { Width = 310, Height = 20, Margin = new Padding(0, 2, 0, 0), BackColor = Color.Transparent }; + row.Controls.Add(new Label + { + Text = value, AutoSize = false, Width = 230, Height = 20, Dock = DockStyle.Right, + ForeColor = Theme.Text, Font = Theme.UiFont, TextAlign = ContentAlignment.MiddleRight + }); + row.Controls.Add(new Label + { + Text = label, AutoSize = false, Width = 70, Height = 20, Dock = DockStyle.Left, + ForeColor = Theme.TextDim, Font = Theme.UiFont, TextAlign = ContentAlignment.MiddleLeft + }); + return row; + } + + private static Control MakeSpacer(int height) => new Panel { Width = 10, Height = height, Margin = Padding.Empty }; + + // ── Changelog Data ── + + private static readonly ChangelogEntry[] Changelog = + [ + new("0.1.0", new DateOnly(2026, 6, 3), "Initial Release", + [ + "Beta Version: expect bugs and missing features. Feedback welcome!", + ]), + ]; + + private record ChangelogEntry(string Version, DateOnly Date, string Title, string[] Changes); +} diff --git a/src/ETTerms/App/ActivityRail.cs b/src/ETTerms/App/ActivityRail.cs new file mode 100644 index 0000000..6cd2a20 --- /dev/null +++ b/src/ETTerms/App/ActivityRail.cs @@ -0,0 +1,117 @@ +using System.Drawing; +using System.Windows.Forms; + +namespace ETTerms.App; + +/// +/// 左側圖示列(仿 KKTerm ActivityRail)。 +/// 三個檢視:Terminal / Scripts / Settings。 +/// 點擊會觸發 。 +/// +public sealed class ActivityRail : UserControl +{ + public enum RailView { Terminal, Settings, About } + + public event EventHandler? ViewSelected; + + private RailView _active = RailView.Terminal; + private RailView? _hover; + + private const int ItemSize = 56; + private static readonly (RailView view, string glyph, string tip)[] Items = + { + (RailView.Terminal, "▤", "Terminal"), + (RailView.Settings, "⚙", "Settings"), + (RailView.About, "ℹ", "About"), + }; + + private readonly ToolTip _toolTip = new(); + + public ActivityRail() + { + Width = ItemSize; + Dock = DockStyle.Left; + BackColor = Theme.RailBack; + DoubleBuffered = true; + SetStyle(ControlStyles.ResizeRedraw, true); + Cursor = Cursors.Hand; + } + + [System.ComponentModel.DesignerSerializationVisibility( + System.ComponentModel.DesignerSerializationVisibility.Hidden)] + public RailView ActiveView + { + get => _active; + set { _active = value; Invalidate(); } + } + + private int IndexAt(int y) + { + int idx = y / ItemSize; + return (idx >= 0 && idx < Items.Length) ? idx : -1; + } + + protected override void OnMouseMove(MouseEventArgs e) + { + base.OnMouseMove(e); + int idx = IndexAt(e.Y); + RailView? newHover = idx >= 0 ? Items[idx].view : null; + if (newHover != _hover) + { + _hover = newHover; + _toolTip.SetToolTip(this, idx >= 0 ? Items[idx].tip : ""); + Invalidate(); + } + } + + protected override void OnMouseLeave(EventArgs e) + { + base.OnMouseLeave(e); + _hover = null; + Invalidate(); + } + + protected override void OnMouseClick(MouseEventArgs e) + { + base.OnMouseClick(e); + int idx = IndexAt(e.Y); + if (idx < 0) return; + ActiveView = Items[idx].view; + ViewSelected?.Invoke(this, Items[idx].view); + } + + protected override void OnPaint(PaintEventArgs e) + { + var g = e.Graphics; + g.Clear(Theme.RailBack); + g.TextRenderingHint = System.Drawing.Text.TextRenderingHint.ClearTypeGridFit; + + using var glyphFont = new Font("Segoe UI Symbol", 17f); + var fmt = new StringFormat + { + Alignment = StringAlignment.Center, + LineAlignment = StringAlignment.Center + }; + + for (int i = 0; i < Items.Length; i++) + { + var rect = new Rectangle(0, i * ItemSize, ItemSize, ItemSize); + bool isActive = Items[i].view == _active; + bool isHover = _hover == Items[i].view; + + if (isHover && !isActive) + using (var b = new SolidBrush(Theme.Hover)) g.FillRectangle(b, rect); + + if (isActive) + { + using var ab = new SolidBrush(Theme.AccentDim); + g.FillRectangle(ab, rect); + using var bar = new SolidBrush(Theme.Accent); + g.FillRectangle(bar, new Rectangle(0, rect.Top, 3, ItemSize)); + } + + using var tb = new SolidBrush(isActive ? Theme.Text : Theme.TextDim); + g.DrawString(Items[i].glyph, glyphFont, tb, rect, fmt); + } + } +} diff --git a/src/ETTerms/App/ConnectionSidebar.cs b/src/ETTerms/App/ConnectionSidebar.cs new file mode 100644 index 0000000..62582a7 --- /dev/null +++ b/src/ETTerms/App/ConnectionSidebar.cs @@ -0,0 +1,663 @@ +using System.Drawing; +using System.Windows.Forms; +using ETTerms.App.Dialogs; +using ETTerms.Connections; + +namespace ETTerms.App; + +/// +/// Saved Connections 側欄(仿 KKTerm):可自建資料夾、把連線分類進去。 +/// 功能:搜尋、Quick Connect、新增資料夾 / 連線、改名、刪除、拖曳分類、展開 / 收合。 +/// Phase 2:連線資料綁 (SQLite)持久化,密碼走 。 +/// 資料夾以連線的 GroupName('/'-join 路徑)持久化;空資料夾為 session-only。 +/// +public sealed class ConnectionSidebar : UserControl +{ + /// 使用者啟用(雙擊 / Open / Quick Connect)一條連線時觸發。 + public event EventHandler? ConnectionActivated; + + // ── in-memory tree(連線節點掛 Connection;資料夾節點只用 Name)── + private enum Kind { Folder, Connection } + + private sealed class Node + { + public Kind Kind; + public string Name = ""; // 資料夾名稱 + public Connection? Conn; // 連線資料(Kind == Connection) + public bool Expanded = true; + public Node? Parent; + public readonly List Children = new(); + + public bool IsFolder => Kind == Kind.Folder; + public string DisplayName => IsFolder ? Name : (Conn?.Name ?? ""); + public string Detail => Conn?.Detail ?? ""; + public bool IsSsh => Conn?.IsSsh ?? false; + } + + private readonly Node _root = new() { Kind = Kind.Folder, Name = "root" }; + private readonly ConnectionStore _store = new(); + private int _sortSeq; + + private readonly TreeView _tree; + private readonly TextBox _search; + private TreeNode? _dragNode; + + public ConnectionSidebar() + { + Width = 250; + Dock = DockStyle.Left; + BackColor = Theme.SidebarBack; + + // ── Tab bar (Sessions / SFTP) ── + var tabBar = new FlowLayoutPanel + { + Dock = DockStyle.Top, Height = 30, BackColor = Theme.RailBack, + Padding = new Padding(4, 3, 4, 0), WrapContents = false + }; + + var sessionsPanel = new Panel { Dock = DockStyle.Fill, BackColor = Theme.SidebarBack }; + var sftpPanel = new Panel { Dock = DockStyle.Fill, BackColor = Theme.SidebarBack, Visible = false }; + + Button? activeTab = null; + Button MakeTabBtn(string text, Panel panel) + { + var b = new Button + { + Text = text, Width = 70, Height = 24, FlatStyle = FlatStyle.Flat, + ForeColor = Theme.Text, BackColor = Theme.TabBack, Font = Theme.UiFont, + Margin = new Padding(0, 0, 4, 0), Cursor = Cursors.Hand + }; + b.FlatAppearance.BorderColor = Theme.Border; + b.FlatAppearance.MouseOverBackColor = Theme.Hover; + b.Click += (_, _) => + { + sessionsPanel.Visible = panel == sessionsPanel; + sftpPanel.Visible = panel == sftpPanel; + if (activeTab != null) activeTab.BackColor = Theme.TabBack; + b.BackColor = Theme.TabActiveBack; + activeTab = b; + }; + return b; + } + var sessBtn = MakeTabBtn("📡 Sessions", sessionsPanel); + var sftpBtn = MakeTabBtn("📁 SFTP", sftpPanel); + sessBtn.BackColor = Theme.TabActiveBack; + activeTab = sessBtn; + tabBar.Controls.Add(sessBtn); + tabBar.Controls.Add(sftpBtn); + + // ── Sessions panel content ── + var header = new Panel { Dock = DockStyle.Top, Height = 34, BackColor = Theme.SidebarBack }; + var title = new Label + { + Text = "CONNECTIONS", + Dock = DockStyle.Fill, + ForeColor = Theme.Text, + Font = Theme.UiFontBold, + TextAlign = ContentAlignment.MiddleLeft, + Padding = new Padding(10, 0, 0, 0) + }; + var btnNewFolder = IconButton("🗀", "New Folder", (_, _) => NewFolder(SelectedFolder())); + var btnNewConn = IconButton("+", "New Connection", (_, _) => NewConnection(SelectedFolder())); + btnNewConn.Dock = DockStyle.Right; + btnNewFolder.Dock = DockStyle.Right; + header.Controls.Add(title); + header.Controls.Add(btnNewFolder); + header.Controls.Add(btnNewConn); + + var searchHost = new Panel { Dock = DockStyle.Top, Height = 32, BackColor = Theme.SidebarBack, Padding = new Padding(8, 2, 8, 4) }; + _search = new TextBox + { + Dock = DockStyle.Fill, + BackColor = Theme.WorkspaceBack, + ForeColor = Theme.Text, + BorderStyle = BorderStyle.FixedSingle, + Font = Theme.UiFont, + PlaceholderText = "Search hosts, folders" + }; + _search.TextChanged += (_, _) => Rebuild(); + searchHost.Controls.Add(_search); + + var quick = new Button + { + Text = "▷ Quick Connect", + Dock = DockStyle.Top, + Height = 34, + FlatStyle = FlatStyle.Flat, + ForeColor = Color.White, + BackColor = Theme.Accent, + Font = Theme.UiFontBold, + Cursor = Cursors.Hand, + Margin = new Padding(8) + }; + quick.FlatAppearance.BorderSize = 0; + quick.FlatAppearance.MouseOverBackColor = Theme.AccentDim; + quick.Click += (_, _) => QuickConnect(); + var quickHost = new Panel { Dock = DockStyle.Top, Height = 42, BackColor = Theme.SidebarBack, Padding = new Padding(8, 4, 8, 4) }; + quickHost.Controls.Add(quick); + + var shellBtn = new Button + { + Text = "🖥 Local Shell", Dock = DockStyle.Top, Height = 30, + FlatStyle = FlatStyle.Flat, ForeColor = Theme.Text, BackColor = Theme.TabBack, + Font = Theme.UiFont, Cursor = Cursors.Hand + }; + shellBtn.FlatAppearance.BorderColor = Theme.Border; + shellBtn.FlatAppearance.MouseOverBackColor = Theme.Hover; + shellBtn.Click += (_, _) => OpenLocalShell(); + var shellHost = new Panel { Dock = DockStyle.Top, Height = 36, BackColor = Theme.SidebarBack, Padding = new Padding(8, 2, 8, 4) }; + shellHost.Controls.Add(shellBtn); + + var toolbar = new Panel { Dock = DockStyle.Top, Height = 28, BackColor = Theme.SidebarBack }; + var btnExpand = IconButton("⊞", "Expand All", (_, _) => SetAllExpanded(true)); + var btnCollapse = IconButton("⊟", "Collapse All", (_, _) => SetAllExpanded(false)); + btnExpand.Dock = DockStyle.Right; + btnCollapse.Dock = DockStyle.Right; + toolbar.Controls.Add(btnExpand); + toolbar.Controls.Add(btnCollapse); + + _tree = new TreeView + { + Dock = DockStyle.Fill, + BackColor = Theme.SidebarBack, + ForeColor = Theme.Text, + BorderStyle = BorderStyle.None, + Font = Theme.UiFont, + HideSelection = false, + ShowLines = false, + ShowRootLines = true, + ShowPlusMinus = true, + FullRowSelect = true, + ItemHeight = 26, + Indent = 18, + AllowDrop = true + }; + _tree.NodeMouseDoubleClick += OnNodeDoubleClick; + _tree.AfterExpand += (_, e) => { if (e.Node?.Tag is Node n) n.Expanded = true; }; + _tree.AfterCollapse += (_, e) => { if (e.Node?.Tag is Node n) n.Expanded = false; }; + _tree.MouseDown += OnTreeMouseDown; + _tree.ItemDrag += OnItemDrag; + _tree.DragEnter += (_, e) => e.Effect = DragDropEffects.Move; + _tree.DragOver += OnDragOver; + _tree.DragDrop += OnDragDrop; + + sessionsPanel.Controls.Add(_tree); + sessionsPanel.Controls.Add(toolbar); + sessionsPanel.Controls.Add(shellHost); + sessionsPanel.Controls.Add(quickHost); + sessionsPanel.Controls.Add(searchHost); + sessionsPanel.Controls.Add(header); + + // ── SFTP panel content ── + BuildSftpPanel(sftpPanel); + + // ── Main layout ── + Controls.Add(sessionsPanel); + Controls.Add(sftpPanel); + Controls.Add(tabBar); + + LoadFromStore(); + Rebuild(); + } + + // ── SFTP panel builder ────────────────────────────────────── + private Renci.SshNet.SftpClient? _sftp; + private ListView? _sftpList; + private TextBox? _sftpPath; + + private void BuildSftpPanel(Panel panel) + { + // Connection row + var connRow = new FlowLayoutPanel + { + Dock = DockStyle.Top, Height = 30, BackColor = Theme.SidebarBack, + Padding = new Padding(4, 4, 4, 0), WrapContents = false + }; + var sshLabel = new Label { Text = "SSH:", AutoSize = true, ForeColor = Theme.TextDim, Font = Theme.UiFont, Margin = new Padding(0, 4, 4, 0) }; + var sshCombo = new ComboBox { Width = 120, DropDownStyle = ComboBoxStyle.DropDownList, BackColor = Theme.TabBack, ForeColor = Theme.Text, Font = Theme.UiFont }; + var connectBtn = new Button + { + Text = "Connect", Width = 64, Height = 24, FlatStyle = FlatStyle.Flat, + ForeColor = Theme.Text, BackColor = Theme.TabBack, Font = Theme.UiFont, Cursor = Cursors.Hand, Margin = new Padding(4, 0, 0, 0) + }; + connectBtn.FlatAppearance.BorderColor = Theme.SerialColor; + connRow.Controls.Add(sshLabel); + connRow.Controls.Add(sshCombo); + connRow.Controls.Add(connectBtn); + + // Path bar + _sftpPath = new TextBox + { + Dock = DockStyle.Top, Height = 24, Text = "/", + BackColor = Theme.TabBack, ForeColor = Theme.Accent, Font = Theme.UiFont, BorderStyle = BorderStyle.FixedSingle + }; + _sftpPath.KeyDown += (_, e) => { if (e.KeyCode == Keys.Enter && _sftp?.IsConnected == true) SftpNavigate(_sftpPath.Text); }; + + // File list + _sftpList = new ListView + { + Dock = DockStyle.Fill, View = View.Details, FullRowSelect = true, + BackColor = Theme.SidebarBack, ForeColor = Theme.Text, Font = Theme.UiFont, + BorderStyle = BorderStyle.None, HeaderStyle = ColumnHeaderStyle.Nonclickable + }; + _sftpList.Columns.Add("Name", 150); + _sftpList.Columns.Add("Size", 60, HorizontalAlignment.Right); + _sftpList.DoubleClick += (_, _) => + { + if (_sftpList.SelectedItems.Count == 0 || _sftp?.IsConnected != true) return; + var item = _sftpList.SelectedItems[0]; + if (item.Tag is string dir) SftpNavigate(dir); + }; + + panel.Controls.Add(_sftpList); + panel.Controls.Add(_sftpPath); + panel.Controls.Add(connRow); + + // Populate SSH connections on tab show + panel.VisibleChanged += (_, _) => + { + if (!panel.Visible) return; + sshCombo.Items.Clear(); + foreach (var conn in _store.GetAll().Where(c => c.IsSsh)) + sshCombo.Items.Add(conn); + sshCombo.DisplayMember = "Name"; + }; + + connectBtn.Click += (_, _) => + { + if (_sftp?.IsConnected == true) { _sftp.Disconnect(); _sftp.Dispose(); _sftp = null; connectBtn.Text = "Connect"; _sftpList!.Items.Clear(); return; } + if (sshCombo.SelectedItem is not Connections.Connection conn || conn.Ssh == null) return; + try + { + var secret = Connections.CredentialVault.Get(conn.CredentialKey); + var methods = new List(); + if (!string.IsNullOrEmpty(secret)) + methods.Add(new Renci.SshNet.PasswordAuthenticationMethod(conn.Ssh.Username, secret)); + var ci = new Renci.SshNet.ConnectionInfo(conn.Ssh.Host, conn.Ssh.Port, conn.Ssh.Username, methods.ToArray()); + _sftp = new Renci.SshNet.SftpClient(ci); + _sftp.Connect(); + connectBtn.Text = "Disconnect"; + SftpNavigate(_sftp.WorkingDirectory ?? "/"); + } + catch (Exception ex) + { + MessageBox.Show(this, $"SFTP connect failed:\n{ex.Message}", "SFTP", MessageBoxButtons.OK, MessageBoxIcon.Warning); + } + }; + } + + private void SftpNavigate(string path) + { + if (_sftp == null || _sftpList == null || _sftpPath == null) return; + try + { + var items = _sftp.ListDirectory(path).OrderByDescending(f => f.IsDirectory).ThenBy(f => f.Name).ToList(); + _sftpPath.Text = path; + _sftpList.Items.Clear(); + if (path != "/") + { + var parent = path.TrimEnd('/'); + int lastSlash = parent.LastIndexOf('/'); + string parentPath = lastSlash <= 0 ? "/" : parent[..lastSlash]; + var up = new ListViewItem(new[] { "..", "" }) { Tag = parentPath, ForeColor = Theme.Accent }; + _sftpList.Items.Add(up); + } + foreach (var f in items) + { + if (f.Name == "." || f.Name == "..") continue; + string size = f.IsDirectory ? "" : FormatSize(f.Length); + var lvi = new ListViewItem(new[] { (f.IsDirectory ? "📁 " : "📄 ") + f.Name, size }); + lvi.ForeColor = f.IsDirectory ? Theme.Accent : Theme.Text; + if (f.IsDirectory) lvi.Tag = f.FullName; + _sftpList.Items.Add(lvi); + } + } + catch (Exception ex) + { + MessageBox.Show(this, ex.Message, "SFTP", MessageBoxButtons.OK, MessageBoxIcon.Warning); + } + } + + private static string FormatSize(long bytes) => bytes switch + { + < 1024 => $"{bytes}", + < 1024 * 1024 => $"{bytes / 1024}K", + _ => $"{bytes / (1024 * 1024)}M" + }; + + // ── 從 SQLite 載入並重建資料夾樹 ───────────────────────── + private void LoadFromStore() + { + foreach (var conn in _store.GetAll()) + { + var folder = EnsureFolderPath(conn.GroupName); + AddNode(folder, new Node { Kind = Kind.Connection, Conn = conn }); + _sortSeq = Math.Max(_sortSeq, conn.SortOrder); + } + } + + private Node EnsureFolderPath(string? group) + { + var cur = _root; + if (string.IsNullOrEmpty(group)) return cur; + foreach (var part in group.Split('/', StringSplitOptions.RemoveEmptyEntries)) + { + var next = cur.Children.FirstOrDefault(c => c.IsFolder && c.Name == part) + ?? AddNode(cur, new Node { Kind = Kind.Folder, Name = part }); + cur = next; + } + return cur; + } + + private string? PathOf(Node folder) + { + var parts = new List(); + for (var n = folder; n != null && n != _root; n = n.Parent) parts.Insert(0, n.Name); + return parts.Count == 0 ? null : string.Join("/", parts); + } + + private static Node AddNode(Node parent, Node child) + { + child.Parent = parent; + parent.Children.Add(child); + return child; + } + + // ── 重建 TreeView(套用搜尋過濾、保留展開狀態)──────────── + private void Rebuild() + { + string q = _search.Text.Trim().ToLowerInvariant(); + _tree.BeginUpdate(); + _tree.Nodes.Clear(); + foreach (var child in _root.Children) + { + var tn = BuildTreeNode(child, q); + if (tn != null) _tree.Nodes.Add(tn); + } + _tree.EndUpdate(); + } + + private TreeNode? BuildTreeNode(Node node, string filter) + { + bool selfMatch = filter.Length == 0 + || node.DisplayName.ToLowerInvariant().Contains(filter) + || node.Detail.ToLowerInvariant().Contains(filter); + + if (node.IsFolder) + { + var childNodes = new List(); + foreach (var c in node.Children) + { + var ctn = BuildTreeNode(c, filter); + if (ctn != null) childNodes.Add(ctn); + } + if (!selfMatch && childNodes.Count == 0) return null; + + var tn = new TreeNode($"📁 {node.Name} ({CountConnections(node)})") + { + Tag = node, + ForeColor = Theme.TextDim + }; + tn.Nodes.AddRange(childNodes.ToArray()); + if (filter.Length > 0 || node.Expanded) tn.Expand(); + return tn; + } + + if (!selfMatch) return null; + string detail = node.Conn != null ? $" ({(node.IsSsh ? node.Conn.Ssh?.Host : node.Conn.Serial?.PortName)})" : ""; + return new TreeNode($"{(node.IsSsh ? "🖧" : "🔌")} {node.DisplayName}{detail}") + { + Tag = node, + ToolTipText = node.Detail, + ForeColor = node.IsSsh ? Theme.SshColor : Theme.SerialColor + }; + } + + private static int CountConnections(Node folder) + { + int n = 0; + foreach (var c in folder.Children) + n += c.IsFolder ? CountConnections(c) : 1; + return n; + } + + // ── 操作:新增 / 改名 / 刪除 ────────────────────────────── + private Node SelectedFolder() + { + if (_tree.SelectedNode?.Tag is Node n) + return n.IsFolder ? n : (n.Parent ?? _root); + return _root; + } + + private void NewFolder(Node parent) + { + string? name = TextPromptDialog.Ask(this, "New Folder", "Folder name", "New Folder"); + if (name == null) return; + var node = AddNode(parent, new Node { Kind = Kind.Folder, Name = name }); + parent.Expanded = true; + Rebuild(); + SelectModelNode(node); + } + + private void NewConnection(Node parent) + { + using var d = new ConnectionEditDialog("New Connection"); + if (d.ShowDialog(this) != DialogResult.OK) return; + var conn = d.Result; + conn.GroupName = PathOf(parent); + conn.SortOrder = ++_sortSeq; + _store.Upsert(conn); + if (d.Password.Length > 0) CredentialVault.Set(conn.CredentialKey, d.Password); + var node = AddNode(parent, new Node { Kind = Kind.Connection, Conn = conn }); + parent.Expanded = true; + Rebuild(); + SelectModelNode(node); + } + + private void RenameNode(Node node) + { + if (node.IsFolder) + { + string? name = TextPromptDialog.Ask(this, "Rename Folder", "Folder name", node.Name); + if (name == null) return; + node.Name = name; + PersistSubtreeGroups(node); // 子連線的 GroupName 路徑跟著變 + } + else + { + using var d = new ConnectionEditDialog("Edit Connection", node.Conn); + if (d.ShowDialog(this) != DialogResult.OK) return; + node.Conn = d.Result; // 保留 Id / GroupName / SortOrder + _store.Upsert(node.Conn); + if (d.Password.Length > 0) CredentialVault.Set(node.Conn.CredentialKey, d.Password); + } + Rebuild(); + SelectModelNode(node); + } + + private void DeleteNode(Node node) + { + string what = node.IsFolder ? $"folder \"{node.Name}\" and its contents" : $"connection \"{node.DisplayName}\""; + if (MessageBox.Show($"Delete {what}?", "Delete", MessageBoxButtons.OKCancel, MessageBoxIcon.Warning) + != DialogResult.OK) return; + DeleteFromStore(node); + node.Parent?.Children.Remove(node); + Rebuild(); + } + + private void DeleteFromStore(Node node) + { + if (node.IsFolder) + { + foreach (var c in node.Children) DeleteFromStore(c); + } + else if (node.Conn is { } c) + { + _store.Delete(c.Id); + CredentialVault.Delete(c.CredentialKey); + } + } + + /// 把資料夾子樹下所有連線的 GroupName 重設為現在的路徑並存檔。 + private void PersistSubtreeGroups(Node folder) + { + foreach (var c in folder.Children) + { + if (c.IsFolder) PersistSubtreeGroups(c); + else if (c.Conn is { } conn) { conn.GroupName = PathOf(folder); _store.Upsert(conn); } + } + } + + private void QuickConnect() + { + using var d = new ConnectionEditDialog("Quick Connect"); + if (d.ShowDialog(this) != DialogResult.OK) return; + ConnectionActivated?.Invoke(this, d.Result); // ad-hoc:不存入樹 / SQLite + } + + private void OpenLocalShell() + { + var s = Infrastructure.AppSettings.Instance; + var conn = new Connection + { + Name = s.ShellType, + Type = ConnectionType.Shell, + Shell = new ShellSettings { ShellType = s.ShellType, StartupDirectory = s.ShellStartupDir } + }; + ConnectionActivated?.Invoke(this, conn); + } + + private void SetAllExpanded(bool expanded) + { + void Walk(Node n) { if (n.IsFolder) { n.Expanded = expanded; foreach (var c in n.Children) Walk(c); } } + foreach (var c in _root.Children) Walk(c); + Rebuild(); + } + + // ── 事件 ───────────────────────────────────────────────── + private void Activate(Node n) + { + if (n.Conn is { } c) ConnectionActivated?.Invoke(this, c); + } + + private void OnNodeDoubleClick(object? sender, TreeNodeMouseClickEventArgs e) + { + if (e.Node?.Tag is Node { IsFolder: false } n) Activate(n); + } + + private void OnTreeMouseDown(object? sender, MouseEventArgs e) + { + var node = _tree.GetNodeAt(e.Location); + if (node != null) _tree.SelectedNode = node; + if (e.Button == MouseButtons.Right) + ShowContextMenu(node?.Tag as Node, e.Location); + } + + private void ShowContextMenu(Node? node, Point at) + { + var menu = new ContextMenuStrip { BackColor = Theme.SidebarBack, ForeColor = Theme.Text }; + if (node is { IsFolder: false } conn) + { + menu.Items.Add("Open", null, (_, _) => Activate(conn)); + menu.Items.Add(new ToolStripSeparator()); + menu.Items.Add("Rename / Edit", null, (_, _) => RenameNode(conn)); + menu.Items.Add("Delete", null, (_, _) => DeleteNode(conn)); + } + else + { + var target = node ?? _root; + menu.Items.Add("New Folder", null, (_, _) => NewFolder(target)); + menu.Items.Add("New Connection", null, (_, _) => NewConnection(target)); + if (node != null) + { + menu.Items.Add(new ToolStripSeparator()); + menu.Items.Add("Rename", null, (_, _) => RenameNode(node)); + menu.Items.Add("Delete", null, (_, _) => DeleteNode(node)); + } + } + menu.Show(_tree, at); + } + + // ── 拖曳分類(更新 GroupName 並持久化)──────────────────── + private void OnItemDrag(object? sender, ItemDragEventArgs e) + { + _dragNode = e.Item as TreeNode; + if (_dragNode != null) _tree.DoDragDrop(_dragNode, DragDropEffects.Move); + } + + private void OnDragOver(object? sender, DragEventArgs e) + { + var pt = _tree.PointToClient(new Point(e.X, e.Y)); + _tree.SelectedNode = _tree.GetNodeAt(pt); + e.Effect = DragDropEffects.Move; + } + + private void OnDragDrop(object? sender, DragEventArgs e) + { + if (_dragNode?.Tag is not Node src) return; + var pt = _tree.PointToClient(new Point(e.X, e.Y)); + var targetNode = _tree.GetNodeAt(pt)?.Tag as Node; + + Node dest = targetNode == null ? _root + : targetNode.IsFolder ? targetNode + : (targetNode.Parent ?? _root); + + if (src == dest || IsDescendant(src, dest)) return; + + src.Parent?.Children.Remove(src); + src.Parent = dest; + dest.Children.Add(src); + dest.Expanded = true; + + if (src.IsFolder) PersistSubtreeGroups(src); + else if (src.Conn is { } c) { c.GroupName = PathOf(dest); _store.Upsert(c); } + + Rebuild(); + SelectModelNode(src); + _dragNode = null; + } + + private static bool IsDescendant(Node ancestor, Node maybe) + { + for (var p = maybe.Parent; p != null; p = p.Parent) + if (p == ancestor) return true; + return false; + } + + // ── 小工具 ─────────────────────────────────────────────── + private void SelectModelNode(Node target) + { + TreeNode? Find(TreeNodeCollection nodes) + { + foreach (TreeNode tn in nodes) + { + if (ReferenceEquals(tn.Tag, target)) return tn; + var r = Find(tn.Nodes); + if (r != null) return r; + } + return null; + } + var found = Find(_tree.Nodes); + if (found != null) _tree.SelectedNode = found; + } + + private Button IconButton(string glyph, string tip, EventHandler onClick) + { + var b = new Button + { + Text = glyph, + Width = 34, + Height = 34, + FlatStyle = FlatStyle.Flat, + ForeColor = Theme.TextDim, + BackColor = Theme.SidebarBack, + Font = new Font("Segoe UI Symbol", 11f), + Cursor = Cursors.Hand + }; + b.FlatAppearance.BorderSize = 0; + b.FlatAppearance.MouseOverBackColor = Theme.Hover; + b.Click += onClick; + new ToolTip().SetToolTip(b, tip); + return b; + } +} diff --git a/src/ETTerms/App/Dialogs/ConnectionEditDialog.cs b/src/ETTerms/App/Dialogs/ConnectionEditDialog.cs new file mode 100644 index 0000000..eb8b369 --- /dev/null +++ b/src/ETTerms/App/Dialogs/ConnectionEditDialog.cs @@ -0,0 +1,208 @@ +using System.Drawing; +using System.IO.Ports; +using System.Windows.Forms; +using ETTerms.Connections; + +namespace ETTerms.App.Dialogs; + +/// +/// 連線新增 / 編輯對話框:依類型切換 SSH(host/port/user/auth/key/password) +/// 與 Serial(COM/baud/databits/parity/stopbits/handshake)表單。 +/// 輸出 (Connection)與 (空 = 不變更)。 +/// +public sealed class ConnectionEditDialog : DarkDialog +{ + private readonly Connection? _existing; + + private readonly TextBox _name; + private readonly ComboBox _type; + + // SSH(於 BuildSshPanel 指派) + private readonly Panel _sshPanel; + private TextBox _host = null!, _user = null!, _keyPath = null!, _password = null!; + private NumericUpDown _port = null!; + private ComboBox _auth = null!; + + // Serial(於 BuildSerialPanel 指派) + private readonly Panel _serialPanel; + private ComboBox _com = null!, _baud = null!, _parity = null!, _stopBits = null!, _handshake = null!; + private NumericUpDown _dataBits = null!; + + public string Password => _password.Text; + public Connection Result { get; private set; } = new(); + + public ConnectionEditDialog(string title, Connection? existing = null) + { + _existing = existing; + Text = title; + ClientSize = new Size(420, 452); + + var lblName = MakeLabel("Name"); lblName.Location = new Point(16, 12); + _name = MakeTextBox(existing?.Name ?? ""); _name.SetBounds(16, 34, 388, 26); + + var lblType = MakeLabel("Type"); lblType.Location = new Point(16, 66); + _type = MakeCombo(false); _type.Items.AddRange(new object[] { "SSH", "Serial" }); + _type.SetBounds(16, 88, 388, 26); + _type.SelectedIndexChanged += (_, _) => ToggleType(); + + _sshPanel = new Panel { Bounds = new Rectangle(12, 122, 404, 300), BackColor = Theme.SidebarBack }; + _serialPanel = new Panel { Bounds = new Rectangle(12, 122, 404, 300), BackColor = Theme.SidebarBack }; + BuildSshPanel(existing?.Ssh); + BuildSerialPanel(existing?.Serial); + + var ok = MakeButton("OK", DialogResult.OK, accent: true); ok.Location = new Point(224, 414); + var cancel = MakeButton("Cancel", DialogResult.Cancel); cancel.Location = new Point(316, 414); + + Controls.AddRange(new Control[] { lblName, _name, lblType, _type, _sshPanel, _serialPanel, ok, cancel }); + AcceptButton = ok; + CancelButton = cancel; + + _type.SelectedIndex = (existing?.Type ?? ConnectionType.Ssh) == ConnectionType.Ssh ? 0 : 1; + ToggleType(); + } + + private void ToggleType() + { + bool ssh = _type.SelectedIndex == 0; + _sshPanel.Visible = ssh; + _serialPanel.Visible = !ssh; + } + + private void BuildSshPanel(SshSettings? s) + { + var lblHost = MakeLabel("Host"); lblHost.Location = new Point(4, 4); + _host = MakeTextBox(s?.Host ?? ""); _host.SetBounds(4, 26, 250, 26); + var lblPort = MakeLabel("Port"); lblPort.Location = new Point(264, 4); + _port = MakeNumeric(1, 65535, s?.Port ?? 22); _port.SetBounds(264, 26, 136, 26); + + var lblUser = MakeLabel("Username"); lblUser.Location = new Point(4, 58); + _user = MakeTextBox(s?.Username ?? ""); _user.SetBounds(4, 80, 396, 26); + + var lblAuth = MakeLabel("Auth Method"); lblAuth.Location = new Point(4, 112); + _auth = MakeCombo(false); + _auth.Items.AddRange(new object[] { "Password", "Private Key", "Keyboard Interactive" }); + _auth.SelectedIndex = (int)(s?.AuthMethod ?? SshAuthMethod.Password); + _auth.SetBounds(4, 134, 396, 26); + + var lblKey = MakeLabel("Private Key Path (optional)"); lblKey.Location = new Point(4, 166); + _keyPath = MakeTextBox(s?.PrivateKeyPath ?? ""); _keyPath.SetBounds(4, 188, 396, 26); + + var lblPwd = MakeLabel("Password / Passphrase (blank = unchanged)"); lblPwd.Location = new Point(4, 220); + _password = MakeTextBox(""); _password.UseSystemPasswordChar = true; _password.SetBounds(4, 242, 396, 26); + + _sshPanel.Controls.AddRange(new Control[] + { + lblHost, _host, lblPort, _port, lblUser, _user, + lblAuth, _auth, lblKey, _keyPath, lblPwd, _password + }); + } + + private void BuildSerialPanel(SerialSettings? s) + { + var lblCom = MakeLabel("COM Port"); lblCom.Location = new Point(4, 4); + _com = MakeCombo(true); _com.Items.AddRange(SerialPort.GetPortNames()); + _com.Text = s?.PortName ?? (_com.Items.Count > 0 ? _com.Items[0]!.ToString()! : "COM1"); + _com.SetBounds(4, 26, 196, 26); + + var lblBaud = MakeLabel("Baud Rate"); lblBaud.Location = new Point(208, 4); + _baud = MakeCombo(true); + _baud.Items.AddRange(new object[] { 9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600 }); + _baud.Text = (s?.BaudRate ?? 115200).ToString(); + _baud.SetBounds(208, 26, 192, 26); + + var lblData = MakeLabel("Data Bits"); lblData.Location = new Point(4, 58); + _dataBits = MakeNumeric(5, 8, s?.DataBits ?? 8); _dataBits.SetBounds(4, 80, 196, 26); + + var lblParity = MakeLabel("Parity"); lblParity.Location = new Point(208, 58); + _parity = MakeEnumCombo(s?.Parity ?? Parity.None); _parity.SetBounds(208, 80, 192, 26); + + var lblStop = MakeLabel("Stop Bits"); lblStop.Location = new Point(4, 112); + _stopBits = MakeEnumCombo(s?.StopBits ?? StopBits.One); _stopBits.SetBounds(4, 134, 196, 26); + + var lblHand = MakeLabel("Handshake"); lblHand.Location = new Point(208, 112); + _handshake = MakeEnumCombo(s?.Handshake ?? Handshake.None); _handshake.SetBounds(208, 134, 192, 26); + + _serialPanel.Controls.AddRange(new Control[] + { + lblCom, _com, lblBaud, _baud, lblData, _dataBits, + lblParity, _parity, lblStop, _stopBits, lblHand, _handshake + }); + } + + private Connection BuildResult() + { + var c = new Connection + { + Id = _existing?.Id ?? Guid.NewGuid(), + Name = _name.Text.Trim(), + SortOrder = _existing?.SortOrder ?? 0, + GroupName = _existing?.GroupName, + LastUsedUtc = _existing?.LastUsedUtc ?? DateTime.UtcNow + }; + if (_type.SelectedIndex == 0) + { + c.Type = ConnectionType.Ssh; + c.Ssh = new SshSettings + { + Host = _host.Text.Trim(), + Port = (int)_port.Value, + Username = _user.Text.Trim(), + AuthMethod = (SshAuthMethod)_auth.SelectedIndex, + PrivateKeyPath = string.IsNullOrWhiteSpace(_keyPath.Text) ? null : _keyPath.Text.Trim() + }; + } + else + { + c.Type = ConnectionType.Serial; + c.Serial = new SerialSettings + { + PortName = _com.Text.Trim(), + BaudRate = int.TryParse(_baud.Text, out int b) ? b : 115200, + DataBits = (int)_dataBits.Value, + Parity = Enum.TryParse(_parity.Text, out var p) ? p : Parity.None, + StopBits = Enum.TryParse(_stopBits.Text, out var sb) ? sb : StopBits.One, + Handshake = Enum.TryParse(_handshake.Text, out var h) ? h : Handshake.None + }; + } + return c; + } + + protected override void OnFormClosing(FormClosingEventArgs e) + { + if (DialogResult == DialogResult.OK) + { + if (_name.Text.Trim().Length == 0) { e.Cancel = true; _name.Focus(); return; } + Result = BuildResult(); + } + base.OnFormClosing(e); + } + + // ── 控制項工廠 ─────────────────────────────────────────── + private ComboBox MakeCombo(bool editable) => new() + { + DropDownStyle = editable ? ComboBoxStyle.DropDown : ComboBoxStyle.DropDownList, + BackColor = Theme.WorkspaceBack, + ForeColor = Theme.Text, + FlatStyle = FlatStyle.Flat, + Font = Theme.UiFont + }; + + private ComboBox MakeEnumCombo(TEnum selected) where TEnum : struct, Enum + { + var cb = MakeCombo(false); + cb.Items.AddRange(Enum.GetNames()); + cb.SelectedItem = selected.ToString(); + return cb; + } + + private static NumericUpDown MakeNumeric(int min, int max, int value) => new() + { + Minimum = min, + Maximum = max, + Value = Math.Clamp(value, min, max), + BackColor = Theme.WorkspaceBack, + ForeColor = Theme.Text, + BorderStyle = BorderStyle.FixedSingle, + Font = Theme.UiFont + }; +} diff --git a/src/ETTerms/App/Dialogs/DarkDialog.cs b/src/ETTerms/App/Dialogs/DarkDialog.cs new file mode 100644 index 0000000..013b121 --- /dev/null +++ b/src/ETTerms/App/Dialogs/DarkDialog.cs @@ -0,0 +1,65 @@ +using System.Drawing; +using System.Windows.Forms; +using ETTerms.Infrastructure; + +namespace ETTerms.App.Dialogs; + +/// 深色對話框基底:統一配色、深色標題列、固定大小、Enter/Esc。 +public abstract class DarkDialog : Form +{ + protected DarkDialog() + { + BackColor = Theme.SidebarBack; + ForeColor = Theme.Text; + Font = Theme.UiFont; + FormBorderStyle = FormBorderStyle.FixedDialog; + StartPosition = FormStartPosition.CenterParent; + MaximizeBox = false; + MinimizeBox = false; + ShowInTaskbar = false; + } + + protected override void OnHandleCreated(EventArgs e) + { + base.OnHandleCreated(e); + NativeTheme.ApplyDarkTitleBar(this); + } + + protected Button MakeButton(string text, DialogResult result, bool accent = false) + { + var b = new Button + { + Text = text, + DialogResult = result, + Size = new Size(88, 30), + FlatStyle = FlatStyle.Flat, + ForeColor = Theme.Text, + BackColor = accent ? Theme.Accent : Theme.TabBack, + Font = Theme.UiFont, + Cursor = Cursors.Hand + }; + b.FlatAppearance.BorderColor = Theme.Border; + b.FlatAppearance.MouseOverBackColor = accent ? Theme.AccentDim : Theme.Hover; + return b; + } + + protected TextBox MakeTextBox(string initial = "") + { + return new TextBox + { + Text = initial, + BackColor = Theme.WorkspaceBack, + ForeColor = Theme.Text, + BorderStyle = BorderStyle.FixedSingle, + Font = Theme.UiFont + }; + } + + protected Label MakeLabel(string text) => new() + { + Text = text, + AutoSize = true, + ForeColor = Theme.TextDim, + Font = Theme.UiFont + }; +} diff --git a/src/ETTerms/App/Dialogs/TextPromptDialog.cs b/src/ETTerms/App/Dialogs/TextPromptDialog.cs new file mode 100644 index 0000000..02eec7c --- /dev/null +++ b/src/ETTerms/App/Dialogs/TextPromptDialog.cs @@ -0,0 +1,41 @@ +using System.Drawing; +using System.Windows.Forms; + +namespace ETTerms.App.Dialogs; + +/// 單行文字輸入對話框(資料夾命名 / 改名用)。 +public sealed class TextPromptDialog : DarkDialog +{ + private readonly TextBox _input; + + public string Value => _input.Text.Trim(); + + public TextPromptDialog(string title, string prompt, string initial = "") + { + Text = title; + ClientSize = new Size(360, 130); + + var lbl = MakeLabel(prompt); + lbl.Location = new Point(16, 16); + + _input = MakeTextBox(initial); + _input.SetBounds(16, 42, 328, 26); + _input.SelectAll(); + + var ok = MakeButton("OK", DialogResult.OK, accent: true); + ok.Location = new Point(164, 84); + var cancel = MakeButton("Cancel", DialogResult.Cancel); + cancel.Location = new Point(256, 84); + + Controls.AddRange(new Control[] { lbl, _input, ok, cancel }); + AcceptButton = ok; + CancelButton = cancel; + } + + /// 顯示對話框;回傳輸入值,取消或空白回傳 null。 + public static string? Ask(IWin32Window owner, string title, string prompt, string initial = "") + { + using var d = new TextPromptDialog(title, prompt, initial); + return d.ShowDialog(owner) == DialogResult.OK && d.Value.Length > 0 ? d.Value : null; + } +} diff --git a/src/ETTerms/App/MainForm.Designer.cs b/src/ETTerms/App/MainForm.Designer.cs new file mode 100644 index 0000000..4016bf6 --- /dev/null +++ b/src/ETTerms/App/MainForm.Designer.cs @@ -0,0 +1,33 @@ +#nullable enable +using System.Drawing; + +namespace ETTerms.App; + +partial class MainForm +{ + private System.ComponentModel.IContainer? components = null; + + protected override void Dispose(bool disposing) + { + if (disposing && components != null) + components.Dispose(); + base.Dispose(disposing); + } + + private void InitializeComponent() + { + SuspendLayout(); + + AutoScaleDimensions = new SizeF(7F, 15F); + AutoScaleMode = AutoScaleMode.Font; + ClientSize = new Size(1100, 700); + MinimumSize = new Size(720, 480); + BackColor = Theme.WorkspaceBack; + ForeColor = Theme.Text; + Font = Theme.UiFont; + Text = "ETTerms"; + StartPosition = FormStartPosition.CenterScreen; + + ResumeLayout(false); + } +} diff --git a/src/ETTerms/App/MainForm.cs b/src/ETTerms/App/MainForm.cs new file mode 100644 index 0000000..93534b1 --- /dev/null +++ b/src/ETTerms/App/MainForm.cs @@ -0,0 +1,81 @@ +using System.Windows.Forms; +using ETTerms.App.Workspace; +using ETTerms.Infrastructure; + +namespace ETTerms.App; + +/// +/// 主視窗:三欄佈局 = ActivityRail(左圖示列)+ ConnectionSidebar(連線清單) +/// + WorkspaceTabs(分頁工作區)。Phase 1 只做外殼,連線為佔位。 +/// +public partial class MainForm : Form +{ + private readonly ActivityRail _rail = new(); + private readonly ConnectionSidebar _sidebar = new(); + private readonly WorkspaceView _workspace = new(); + private readonly SettingsView _settings = new(); + private readonly AboutView _about = new(); + private readonly StatusStrip _status = new(); + private readonly ToolStripStatusLabel _statusLabel = new(); + + public MainForm() + { + InitializeComponent(); + BuildLayout(); + WireEvents(); + AppSettings.Instance.ApplyWindowPosition(this); + AppLogger.Info("MainForm initialized"); + } + + private void BuildLayout() + { + Controls.Add(_workspace); // Fill + Controls.Add(_settings); // Fill (hidden) + Controls.Add(_about); // Fill (hidden) + Controls.Add(_sidebar); // Left (內側) + Controls.Add(_rail); // Left (最外側) + + _settings.Visible = false; + _about.Visible = false; + + _statusLabel.Text = "Ready"; + _statusLabel.ForeColor = Theme.TextDim; + _status.Items.Add(_statusLabel); + _status.BackColor = Theme.RailBack; + _status.SizingGrip = false; + Controls.Add(_status); + } + + private void WireEvents() + { + _rail.ViewSelected += (_, view) => + { + _statusLabel.Text = $"View: {view}"; + _sidebar.Visible = view == ActivityRail.RailView.Terminal; + _workspace.Visible = view == ActivityRail.RailView.Terminal; + _settings.Visible = view == ActivityRail.RailView.Settings; + _about.Visible = view == ActivityRail.RailView.About; + AppLogger.LogInfo($"View selected: {view}"); + }; + + _sidebar.ConnectionActivated += (_, conn) => + { + _workspace.OpenConnection(conn); + _statusLabel.Text = $"Opened: {conn.Name}"; + AppLogger.LogInfo($"Open session: {conn.Name} ({(conn.IsSsh ? "SSH" : "Serial")})"); + }; + } + + protected override void OnHandleCreated(EventArgs e) + { + base.OnHandleCreated(e); + NativeTheme.ApplyDarkTitleBar(this); + } + + protected override void OnFormClosed(FormClosedEventArgs e) + { + AppSettings.Instance.SaveWindowPosition(this); + AppLogger.LogApplicationClose(); + base.OnFormClosed(e); + } +} diff --git a/src/ETTerms/App/SettingsView.cs b/src/ETTerms/App/SettingsView.cs new file mode 100644 index 0000000..d0de77e --- /dev/null +++ b/src/ETTerms/App/SettingsView.cs @@ -0,0 +1,294 @@ +using System.Drawing; +using System.Windows.Forms; +using ETTerms.Infrastructure; +using ETTerms.Scripting.Pdu; + +namespace ETTerms.App; + +/// Settings page with tabs: Terminal / PDU. +public sealed class SettingsView : UserControl +{ + public SettingsView() + { + Dock = DockStyle.Fill; + BackColor = Theme.WorkspaceBack; + + // Use a custom tab strip + panel swapping instead of TabControl to avoid white borders + var tabBar = new FlowLayoutPanel + { + Dock = DockStyle.Top, Height = 32, BackColor = Theme.RailBack, + Padding = new Padding(4, 4, 4, 0), WrapContents = false + }; + + var terminalPage = BuildTerminalTab(); + var pduPage = BuildPduTab(); + terminalPage.Dock = DockStyle.Fill; + pduPage.Dock = DockStyle.Fill; + pduPage.Visible = false; + + var body = new Panel { Dock = DockStyle.Fill, BackColor = Theme.WorkspaceBack }; + body.Controls.Add(terminalPage); + body.Controls.Add(pduPage); + + Button? activeBtn = null; + Button MakeTab(string text, Panel page) + { + var b = new Button + { + Text = text, AutoSize = false, Width = 80, Height = 26, FlatStyle = FlatStyle.Flat, + ForeColor = Theme.Text, BackColor = Theme.TabBack, Font = Theme.UiFont, + Margin = new Padding(0, 0, 4, 0), Cursor = Cursors.Hand + }; + b.FlatAppearance.BorderColor = Theme.Border; + b.FlatAppearance.MouseOverBackColor = Theme.Hover; + b.Click += (_, _) => + { + terminalPage.Visible = page == terminalPage; + pduPage.Visible = page == pduPage; + if (activeBtn != null) activeBtn.BackColor = Theme.TabBack; + b.BackColor = Theme.TabActiveBack; + activeBtn = b; + }; + return b; + } + + var termBtn = MakeTab("Terminal", terminalPage); + var pduBtn = MakeTab("PDU", pduPage); + tabBar.Controls.Add(termBtn); + tabBar.Controls.Add(pduBtn); + + // Set initial active + termBtn.BackColor = Theme.TabActiveBack; + activeBtn = termBtn; + + Controls.Add(body); + Controls.Add(tabBar); + } + + // ═══ Terminal Tab ═══ + private Panel BuildTerminalTab() + { + var page = new Panel { BackColor = Theme.WorkspaceBack, Padding = new Padding(20) }; + var s = AppSettings.Instance; + + var flow = new FlowLayoutPanel + { + Dock = DockStyle.Fill, FlowDirection = FlowDirection.TopDown, + WrapContents = false, BackColor = Theme.WorkspaceBack, AutoScroll = true + }; + + var font = new ComboBox { Width = 200, DropDownStyle = ComboBoxStyle.DropDown, BackColor = Theme.TabBack, ForeColor = Theme.Text, FlatStyle = FlatStyle.Flat }; + font.Items.AddRange(new object[] { "Cascadia Mono", "Consolas", "Courier New", "Lucida Console", "JetBrains Mono" }); + font.Text = s.FontFamily; + + var fontSize = new NumericUpDown { Width = 80, Minimum = 8, Maximum = 24, DecimalPlaces = 1, Increment = 0.5m, Value = (decimal)s.FontSize, BackColor = Theme.TabBack, ForeColor = Theme.Text, BorderStyle = BorderStyle.FixedSingle }; + var scrollback = new NumericUpDown { Width = 100, Minimum = 500, Maximum = 50000, Increment = 500, Value = s.ScrollbackLines, BackColor = Theme.TabBack, ForeColor = Theme.Text, BorderStyle = BorderStyle.FixedSingle }; + + var scheme = new ComboBox { Width = 150, DropDownStyle = ComboBoxStyle.DropDownList, BackColor = Theme.TabBack, ForeColor = Theme.Text, FlatStyle = FlatStyle.Flat }; + scheme.Items.AddRange(new object[] { "Dark", "Solarized Dark", "Monokai" }); + scheme.Text = s.ColorScheme; + + var newline = new ComboBox { Width = 120, DropDownStyle = ComboBoxStyle.DropDownList, BackColor = Theme.TabBack, ForeColor = Theme.Text, FlatStyle = FlatStyle.Flat }; + newline.Items.AddRange(new object[] { "\\r\\n", "\\r", "\\n" }); + newline.Text = s.DefaultNewLine; + + flow.Controls.Add(MakeRow("Font Family", font)); + flow.Controls.Add(MakeRow("Font Size", fontSize)); + flow.Controls.Add(MakeRow("Scrollback Lines", scrollback)); + flow.Controls.Add(MakeRow("Color Scheme", scheme)); + flow.Controls.Add(MakeRow("Default Newline", newline)); + flow.Controls.Add(MakeSpacer(16)); + + // Shell settings + flow.Controls.Add(new Label { Text = "Shell Settings", AutoSize = true, ForeColor = Theme.Accent, Font = Theme.UiFontBold, Margin = new Padding(0, 0, 0, 4) }); + + var shellType = new ComboBox { Width = 150, DropDownStyle = ComboBoxStyle.DropDownList, BackColor = Theme.TabBack, ForeColor = Theme.Text, FlatStyle = FlatStyle.Flat }; + shellType.Items.AddRange(new object[] { "PowerShell", "Bash", "Cmd" }); + shellType.Text = s.ShellType; + flow.Controls.Add(MakeRow("Terminal Shell", shellType)); + + var shellDir = new TextBox { Width = 160, BackColor = Theme.TabBack, ForeColor = Theme.Text, Font = Theme.UiFont, Text = s.ShellStartupDir, BorderStyle = BorderStyle.FixedSingle }; + var browseBtn = new Button + { + Text = "📁", Width = 30, Height = 22, FlatStyle = FlatStyle.Flat, + ForeColor = Theme.Text, BackColor = Theme.TabBack, Font = Theme.UiFont, Cursor = Cursors.Hand + }; + browseBtn.FlatAppearance.BorderColor = Theme.Border; + browseBtn.Click += (_, _) => + { + using var dlg = new FolderBrowserDialog { SelectedPath = shellDir.Text }; + if (dlg.ShowDialog(this) == DialogResult.OK) shellDir.Text = dlg.SelectedPath; + }; + var dirPanel = new Panel { Width = 200, Height = 24 }; + shellDir.Dock = DockStyle.Fill; + browseBtn.Dock = DockStyle.Right; + dirPanel.Controls.Add(shellDir); + dirPanel.Controls.Add(browseBtn); + flow.Controls.Add(MakeRow("Startup Directory", dirPanel)); + flow.Controls.Add(MakeSpacer(12)); + + // ── Live Preview ── + var preview = new Panel { Width = 460, Height = 100, BackColor = Color.FromArgb(20, 20, 24), Margin = new Padding(0, 0, 0, 8) }; + var previewLabel = new Label + { + Dock = DockStyle.Fill, BackColor = Color.FromArgb(20, 20, 24), ForeColor = Color.FromArgb(200, 200, 200), + Text = "admin@switch01:~$ show version\nCisco IOS v15.2 — 設定預覽\nABCDEFGHIJKLMNOPQRSTUVWXYZ 0123456789", + TextAlign = ContentAlignment.MiddleLeft, Padding = new Padding(10, 5, 10, 5) + }; + preview.Controls.Add(previewLabel); + flow.Controls.Add(preview); + + void UpdatePreview() + { + try { previewLabel.Font = new Font(font.Text, (float)fontSize.Value); } catch { } + } + font.TextChanged += (_, _) => UpdatePreview(); + fontSize.ValueChanged += (_, _) => UpdatePreview(); + UpdatePreview(); + + flow.Controls.Add(MakeSpacer(8)); + + var save = MakeButton("Save", Theme.Accent); + save.Click += (_, _) => + { + s.FontFamily = font.Text; + s.FontSize = (float)fontSize.Value; + s.ScrollbackLines = (int)scrollback.Value; + s.ColorScheme = scheme.Text; + s.DefaultNewLine = newline.Text; + s.ShellType = shellType.Text; + s.ShellStartupDir = shellDir.Text; + s.Save(); + MessageBox.Show(this, "Settings saved.\nNew sessions will use these settings.\nExisting sessions keep their current font.", "Settings", MessageBoxButtons.OK, MessageBoxIcon.Information); + }; + flow.Controls.Add(save); + + page.Controls.Add(flow); + return page; + } + + // ═══ PDU Tab ═══ + private Panel BuildPduTab() + { + var page = new Panel { BackColor = Theme.WorkspaceBack, Padding = new Padding(20) }; + + var flow = new FlowLayoutPanel + { + Dock = DockStyle.Fill, FlowDirection = FlowDirection.TopDown, + WrapContents = false, BackColor = Theme.WorkspaceBack, AutoScroll = true + }; + + // Connection row + var ipBox = new TextBox { Width = 160, Text = "192.168.1.21", BackColor = Theme.TabBack, ForeColor = Theme.Text, Font = Theme.UiFont }; + var connectBtn = MakeButton("Connect", Theme.SerialColor); + var statusLabel = new Label { AutoSize = true, Text = "Disconnected", ForeColor = Theme.TextDim, Font = Theme.UiFont, Margin = new Padding(8, 8, 0, 0) }; + + var connRow = new FlowLayoutPanel { FlowDirection = FlowDirection.LeftToRight, Width = 500, Height = 36, WrapContents = false, Margin = new Padding(0, 0, 0, 8) }; + connRow.Controls.Add(new Label { Text = "PDU IP:", AutoSize = true, ForeColor = Theme.Text, Font = Theme.UiFont, Margin = new Padding(0, 6, 8, 0) }); + connRow.Controls.Add(ipBox); + connRow.Controls.Add(connectBtn); + connRow.Controls.Add(statusLabel); + flow.Controls.Add(connRow); + + // Port status grid + var grid = new DataGridView + { + Width = 480, Height = 310, AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill, + BackgroundColor = Theme.WorkspaceBack, ForeColor = Theme.Text, GridColor = Theme.Border, + BorderStyle = BorderStyle.None, CellBorderStyle = DataGridViewCellBorderStyle.SingleHorizontal, + DefaultCellStyle = { BackColor = Theme.TabBack, ForeColor = Theme.Text, SelectionBackColor = Theme.Hover, SelectionForeColor = Theme.Text }, + ColumnHeadersDefaultCellStyle = { BackColor = Theme.RailBack, ForeColor = Theme.Accent, Font = Theme.UiFontBold }, + ColumnHeadersBorderStyle = DataGridViewHeaderBorderStyle.Single, + EnableHeadersVisualStyles = false, RowHeadersVisible = false, + AllowUserToAddRows = false, AllowUserToDeleteRows = false, ReadOnly = true, + AllowUserToResizeRows = false, SelectionMode = DataGridViewSelectionMode.FullRowSelect, + ScrollBars = ScrollBars.None, Font = Theme.UiFont, + RowTemplate = { Height = 24 }, + Margin = new Padding(0, 8, 0, 8) + }; + grid.Columns.Add("Port", "Port"); + grid.Columns.Add("Status", "Status"); + grid.Columns.Add("Current", "Current (mA)"); + grid.Columns.Add("Power", "Power (W)"); + for (int i = 1; i <= 12; i++) + grid.Rows.Add($"Port {i}", "—", "—", "—"); + flow.Controls.Add(grid); + + // Refresh button + var refreshBtn = MakeButton("Refresh", Theme.Accent); + flow.Controls.Add(refreshBtn); + + // Logic + PduController? pdu = null; + + connectBtn.Click += (_, _) => + { + if (pdu != null) { pdu.Dispose(); pdu = null; statusLabel.Text = "Disconnected"; statusLabel.ForeColor = Theme.TextDim; connectBtn.Text = "Connect"; return; } + var ip = ipBox.Text.Trim(); + var p = new PduController(ip); + if (p.CheckConnection()) + { + pdu = p; + statusLabel.Text = $"Connected to {ip}"; + statusLabel.ForeColor = Theme.SerialColor; + connectBtn.Text = "Disconnect"; + RefreshPduGrid(pdu, grid); + } + else + { + p.Dispose(); + MessageBox.Show(this, $"Failed to connect to PDU at {ip}", "PDU", MessageBoxButtons.OK, MessageBoxIcon.Warning); + } + }; + + refreshBtn.Click += (_, _) => + { + if (pdu == null) { MessageBox.Show(this, "Connect to PDU first.", "PDU", MessageBoxButtons.OK, MessageBoxIcon.Information); return; } + RefreshPduGrid(pdu, grid); + }; + + page.Controls.Add(flow); + return page; + } + + private static void RefreshPduGrid(PduController pdu, DataGridView grid) + { + for (int i = 0; i < 12; i++) + { + int port = i + 1; + var state = pdu.GetPortState(port); + var current = pdu.GetPortCurrent(port); + var power = pdu.GetPortPowerWatts(port); + var row = grid.Rows[i]; + row.Cells["Status"].Value = state == true ? "ON" : state == false ? "OFF" : "—"; + row.Cells["Current"].Value = current.HasValue ? $"{current.Value}" : "—"; + row.Cells["Power"].Value = power.HasValue ? $"{power.Value:F1}" : "—"; + row.DefaultCellStyle.BackColor = state == true ? Color.FromArgb(40, 80, 40) : state == false ? Color.FromArgb(60, 40, 40) : Theme.TabBack; + } + } + + // ── Helpers ── + private static Panel MakeRow(string label, Control control) + { + var row = new Panel { Width = 400, Height = 34, Margin = new Padding(0, 4, 0, 4) }; + control.Dock = DockStyle.Right; + row.Controls.Add(control); + row.Controls.Add(new Label { Text = label, Dock = DockStyle.Left, Width = 160, ForeColor = Theme.Text, Font = Theme.UiFont, TextAlign = ContentAlignment.MiddleLeft }); + return row; + } + + private static Button MakeButton(string text, Color borderColor) + { + var b = new Button + { + Text = text, Width = 90, Height = 28, FlatStyle = FlatStyle.Flat, + ForeColor = Theme.Text, BackColor = Theme.TabBack, Font = Theme.UiFont, + Cursor = Cursors.Hand, Margin = new Padding(8, 0, 0, 0) + }; + b.FlatAppearance.BorderColor = borderColor; + b.FlatAppearance.MouseOverBackColor = Theme.Hover; + return b; + } + + private static Control MakeSpacer(int h) => new Panel { Width = 10, Height = h, Margin = Padding.Empty }; +} diff --git a/src/ETTerms/App/Theme.cs b/src/ETTerms/App/Theme.cs new file mode 100644 index 0000000..2d899d9 --- /dev/null +++ b/src/ETTerms/App/Theme.cs @@ -0,0 +1,30 @@ +using System.Drawing; + +namespace ETTerms.App; + +/// +/// 全域深色配色(KKTerm 風格)。Phase 1 先集中在這裡, +/// 後續 Phase 5 的 TerminalProfile / Settings 可覆寫。 +/// +public static class Theme +{ + public static readonly Color RailBack = Color.FromArgb(24, 24, 28); + public static readonly Color SidebarBack = Color.FromArgb(32, 32, 38); + public static readonly Color WorkspaceBack = Color.FromArgb(40, 40, 46); + public static readonly Color TabBack = Color.FromArgb(48, 48, 56); + public static readonly Color TabActiveBack = Color.FromArgb(40, 40, 46); + + public static readonly Color Accent = Color.FromArgb(138, 99, 210); // KKTerm 紫 + public static readonly Color AccentDim = Color.FromArgb(86, 64, 130); + + public static readonly Color Text = Color.FromArgb(222, 222, 226); + public static readonly Color TextDim = Color.FromArgb(150, 150, 158); + public static readonly Color Border = Color.FromArgb(58, 58, 66); + public static readonly Color Hover = Color.FromArgb(60, 60, 70); + + public static readonly Color SshColor = Color.FromArgb(120, 180, 255); + public static readonly Color SerialColor = Color.FromArgb(140, 210, 140); + + public static readonly Font UiFont = new("Segoe UI", 9.5f); + public static readonly Font UiFontBold = new("Segoe UI", 9.5f, FontStyle.Bold); +} diff --git a/src/ETTerms/App/Workspace/WorkspaceView.cs b/src/ETTerms/App/Workspace/WorkspaceView.cs new file mode 100644 index 0000000..f60d1cd --- /dev/null +++ b/src/ETTerms/App/Workspace/WorkspaceView.cs @@ -0,0 +1,413 @@ +using System.Drawing; +using System.Windows.Forms; +using ETTerms.Connections; +using ETTerms.Scripting; +using ETTerms.Sessions; + +namespace ETTerms.App.Workspace; + +/// +/// 工作區(MobaXterm 風格):所有連線是頂部「分頁」,預設單一視圖(1×1)。 +/// 點 Layout(1×1 / 1×2 / 2×1 / 2×2 / 2×3 / 3×3)會把已開好的分頁自動鋪進格子, +/// 每格一個 session(下方顯示標題)。無 per-pane 動作鈕,開分頁只能從側欄連線。 +/// +public sealed class WorkspaceView : UserControl +{ + private sealed class Session + { + public required string Title; + public required bool IsSsh; + public required SessionPage Page; + public Rectangle TabBounds; + public Rectangle CloseRect; + } + + private readonly FlowLayoutPanel _toolbar; + private readonly Panel _tabStrip; + private readonly Panel _body; + private readonly Label _empty; + + private readonly List _sessions = new(); + private Session? _active; + private int _rows = 1, _cols = 1; + private Session? _hoverTab; + + private const int StripH = 30; + private const int TabW = 180; + private const int CloseSz = 14; + + private static readonly (string label, int r, int c)[] Presets = + { ("1×1", 1, 1), ("1×2", 1, 2), ("2×1", 2, 1), ("2×2", 2, 2), ("2×3", 2, 3), ("3×3", 3, 3) }; + + public WorkspaceView() + { + Dock = DockStyle.Fill; + BackColor = Theme.WorkspaceBack; + + _toolbar = new FlowLayoutPanel + { + Dock = DockStyle.Top, Height = 38, BackColor = Theme.RailBack, + Padding = new Padding(8, 6, 8, 6), WrapContents = false + }; + _toolbar.Controls.Add(new Label + { + Text = "Layout", AutoSize = true, ForeColor = Theme.TextDim, + Font = Theme.UiFont, Margin = new Padding(0, 6, 8, 0) + }); + foreach (var p in Presets) _toolbar.Controls.Add(MakePresetButton(p.label, p.r, p.c)); + + // ── Run All / Run Group 按鈕 ── + _toolbar.Controls.Add(MakeActionButton("▶ Run All", 80, 16, OnRunAllSerial)); + _toolbar.Controls.Add(MakeActionButton("▶ Group1", 90, 8, (_, _) => OnRunGroup(1))); + _toolbar.Controls.Add(MakeActionButton("▶ Group2", 90, 4, (_, _) => OnRunGroup(2))); + _toolbar.Controls.Add(MakeActionButton("▶ Group3", 90, 4, (_, _) => OnRunGroup(3))); + + _tabStrip = new Panel { Dock = DockStyle.Top, Height = StripH, BackColor = Theme.RailBack }; + _tabStrip.Paint += OnStripPaint; + _tabStrip.MouseDown += OnStripMouseDown; + _tabStrip.MouseMove += OnStripMouseMove; + _tabStrip.MouseLeave += (_, _) => { _hoverTab = null; _tabStrip.Invalidate(); }; + SetDoubleBuffered(_tabStrip); + + _body = new Panel { Dock = DockStyle.Fill, BackColor = Theme.WorkspaceBack }; + _empty = new Label + { + Dock = DockStyle.Fill, + Text = "No sessions\nDouble-click a connection to open", + ForeColor = Theme.TextDim, BackColor = Theme.WorkspaceBack, + Font = Theme.UiFont, TextAlign = ContentAlignment.MiddleCenter + }; + + Controls.Add(_body); // Fill 先加 + Controls.Add(_tabStrip); // Top + Controls.Add(_toolbar); // Top(最上) + _body.Controls.Add(_empty); + } + + /// 從側欄開啟連線:新增一個分頁並設為 active。 + public void OpenConnection(Connection conn) + { + var page = BuildPage(conn); + var s = new Session { Title = conn.Name, IsSsh = conn.IsSsh, Page = page }; + page.ConnectFailed += msg => OnConnectFailed(s, msg); + _sessions.Add(s); + _active = s; + Relayout(); + } + + private void OnConnectFailed(Session s, string msg) + { + if (IsDisposed) return; + BeginInvoke(() => + { + MessageBox.Show(this, msg, "Connection Failed", MessageBoxButtons.OK, MessageBoxIcon.Warning); + CloseSession(s); + }); + } + + private static SessionPage BuildPage(Connection conn) + { + if (conn.Type == ConnectionType.Serial && conn.Serial != null) + return new SessionPage(new SerialChannel(conn.Serial), conn.Name); + if (conn.Type == ConnectionType.Shell && conn.Shell != null) + return new SessionPage(new ShellChannel(conn.Shell), conn.Name); + return new SessionPage(new SshChannel(conn), conn.Name); + } + + private void SetLayout(int r, int c) { _rows = r; _cols = c; Relayout(); } + + private void CloseSession(Session s) + { + int idx = _sessions.IndexOf(s); + s.Page.Parent = null; + _sessions.Remove(s); + s.Page.Dispose(); + if (_active == s) _active = _sessions.Count > 0 ? _sessions[Math.Min(idx, _sessions.Count - 1)] : null; + RefreshGroupLabels(); + Relayout(); + } + + // ── Group 管理 ─────────────────────────────────────────── + private void SetSessionGroup(Session s, int group) + { + s.Page.Group = group; + RefreshGroupLabels(); + Relayout(); + } + + private void RefreshGroupLabels() + { + for (int g = 1; g <= 3; g++) + { + char letter = 'A'; + foreach (var s in _sessions.Where(x => x.Page.Group == g)) + s.Page.GroupLabel = $"Group{g}-{letter++}"; + } + foreach (var s in _sessions.Where(x => x.Page.Group == 0)) + s.Page.GroupLabel = ""; + } + + // ── 依目前 Layout 把分頁鋪進格子 ───────────────────────── + private void Relayout() + { + foreach (var s in _sessions) s.Page.Parent = null; // 先卸下(保留存活) + _body.SuspendLayout(); + for (int i = _body.Controls.Count - 1; i >= 0; i--) + { + var c = _body.Controls[i]; + if (c != _empty) { _body.Controls.Remove(c); c.Dispose(); } + } + + if (_sessions.Count == 0) + { + _empty.Visible = true; + _body.ResumeLayout(); + _tabStrip.Invalidate(); + return; + } + _empty.Visible = false; + _active ??= _sessions[0]; + + int cells = _rows * _cols; + int activeIdx = _sessions.IndexOf(_active); + int start = (activeIdx / cells) * cells; // 含 active 的那一頁 + + var grid = new TableLayoutPanel + { + Dock = DockStyle.Fill, BackColor = Theme.WorkspaceBack, + ColumnCount = _cols, RowCount = _rows, Margin = Padding.Empty, Padding = Padding.Empty + }; + for (int c = 0; c < _cols; c++) grid.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100f / _cols)); + for (int r = 0; r < _rows; r++) grid.RowStyles.Add(new RowStyle(SizeType.Percent, 100f / _rows)); + + for (int i = 0; i < cells; i++) + { + int si = start + i; + Control cell = si < _sessions.Count ? MakeCell(_sessions[si], cells > 1) : new Panel { Dock = DockStyle.Fill, BackColor = Theme.WorkspaceBack }; + grid.Controls.Add(cell, i % _cols, i / _cols); + } + + _body.Controls.Add(grid); + grid.BringToFront(); + _body.ResumeLayout(); + _tabStrip.Invalidate(); + FocusActive(); + } + + private Control MakeCell(Session s, bool withLabel) + { + var cell = new Panel { Dock = DockStyle.Fill, BackColor = Theme.WorkspaceBack, Margin = Padding.Empty, Padding = new Padding(1) }; + s.Page.Dock = DockStyle.Fill; + s.Page.Visible = true; + cell.Controls.Add(s.Page); // Fill 先加 + if (withLabel) + { + string labelText = string.IsNullOrEmpty(s.Page.GroupLabel) + ? $"{(s.IsSsh ? "🖧" : "🔌")} {s.Title}" + : $"{(s.IsSsh ? "🖧" : "🔌")} {s.Title} [{s.Page.GroupLabel}]"; + var lbl = new Label + { + Dock = DockStyle.Bottom, Height = 22, + Text = labelText, + ForeColor = s == _active ? Theme.Text : Theme.TextDim, + BackColor = Theme.RailBack, Font = Theme.UiFont, + TextAlign = ContentAlignment.MiddleLeft, Padding = new Padding(8, 0, 0, 0) + }; + lbl.Click += (_, _) => { _active = s; Relayout(); }; + cell.Controls.Add(lbl); // Bottom + } + return cell; + } + + private void FocusActive() + { + if (_active?.Page is { IsDisposed: false } p && p.IsHandleCreated) + p.Focus(); + } + + // ── 頂部 Tab 列 ────────────────────────────────────────── + private void LayoutTabs() + { + int x = 0; + foreach (var s in _sessions) + { + s.TabBounds = new Rectangle(x, 0, TabW, StripH); + s.CloseRect = new Rectangle(s.TabBounds.Right - CloseSz - 6, (StripH - CloseSz) / 2, CloseSz, CloseSz); + x += TabW; + } + } + + private void OnStripMouseDown(object? sender, MouseEventArgs e) + { + LayoutTabs(); + if (e.Button == MouseButtons.Right) + { + foreach (var s in _sessions) + { + if (s.TabBounds.Contains(e.Location)) { ShowGroupMenu(s, e.Location); return; } + } + return; + } + foreach (var s in _sessions) + { + if (s.CloseRect.Contains(e.Location)) { CloseSession(s); return; } + if (s.TabBounds.Contains(e.Location)) { _active = s; Relayout(); return; } + } + } + + private void ShowGroupMenu(Session s, Point pt) + { + var menu = new ContextMenuStrip(); + menu.Items.Add("No Group", null, (_, _) => SetSessionGroup(s, 0)); + menu.Items.Add("Group 1", null, (_, _) => SetSessionGroup(s, 1)); + menu.Items.Add("Group 2", null, (_, _) => SetSessionGroup(s, 2)); + menu.Items.Add("Group 3", null, (_, _) => SetSessionGroup(s, 3)); + // Check current + int current = s.Page.Group; + ((ToolStripMenuItem)menu.Items[current]).Checked = true; + menu.Show(_tabStrip, pt); + } + + private void OnStripMouseMove(object? sender, MouseEventArgs e) + { + LayoutTabs(); + Session? ht = null; + foreach (var s in _sessions) if (s.TabBounds.Contains(e.Location)) { ht = s; break; } + if (ht != _hoverTab) { _hoverTab = ht; _tabStrip.Invalidate(); } + } + + private void OnStripPaint(object? sender, PaintEventArgs e) + { + var g = e.Graphics; + g.Clear(Theme.RailBack); + LayoutTabs(); + foreach (var s in _sessions) + { + bool active = s == _active, hover = s == _hoverTab; + using (var b = new SolidBrush(active ? Theme.TabActiveBack : hover ? Theme.Hover : Theme.TabBack)) + g.FillRectangle(b, s.TabBounds); + if (active) + using (var bar = new SolidBrush(Theme.Accent)) + g.FillRectangle(bar, new Rectangle(s.TabBounds.Left, s.TabBounds.Bottom - 2, s.TabBounds.Width, 2)); + using (var dot = new SolidBrush(s.IsSsh ? Theme.SshColor : Theme.SerialColor)) + g.FillEllipse(dot, s.TabBounds.Left + 9, StripH / 2 - 4, 8, 8); + var tr = new Rectangle(s.TabBounds.Left + 22, s.TabBounds.Top, s.TabBounds.Width - 22 - CloseSz - 10, StripH); + TextRenderer.DrawText(g, s.Title, Theme.UiFont, tr, active ? Theme.Text : Theme.TextDim, + TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis); + TextRenderer.DrawText(g, "✕", Theme.UiFont, s.CloseRect, hover ? Theme.Text : Theme.TextDim, + TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter); + } + } + + // ── Layout 按鈕 ────────────────────────────────────────── + private Button MakePresetButton(string label, int rows, int cols) + { + var b = new Button + { + Text = label, AutoSize = false, Size = new Size(44, 26), FlatStyle = FlatStyle.Flat, + ForeColor = Theme.Text, BackColor = Theme.TabBack, Font = Theme.UiFont, + Margin = new Padding(3, 0, 3, 0), Cursor = Cursors.Hand + }; + b.FlatAppearance.BorderColor = Theme.Border; + b.FlatAppearance.MouseOverBackColor = Theme.Hover; + b.Click += (_, _) => SetLayout(rows, cols); + return b; + } + + private static Button MakeActionButton(string text, int width, int leftMargin, EventHandler onClick) + { + var b = new Button + { + Text = text, AutoSize = false, Size = new Size(width, 26), FlatStyle = FlatStyle.Flat, + ForeColor = Theme.Text, BackColor = Theme.TabBack, Font = Theme.UiFont, + Margin = new Padding(leftMargin, 0, 3, 0), Cursor = Cursors.Hand + }; + b.FlatAppearance.BorderColor = Theme.SerialColor; + b.FlatAppearance.MouseOverBackColor = Theme.Hover; + b.Click += onClick; + return b; + } + + // ── Run All(個別執行,拒絕 waitall/sendlnall) ────────── + private async void OnRunAllSerial(object? sender, EventArgs e) + { + var serials = _sessions + .Where(s => !s.IsSsh && s.Page.IsSerial && !s.Page.IsScriptRunning) + .Select(s => s.Page) + .ToList(); + + if (serials.Count == 0) + { + MessageBox.Show(this, "No available Serial sessions.", "Run All", MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + + string content, name; + using (var dlg = new OpenFileDialog { Filter = "TTL scripts (*.ttl)|*.ttl|All files (*.*)|*.*" }) + { + if (dlg.ShowDialog(this) != DialogResult.OK) return; + content = File.ReadAllText(dlg.FileName); + name = Path.GetFileName(dlg.FileName); + } + + // Run All 拒絕含 waitall / sendlnall 的腳本 + if (ScriptContainsGroupCommands(content)) + { + MessageBox.Show(this, + "Script contains 'waitall' or 'sendlnall' which require Group execution.\nPlease use Run Group buttons instead.", + "Run All", MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + + await Task.WhenAll(serials.Select(sp => sp.RunScriptAsync(content, name))); + } + + // ── Run Group(支援 waitall/sendlnall 同步) ───────────── + private async void OnRunGroup(int group) + { + var members = _sessions + .Where(s => s.Page.Group == group && !s.Page.IsScriptRunning) + .Select(s => s.Page) + .ToList(); + + if (members.Count == 0) + { + MessageBox.Show(this, $"No available sessions in Group {group}.", $"Run Group{group}", MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + + string content, name; + using (var dlg = new OpenFileDialog { Filter = "TTL scripts (*.ttl)|*.ttl|All files (*.*)|*.*" }) + { + if (dlg.ShowDialog(this) != DialogResult.OK) return; + content = File.ReadAllText(dlg.FileName); + name = Path.GetFileName(dlg.FileName); + } + + var sync = new GroupSyncContext(members.Count); + await Task.WhenAll(members.Select((sp, i) => sp.RunGroupScriptAsync(content, name, sync, ((char)('A' + i)).ToString()))); + } + + private static bool ScriptContainsGroupCommands(string content) + { + foreach (var line in content.Split('\n')) + { + var t = line.Trim().ToLower(); + if (t.StartsWith("waitall") || t.StartsWith("sendlnall") || t.StartsWith("sendlngroup")) + return true; + } + return false; + } + + protected override void Dispose(bool disposing) + { + if (disposing) + foreach (var s in _sessions) { try { s.Page.Dispose(); } catch { } } + base.Dispose(disposing); + } + + private static void SetDoubleBuffered(Control c) => + typeof(Control).GetProperty("DoubleBuffered", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic) + ?.SetValue(c, true, null); +} diff --git a/src/ETTerms/Connections/Connection.cs b/src/ETTerms/Connections/Connection.cs new file mode 100644 index 0000000..c2afc99 --- /dev/null +++ b/src/ETTerms/Connections/Connection.cs @@ -0,0 +1,60 @@ +using System.IO.Ports; + +namespace ETTerms.Connections; + +public enum ConnectionType { Ssh = 0, Serial = 1, Shell = 2 } + +public enum SshAuthMethod { Password = 0, PrivateKey = 1, KeyboardInteractive = 2 } + +/// SSH 連線設定(Type == Ssh 時有效)。passphrase / password 走 CredentialVault。 +public sealed class SshSettings +{ + public string Host { get; set; } = ""; + public int Port { get; set; } = 22; + public string Username { get; set; } = ""; + public SshAuthMethod AuthMethod { get; set; } = SshAuthMethod.Password; + public string? PrivateKeyPath { get; set; } +} + +/// Serial 連線設定(Type == Serial 時有效)。 +public sealed class SerialSettings +{ + public string PortName { get; set; } = "COM1"; + public int BaudRate { get; set; } = 115200; + public int DataBits { get; set; } = 8; + public Parity Parity { get; set; } = Parity.None; + public StopBits StopBits { get; set; } = StopBits.One; + public Handshake Handshake { get; set; } = Handshake.None; + public string NewLine { get; set; } = "\r\n"; +} + +/// Local shell settings (Type == Shell). +public sealed class ShellSettings +{ + public string ShellType { get; set; } = "PowerShell"; // "PowerShell", "Bash", "Cmd" + public string StartupDirectory { get; set; } = ""; +} + +/// 連線 metadata。明碼密碼絕不存這裡,只存指向 Credential Manager 的 +public sealed class Connection +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string Name { get; set; } = ""; + public ConnectionType Type { get; set; } + public int SortOrder { get; set; } + public string? GroupName { get; set; } // 資料夾路徑('/'-join) + public DateTime LastUsedUtc { get; set; } + + public SshSettings? Ssh { get; set; } + public SerialSettings? Serial { get; set; } + public ShellSettings? Shell { get; set; } + + public string CredentialKey => $"ETTerms/{Id}"; + + public bool IsSsh => Type == ConnectionType.Ssh; + + /// sidebar 顯示用摘要。 + public string Detail => IsSsh + ? $"{Ssh?.Host}:{Ssh?.Port}" + : $"{Serial?.PortName} @ {Serial?.BaudRate}"; +} diff --git a/src/ETTerms/Connections/ConnectionStore.cs b/src/ETTerms/Connections/ConnectionStore.cs new file mode 100644 index 0000000..917a760 --- /dev/null +++ b/src/ETTerms/Connections/ConnectionStore.cs @@ -0,0 +1,115 @@ +using System.Text.Json; +using Microsoft.Data.Sqlite; +using ETTerms.Infrastructure; + +namespace ETTerms.Connections; + +/// +/// 連線 metadata 的 SQLite 持久化(單表 Connections)。 +/// 路徑:%LocalAppData%\ETTerms\ettermsdb.sqlite,首次使用自動建表。 +/// 密碼不存這裡——只存 CredentialKey,明碼走 CredentialVault。 +/// +public sealed class ConnectionStore +{ + private readonly string _connString; + + private sealed record SettingsBlob(SshSettings? Ssh, SerialSettings? Serial); + + public ConnectionStore() + { + string dir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "ETTerms"); + Directory.CreateDirectory(dir); + _connString = new SqliteConnectionStringBuilder + { + DataSource = Path.Combine(dir, "ettermsdb.sqlite") + }.ToString(); + + using var conn = Open(); + Exec(conn, """ + CREATE TABLE IF NOT EXISTS Connections ( + Id TEXT PRIMARY KEY, + Name TEXT NOT NULL, + Type INTEGER NOT NULL, + SortOrder INTEGER NOT NULL, + GroupName TEXT, + LastUsedUtc TEXT NOT NULL, + SettingsJson TEXT NOT NULL, + CredentialKey TEXT + ); + """); + AppLogger.Info("ConnectionStore ready"); + } + + public List GetAll() + { + var list = new List(); + using var conn = Open(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = + "SELECT Id,Name,Type,SortOrder,GroupName,LastUsedUtc,SettingsJson FROM Connections ORDER BY SortOrder"; + using var r = cmd.ExecuteReader(); + while (r.Read()) + { + var blob = JsonSerializer.Deserialize(r.GetString(6)); + list.Add(new Connection + { + Id = Guid.Parse(r.GetString(0)), + Name = r.GetString(1), + Type = (ConnectionType)r.GetInt32(2), + SortOrder = r.GetInt32(3), + GroupName = r.IsDBNull(4) ? null : r.GetString(4), + LastUsedUtc = DateTime.Parse(r.GetString(5)), + Ssh = blob?.Ssh, + Serial = blob?.Serial + }); + } + return list; + } + + /// 新增或更新一條連線(以 Id 為鍵)。 + public void Upsert(Connection c) + { + using var conn = Open(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + INSERT INTO Connections (Id,Name,Type,SortOrder,GroupName,LastUsedUtc,SettingsJson,CredentialKey) + VALUES ($id,$name,$type,$sort,$group,$used,$json,$cred) + ON CONFLICT(Id) DO UPDATE SET + Name=$name, Type=$type, SortOrder=$sort, GroupName=$group, + LastUsedUtc=$used, SettingsJson=$json, CredentialKey=$cred; + """; + cmd.Parameters.AddWithValue("$id", c.Id.ToString()); + cmd.Parameters.AddWithValue("$name", c.Name); + cmd.Parameters.AddWithValue("$type", (int)c.Type); + cmd.Parameters.AddWithValue("$sort", c.SortOrder); + cmd.Parameters.AddWithValue("$group", (object?)c.GroupName ?? DBNull.Value); + cmd.Parameters.AddWithValue("$used", c.LastUsedUtc.ToString("o")); + cmd.Parameters.AddWithValue("$json", JsonSerializer.Serialize(new SettingsBlob(c.Ssh, c.Serial))); + cmd.Parameters.AddWithValue("$cred", c.CredentialKey); + cmd.ExecuteNonQuery(); + } + + public void Delete(Guid id) + { + using var conn = Open(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = "DELETE FROM Connections WHERE Id=$id"; + cmd.Parameters.AddWithValue("$id", id.ToString()); + cmd.ExecuteNonQuery(); + } + + private SqliteConnection Open() + { + var c = new SqliteConnection(_connString); + c.Open(); + return c; + } + + private static void Exec(SqliteConnection conn, string sql) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = sql; + cmd.ExecuteNonQuery(); + } +} diff --git a/src/ETTerms/Connections/CredentialVault.cs b/src/ETTerms/Connections/CredentialVault.cs new file mode 100644 index 0000000..f3cc47e --- /dev/null +++ b/src/ETTerms/Connections/CredentialVault.cs @@ -0,0 +1,85 @@ +using System.Runtime.InteropServices; +using System.Text; +using ETTerms.Infrastructure; + +namespace ETTerms.Connections; + +/// +/// Windows Credential Manager 封裝(advapi32 P/Invoke)。 +/// 連線密碼 / passphrase 一律存這裡,SQLite 只存 CredentialKey。明碼絕不落地。 +/// +public static class CredentialVault +{ + private const int CRED_TYPE_GENERIC = 1; + private const int CRED_PERSIST_LOCAL_MACHINE = 2; + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct CREDENTIAL + { + public int Flags; + public int Type; + public string TargetName; + public string? Comment; + public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten; + public int CredentialBlobSize; + public IntPtr CredentialBlob; + public int Persist; + public int AttributeCount; + public IntPtr Attributes; + public string? TargetAlias; + public string? UserName; + } + + [DllImport("advapi32.dll", EntryPoint = "CredWriteW", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool CredWrite(ref CREDENTIAL credential, int flags); + + [DllImport("advapi32.dll", EntryPoint = "CredReadW", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool CredRead(string target, int type, int flags, out IntPtr credentialPtr); + + [DllImport("advapi32.dll", EntryPoint = "CredDeleteW", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool CredDelete(string target, int type, int flags); + + [DllImport("advapi32.dll", EntryPoint = "CredFree")] + private static extern void CredFree(IntPtr buffer); + + /// 寫入 / 覆寫一筆密碼。 + public static void Set(string key, string secret) + { + byte[] bytes = Encoding.Unicode.GetBytes(secret); + IntPtr blob = Marshal.AllocCoTaskMem(bytes.Length); + try + { + Marshal.Copy(bytes, 0, blob, bytes.Length); + var cred = new CREDENTIAL + { + Type = CRED_TYPE_GENERIC, + TargetName = key, + CredentialBlobSize = bytes.Length, + CredentialBlob = blob, + Persist = CRED_PERSIST_LOCAL_MACHINE, + UserName = "ETTerms" + }; + if (!CredWrite(ref cred, 0)) + AppLogger.LogError($"CredWrite failed for {key}: {Marshal.GetLastWin32Error()}"); + } + finally { Marshal.FreeCoTaskMem(blob); } + } + + /// 讀取密碼;不存在回傳 null。 + public static string? Get(string key) + { + if (!CredRead(key, CRED_TYPE_GENERIC, 0, out IntPtr ptr)) return null; + try + { + var cred = Marshal.PtrToStructure(ptr); + if (cred.CredentialBlobSize == 0) return ""; + byte[] bytes = new byte[cred.CredentialBlobSize]; + Marshal.Copy(cred.CredentialBlob, bytes, 0, bytes.Length); + return Encoding.Unicode.GetString(bytes); + } + finally { CredFree(ptr); } + } + + /// 刪除密碼(不存在則忽略)。 + public static void Delete(string key) => CredDelete(key, CRED_TYPE_GENERIC, 0); +} diff --git a/src/ETTerms/ETTerms.csproj b/src/ETTerms/ETTerms.csproj new file mode 100644 index 0000000..7e42c96 --- /dev/null +++ b/src/ETTerms/ETTerms.csproj @@ -0,0 +1,27 @@ + + + + WinExe + net8.0-windows + enable + true + enable + ETTerms + ETTerms + + + 0.1.0 + ETTerms + ETTerms Project + + + + + + NU1701 + + + + + + \ No newline at end of file diff --git a/src/ETTerms/Infrastructure/AppLogger.cs b/src/ETTerms/Infrastructure/AppLogger.cs new file mode 100644 index 0000000..1e6a02f --- /dev/null +++ b/src/ETTerms/Infrastructure/AppLogger.cs @@ -0,0 +1,107 @@ +using System.Diagnostics; + +namespace ETTerms.Infrastructure; + +/// +/// 應用程式 Logger(Phase 1 精簡版,移植自 MyTeraTerm.AppLogger)。 +/// - 永遠寫入檔案:%LocalAppData%\ETTerms\logs\ETTerms_yyyyMMdd.log +/// - 同時輸出到 Debug(VS 輸出視窗) +/// 後續 Phase 可再補 Console 即時視窗 / log 輪替清理。 +/// +public static class AppLogger +{ + private static readonly object _lock = new(); + private static string _logFilePath = ""; + private static bool _initialized; + + public static void Initialize() + { + lock (_lock) + { + if (_initialized) return; + try + { + string dir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "ETTerms", "logs"); + Directory.CreateDirectory(dir); + _logFilePath = Path.Combine(dir, $"ETTerms_{DateTime.Now:yyyyMMdd}.log"); + _initialized = true; + + Info(new string('=', 60)); + Info("=== ETTerms Started ==="); + Info($"Time: {DateTime.Now:yyyy-MM-dd HH:mm:ss}"); + Info($"Version: {GetVersion()}"); + Info($"User: {Environment.UserName}@{Environment.MachineName}"); + Info($"OS: {Environment.OSVersion}"); + Info($".NET: {Environment.Version}"); + Info(new string('=', 60)); + } + catch (Exception ex) + { + Debug.WriteLine($"[AppLogger] init failed: {ex.Message}"); + } + } + } + + public static void LogDebug(string message) => Write("DEBUG", message); + public static void Info(string message) => Write("INFO", message); + public static void LogInfo(string message) => Write("INFO", message); + public static void LogWarning(string message) => Write("WARN", message); + + public static void LogError(string message, Exception? ex = null) + { + Write("ERROR", ex is null ? message : $"{message} :: {ex.GetType().Name}: {ex.Message}"); + } + + public static string GetLogFilePath() => _logFilePath; + + public static void OpenLogsFolder() + { + try + { + string dir = Path.GetDirectoryName(_logFilePath) ?? ""; + if (Directory.Exists(dir)) + Process.Start(new ProcessStartInfo("explorer.exe", dir) { UseShellExecute = true }); + } + catch (Exception ex) + { + LogError("Failed to open logs folder", ex); + } + } + + public static void LogApplicationClose() + { + Info("=== ETTerms Closing ==="); + Info(""); + } + + private static void Write(string level, string message) + { + string line = $"{DateTime.Now:HH:mm:ss.fff} [{level,-5}] {message}"; + Debug.WriteLine(line); + + if (!_initialized) return; + lock (_lock) + { + try + { + File.AppendAllText(_logFilePath, line + Environment.NewLine); + } + catch + { + // log 失敗不可拖垮 App + } + } + } + + private static string GetVersion() + { + try + { + var v = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version; + return v is null ? "Unknown" : $"{v.Major}.{v.Minor}.{v.Build}"; + } + catch { return "Unknown"; } + } +} diff --git a/src/ETTerms/Infrastructure/AppSettings.cs b/src/ETTerms/Infrastructure/AppSettings.cs new file mode 100644 index 0000000..cb167cf --- /dev/null +++ b/src/ETTerms/Infrastructure/AppSettings.cs @@ -0,0 +1,85 @@ +using System.Drawing; +using System.Text.Json; + +namespace ETTerms.Infrastructure; + +/// +/// User settings persisted to %LocalAppData%\ETTerms\settings.json. +/// Loaded once at startup; saved on change. +/// +public sealed class AppSettings +{ + private static readonly string Dir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "ETTerms"); + private static readonly string FilePath = Path.Combine(Dir, "settings.json"); + + private static AppSettings? _instance; + public static AppSettings Instance => _instance ??= Load(); + + // ── Terminal ── + public string FontFamily { get; set; } = "Cascadia Mono"; + public float FontSize { get; set; } = 11f; + public int ScrollbackLines { get; set; } = 5000; + public string DefaultNewLine { get; set; } = "\\r\\n"; + public string ColorScheme { get; set; } = "Dark"; + + // ── Shell ── + public string ShellType { get; set; } = "PowerShell"; // PowerShell, Bash, Cmd + public string ShellStartupDir { get; set; } = ""; + + // ── Window ── + public int WindowX { get; set; } = -1; + public int WindowY { get; set; } = -1; + public int WindowW { get; set; } = 1280; + public int WindowH { get; set; } = 800; + public bool WindowMaximized { get; set; } + + public void Save() + { + Directory.CreateDirectory(Dir); + var json = JsonSerializer.Serialize(this, new JsonSerializerOptions { WriteIndented = true }); + File.WriteAllText(FilePath, json); + } + + public void ApplyWindowPosition(System.Windows.Forms.Form form) + { + if (WindowMaximized) + { + form.WindowState = System.Windows.Forms.FormWindowState.Maximized; + return; + } + if (WindowX >= 0 && WindowY >= 0) + { + form.StartPosition = System.Windows.Forms.FormStartPosition.Manual; + form.Location = new Point(WindowX, WindowY); + } + form.Size = new Size(WindowW, WindowH); + } + + public void SaveWindowPosition(System.Windows.Forms.Form form) + { + WindowMaximized = form.WindowState == System.Windows.Forms.FormWindowState.Maximized; + if (!WindowMaximized) + { + WindowX = form.Location.X; + WindowY = form.Location.Y; + WindowW = form.Size.Width; + WindowH = form.Size.Height; + } + Save(); + } + + private static AppSettings Load() + { + try + { + if (File.Exists(FilePath)) + { + var json = File.ReadAllText(FilePath); + return JsonSerializer.Deserialize(json) ?? new(); + } + } + catch { } + return new(); + } +} diff --git a/src/ETTerms/Infrastructure/NativeTheme.cs b/src/ETTerms/Infrastructure/NativeTheme.cs new file mode 100644 index 0000000..82ae7a4 --- /dev/null +++ b/src/ETTerms/Infrastructure/NativeTheme.cs @@ -0,0 +1,27 @@ +using System.Runtime.InteropServices; +using System.Windows.Forms; + +namespace ETTerms.Infrastructure; + +/// Windows 原生外觀輔助(深色標題列)。 +public static class NativeTheme +{ + [DllImport("dwmapi.dll")] + private static extern int DwmSetWindowAttribute(IntPtr hwnd, int attr, ref int value, int size); + + /// 把視窗標題列改成深色(Windows 10 1809+ / 11)。 + public static void ApplyDarkTitleBar(IWin32Window window) + { + try + { + int useDark = 1; + // DWMWA_USE_IMMERSIVE_DARK_MODE = 20(19 為較舊 build 的編號) + if (DwmSetWindowAttribute(window.Handle, 20, ref useDark, sizeof(int)) != 0) + DwmSetWindowAttribute(window.Handle, 19, ref useDark, sizeof(int)); + } + catch (Exception ex) + { + AppLogger.LogWarning($"Dark title bar not applied: {ex.Message}"); + } + } +} diff --git a/src/ETTerms/Program.cs b/src/ETTerms/Program.cs new file mode 100644 index 0000000..28bc765 --- /dev/null +++ b/src/ETTerms/Program.cs @@ -0,0 +1,23 @@ +using ETTerms.App; +using ETTerms.Infrastructure; + +namespace ETTerms; + +static class Program +{ + /// The main entry point for the application. + [STAThread] + static void Main() + { + AppLogger.Initialize(); + + Application.SetUnhandledExceptionMode(UnhandledExceptionMode.CatchException); + Application.ThreadException += (_, e) => + AppLogger.LogError("Unhandled UI exception", e.Exception); + AppDomain.CurrentDomain.UnhandledException += (_, e) => + AppLogger.LogError("Unhandled domain exception", e.ExceptionObject as Exception); + + ApplicationConfiguration.Initialize(); + Application.Run(new MainForm()); + } +} diff --git a/src/ETTerms/Scripting/GroupSyncContext.cs b/src/ETTerms/Scripting/GroupSyncContext.cs new file mode 100644 index 0000000..67a34d8 --- /dev/null +++ b/src/ETTerms/Scripting/GroupSyncContext.cs @@ -0,0 +1,23 @@ +namespace ETTerms.Scripting; + +/// +/// Group 成員間的同步上下文。waitall 會等所有成員都到達同一個 barrier 後才繼續。 +/// sendlnall 會等所有成員到達後,每個成員各自對自己的 channel 送出指令再繼續。 +/// +public sealed class GroupSyncContext +{ + private readonly int _memberCount; + private readonly Barrier _barrier; + + public GroupSyncContext(int memberCount) + { + _memberCount = memberCount; + _barrier = new Barrier(memberCount); + } + + /// 等待所有 group 成員到達此同步點。 + public void WaitAll(CancellationToken ct) + { + _barrier.SignalAndWait(ct); + } +} diff --git a/src/ETTerms/Scripting/Pdu/PduController.cs b/src/ETTerms/Scripting/Pdu/PduController.cs new file mode 100644 index 0000000..9e0a302 --- /dev/null +++ b/src/ETTerms/Scripting/Pdu/PduController.cs @@ -0,0 +1,73 @@ +using System.Net; +using SnmpSharpNet; + +namespace ETTerms.Scripting.Pdu; + +/// +/// PDU Controller for iPoMan II/III models via SNMP. +/// Ported from MyTeraTerm's PDUControlLib. +/// +public sealed class PduController : IDisposable +{ + private readonly string _ip; + private const string Community = "private"; + private const int SnmpPort = 161; + private const int Timeout = 3000; + + public PduController(string ip) => _ip = ip; + + public bool CheckConnection() + { + var name = SnmpGet(".1.3.6.1.4.1.2468.1.4.2.1.1.4"); + return !string.IsNullOrEmpty(name) && name.Contains("PDU"); + } + + public bool SetPortOn(int port) => SnmpSet(PortControlOid(port), new Integer32(3)); + public bool SetPortOff(int port) => SnmpSet(PortControlOid(port), new Integer32(4)); + + public bool? GetPortState(int port) + { + var r = SnmpGet(PortStateOid(port)); + return r == "3" ? true : (r == "2" || r == "4") ? false : null; + } + + public int? GetPortCurrent(int port) => int.TryParse(SnmpGet(PortCurrentOid(port)), out int v) ? v : null; + public double? GetPortPowerWatts(int port) => int.TryParse(SnmpGet(PortPowerOid(port)), out int v) ? v / 10.0 : null; + + private static string PortControlOid(int port) => $".1.3.6.1.4.1.2468.1.4.2.1.3.2.4.1.2.{port}"; + private static string PortStateOid(int port) => $".1.3.6.1.4.1.2468.1.4.2.1.3.2.3.1.2.{port}"; + private static string PortCurrentOid(int port) => $".1.3.6.1.4.1.2468.1.4.2.1.3.2.3.1.3.{port}"; + private static string PortPowerOid(int port) => $".1.3.6.1.4.1.2468.1.4.2.1.3.2.3.1.5.{port}"; + + private bool SnmpSet(string oid, AsnType value) + { + try + { + var param = new AgentParameters(new OctetString(Community)) { Version = SnmpVersion.Ver1 }; + var target = new UdpTarget((IPAddress)new IpAddress(_ip), SnmpPort, Timeout, 1); + var pdu = new SnmpSharpNet.Pdu(PduType.Set); + pdu.VbList.Add(new Oid(oid), value); + var result = (SnmpV1Packet)target.Request(pdu, param); + return result?.Pdu.ErrorStatus == 0; + } + catch { return false; } + } + + private string? SnmpGet(string oid) + { + try + { + var param = new AgentParameters(new OctetString(Community)) { Version = SnmpVersion.Ver1 }; + var target = new UdpTarget((IPAddress)new IpAddress(_ip), SnmpPort, Timeout, 1); + var pdu = new SnmpSharpNet.Pdu(PduType.Get); + pdu.VbList.Add(new Oid(oid)); + var result = (SnmpV1Packet)target.Request(pdu, param); + if (result?.Pdu.ErrorStatus == 0) + foreach (var v in result.Pdu.VbList) return v.Value.ToString(); + } + catch { } + return null; + } + + public void Dispose() { } +} diff --git a/src/ETTerms/Scripting/ScriptRunner.cs b/src/ETTerms/Scripting/ScriptRunner.cs new file mode 100644 index 0000000..5ba523f --- /dev/null +++ b/src/ETTerms/Scripting/ScriptRunner.cs @@ -0,0 +1,90 @@ +using ETTerms.Sessions; + +namespace ETTerms.Scripting; + +/// +/// 非同步驅動 :背景執行緒執行腳本, +/// 轉發 StatusChanged / Output,並在結束時觸發 Finished。可隨時 。 +/// 事件可能在背景執行緒觸發,UI 訂閱者需自行 marshal 回 UI thread。 +/// +public sealed class ScriptRunner +{ + private TTLInterpreter? _interp; + + /// (檔名, 行號, 指令) 進度。 + public event Action? StatusChanged; + + /// 輸出訊息(log / 提示 / 錯誤)。 + public event Action? Output; + + /// 結束:(成功, 訊息)。 + public event Action? Finished; + + public bool IsRunning { get; private set; } + + public async Task RunAsync(string content, string fileName, ISessionChannel channel) + { + if (IsRunning) return; + IsRunning = true; + + var interp = new TTLInterpreter(channel); + interp.StatusChanged += (f, l, c) => StatusChanged?.Invoke(f, l, c); + interp.Output += m => Output?.Invoke(m); + _interp = interp; + + try + { + await Task.Run(() => interp.ExecuteScriptContent(content, fileName)); + Finished?.Invoke(true, "Completed"); + } + catch (OperationCanceledException) + { + Finished?.Invoke(false, "Cancelled"); + } + catch (Exception ex) + { + Output?.Invoke($"[error] {ex.Message}"); + Finished?.Invoke(false, ex.Message); + } + finally + { + interp.Dispose(); + _interp = null; + IsRunning = false; + } + } + + public async Task RunGroupAsync(string content, string fileName, ISessionChannel channel, GroupSyncContext sync, string memberLabel = "") + { + if (IsRunning) return; + IsRunning = true; + + var interp = new TTLInterpreter(channel, sync, memberLabel); + interp.StatusChanged += (f, l, c) => StatusChanged?.Invoke(f, l, c); + interp.Output += m => Output?.Invoke(m); + _interp = interp; + + try + { + await Task.Run(() => interp.ExecuteScriptContent(content, fileName)); + Finished?.Invoke(true, "Completed"); + } + catch (OperationCanceledException) + { + Finished?.Invoke(false, "Cancelled"); + } + catch (Exception ex) + { + Output?.Invoke($"[error] {ex.Message}"); + Finished?.Invoke(false, ex.Message); + } + finally + { + interp.Dispose(); + _interp = null; + IsRunning = false; + } + } + + public void Cancel() => _interp?.Cancel(); +} diff --git a/src/ETTerms/Scripting/TTLInterpreter.cs b/src/ETTerms/Scripting/TTLInterpreter.cs new file mode 100644 index 0000000..1765de7 --- /dev/null +++ b/src/ETTerms/Scripting/TTLInterpreter.cs @@ -0,0 +1,565 @@ +using System.Text; +using System.Text.RegularExpressions; +using ETTerms.Scripting.Pdu; +using ETTerms.Sessions; + +namespace ETTerms.Scripting; + +/// +/// TTL (Tera Term Language) 直譯器,移植自 MyTeraTerm,改驅動 +/// (取代舊版的 ComPortBridge)。送出走 channel.Write,接收監聽 channel.DataReceived。 +/// 支援:send / sendln / pause / wait / timeout / flushrecv / logopen / logwrite / logclose / +/// messagebox / if-elseif-else-endif / while-endwhile / 變數指派。 +/// 阻塞執行(wait/pause 用 Thread.Sleep),由 放到背景執行緒驅動。 +/// +public sealed class TTLInterpreter : IDisposable +{ + private readonly ISessionChannel _channel; + private readonly Encoding _enc = new UTF8Encoding(false); + private readonly Dictionary _vars = new(); + private readonly StringBuilder _recv = new(); + private readonly CancellationTokenSource _cts = new(); + + private GroupSyncContext? _groupSync; + private string _groupMemberLabel = ""; + private readonly Dictionary _pdus = new(); + private StreamWriter? _logWriter; + private int _timeout; // 0 = 無限等待(wait 會一直等到關鍵字出現或被取消) + private int _result; + private string[] _lines = Array.Empty(); + private int _line; + private string _scriptFile = ""; + private volatile bool _cancelled; + private bool _disposed; + + /// (檔名, 行號, 當前指令) 進度更新。 + public event Action? StatusChanged; + + /// 輸出訊息(log / 提示 / 錯誤),供 UI 顯示。 + public event Action? Output; + + public TTLInterpreter(ISessionChannel channel, GroupSyncContext? groupSync = null, string memberLabel = "") + { + _channel = channel ?? throw new ArgumentNullException(nameof(channel)); + _groupSync = groupSync; + _groupMemberLabel = memberLabel; + _channel.DataReceived += OnData; + } + + private void OnData(byte[] data) + { + lock (_recv) _recv.Append(_enc.GetString(data)); + } + + #region Script Execution + + public void ExecuteScriptContent(string scriptContent, string fileName = "") + { + _scriptFile = string.IsNullOrEmpty(fileName) ? "Inline Script" : fileName; + _cancelled = false; + lock (_recv) _recv.Clear(); + + _lines = Preprocess(scriptContent); + _line = 0; + + while (_line < _lines.Length) + { + ThrowIfCancelled(); + StatusChanged?.Invoke(_scriptFile, _line + 1, _lines[_line]); + ExecuteLine(_lines[_line]); + _line++; + } + + StatusChanged?.Invoke(_scriptFile, _lines.Length, "Completed"); + LogClose(); + } + + private static string[] Preprocess(string content) + { + var lines = new List(); + foreach (string raw in content.Split('\n')) + { + string t = raw.Trim(); + int comment = t.IndexOf(';'); + if (comment >= 0) t = t.Substring(0, comment).Trim(); + if (!string.IsNullOrWhiteSpace(t)) lines.Add(t); + } + return lines.ToArray(); + } + + private void ThrowIfCancelled() + { + if (_cancelled) throw new OperationCanceledException("Script execution was cancelled by user"); + } + + #endregion + + #region Line Execution + + private void ExecuteLine(string line) + { + string original = line; + bool isAssignment = original.Contains('=') + && !original.Contains("==") && !original.Contains("!=") + && !original.Contains(">=") && !original.Contains("<=") + && !original.StartsWith("if ") && !original.StartsWith("elseif "); + + string command = GetCommand(line).ToLower(); + if (!isAssignment && command != "while" && command != "if") + line = ReplaceVariables(line); + + if (line.StartsWith(":")) return; // label + + string args = GetArgs(line); + + switch (command) + { + case "send": Send(args, false); break; + case "sendln": Send(args, true); break; + case "pause": Pause(args); break; + case "wait": Wait(args); break; + case "timeout": SetTimeout(args); break; + case "flushrecv": FlushReceive(); break; + case "logopen": LogOpen(args); break; + case "logwrite": LogWrite(args); break; + case "logclose": LogClose(); break; + case "messagebox": ShowMessageBox(args); break; + case "waitall": ExecuteWaitAll(args); break; + case "sendlnall": ExecuteSendlnAll(args); break; + case "sendlngroup": ExecuteSendlnGroup(args); break; + case "pduconnect": ExecutePduConnect(args); break; + case "pductrl": ExecutePduCtrl(args); break; + case "while": ExecuteWhile(original); break; + case "if": ExecuteIf(original); break; + case "endwhile": + case "elseif": + case "else": + case "endif": + break; // handled by ExecuteWhile / ExecuteIf + case "": + if (isAssignment) ExecuteAssignment(original); + break; + default: + if (isAssignment) ExecuteAssignment(original); + else Output?.Invoke($"[ttl] Unknown command: {command}"); + break; + } + } + + #endregion + + #region Control Flow - If + + private void ExecuteIf(string line) + { + var match = Regex.Match(line, @"if\s+(.+?)\s+then", RegexOptions.IgnoreCase); + if (!match.Success) match = Regex.Match(line, @"if\s+(.+)", RegexOptions.IgnoreCase); + if (!match.Success) throw new Exception("Invalid if statement"); + + string condition = match.Groups[1].Value.Trim(); + int ifStart = _line; + int endif = FindBlockEnd(ifStart, "if ", "endif"); + if (endif < 0) throw new Exception("if statement without matching endif"); + + var elseifLines = new List(); + int elseLine = -1; + for (int i = ifStart + 1; i < endif; i++) + { + string t = _lines[i].Trim().ToLower(); + if (t.StartsWith("elseif")) elseifLines.Add(i); + else if (t.StartsWith("else")) { elseLine = i; break; } + } + + bool met = false; + int start = -1, end = -1; + if (EvaluateCondition(ReplaceVariables(condition))) + { + met = true; start = ifStart + 1; + end = elseifLines.Count > 0 ? elseifLines[0] : (elseLine >= 0 ? elseLine : endif); + } + else + { + for (int i = 0; i < elseifLines.Count && !met; i++) + { + var em = Regex.Match(_lines[elseifLines[i]], @"elseif\s+(.+?)\s+then", RegexOptions.IgnoreCase); + if (!em.Success) em = Regex.Match(_lines[elseifLines[i]], @"elseif\s+(.+)", RegexOptions.IgnoreCase); + if (em.Success && EvaluateCondition(ReplaceVariables(em.Groups[1].Value.Trim()))) + { + met = true; start = elseifLines[i] + 1; + end = (i + 1 < elseifLines.Count) ? elseifLines[i + 1] : (elseLine >= 0 ? elseLine : endif); + } + } + if (!met && elseLine >= 0) { met = true; start = elseLine + 1; end = endif; } + } + + if (met && start >= 0 && end > start) + { + _line = start; + while (_line < end) + { + ThrowIfCancelled(); + StatusChanged?.Invoke(_scriptFile, _line + 1, _lines[_line]); + ExecuteLine(_lines[_line]); + _line++; + } + } + _line = endif; + } + + #endregion + + #region Control Flow - While + + private void ExecuteWhile(string line) + { + var match = Regex.Match(line, @"while\s+(.+)", RegexOptions.IgnoreCase); + if (!match.Success) throw new Exception("Invalid while statement"); + + string condition = match.Groups[1].Value.Trim(); + int whileStart = _line; + int endwhile = FindBlockEnd(whileStart, "while", "endwhile"); + if (endwhile < 0) throw new Exception("while statement without matching endwhile"); + + while (true) + { + ThrowIfCancelled(); + if (!EvaluateCondition(ReplaceVariables(condition))) break; + + _line = whileStart + 1; + while (_line < endwhile) + { + ThrowIfCancelled(); + StatusChanged?.Invoke(_scriptFile, _line + 1, _lines[_line]); + ExecuteLine(_lines[_line]); + _line++; + } + } + _line = endwhile; + } + + /// 找出與 配對的 (支援巢狀)。 + private int FindBlockEnd(int startIndex, string open, string close) + { + int depth = 0; + for (int i = startIndex; i < _lines.Length; i++) + { + string t = _lines[i].Trim().ToLower(); + if (t.StartsWith(open)) depth++; + else if (t.StartsWith(close)) { depth--; if (depth == 0) return i; } + } + return -1; + } + + #endregion + + #region Condition Evaluation + + private bool EvaluateCondition(string condition) + { + condition = condition.Trim(); + + foreach (var (op, isNumeric) in new[] { (">=", true), ("<=", true), ("==", false), ("!=", false), (">", true), ("<", true) }) + { + int at = condition.IndexOf(op, StringComparison.Ordinal); + if (at < 0) continue; + string l = condition.Substring(0, at); + string r = condition.Substring(at + op.Length); + if (isNumeric) + { + int li = ParseIntDirect(l), ri = ParseIntDirect(r); + return op switch { ">=" => li >= ri, "<=" => li <= ri, ">" => li > ri, _ => li < ri }; + } + string ls = l.Trim().Trim('\'', '"'), rs = r.Trim().Trim('\'', '"'); + return op == "==" ? ls == rs : ls != rs; + } + + // single '=' as equality + int eq = condition.IndexOf('='); + if (eq >= 0) + { + string l = condition.Substring(0, eq).Trim(), r = condition.Substring(eq + 1).Trim(); + if (int.TryParse(l, out int li) && int.TryParse(r, out int ri)) return li == ri; + return l.Trim('\'', '"') == r.Trim('\'', '"'); + } + + return ParseIntDirect(condition) != 0; + } + + #endregion + + #region Variable Assignment + + private void ExecuteAssignment(string line) + { + int eq = line.IndexOf('='); + if (eq < 0) return; + + string name = line.Substring(0, eq).Trim(); + string expr = line.Substring(eq + 1).Trim(); + + if (!Regex.IsMatch(name, @"^[a-zA-Z_][a-zA-Z0-9_]*$")) + { + Output?.Invoke($"[ttl] Invalid variable name '{name}'"); + return; + } + + _vars[name] = EvaluateExpression(ReplaceVariables(expr)); + } + + private object EvaluateExpression(string expr) + { + expr = expr.Trim(); + + foreach (char op in new[] { '+', '-', '*', '/' }) + { + if (op == '-' && expr.StartsWith("-")) continue; + int at = expr.IndexOf(op); + if (at <= 0) continue; + int l = ParseIntDirect(expr.Substring(0, at)); + int r = ParseIntDirect(expr.Substring(at + 1)); + return op switch { '+' => l + r, '-' => l - r, '*' => l * r, _ => r != 0 ? l / r : 0 }; + } + + if (expr.StartsWith("\"") && expr.EndsWith("\"") && expr.Length >= 2) + return expr.Substring(1, expr.Length - 2); + if (int.TryParse(expr, out int v)) return v; + return expr; + } + + #endregion + + #region Basic Commands + + private void Send(string args, bool newline) + { + string text = args.Trim('\'', '"'); + _channel.Write(_enc.GetBytes(text + (newline ? "\r\n" : ""))); + _logWriter?.WriteLine($">> {text}"); + Output?.Invoke($">> {text}"); + } + + private void Wait(string text) + { + text = ReplaceVariables(text.Trim().Trim('"', '\'')); + if (string.IsNullOrEmpty(text)) return; // 沒有關鍵字可等,直接略過 + Output?.Invoke($"[wait] '{text}'"); + _logWriter?.WriteLine($"[Wait] {text}"); + + int elapsed = 0; + while (true) + { + ThrowIfCancelled(); + lock (_recv) + { + string buf = _recv.ToString(); + int idx = buf.IndexOf(text, StringComparison.Ordinal); + if (idx >= 0) + { + _result = 1; + _recv.Clear(); + _recv.Append(buf.Substring(idx + text.Length)); + return; + } + } + // 只有在明確設定 timeout(>0) 且超時才中止腳本;否則一直等到關鍵字出現或被取消。 + if (_timeout > 0 && elapsed >= _timeout) + { + _result = 0; + _logWriter?.WriteLine($"[Wait] timeout: {text}"); + throw new TimeoutException($"wait timeout ({_timeout}ms): '{text}'"); + } + Thread.Sleep(100); + elapsed += 100; + } + } + + private void FlushReceive() + { + lock (_recv) _recv.Clear(); + } + + private void Pause(string args) + { + int total = ParseInt(args) * 1000, elapsed = 0; + while (elapsed < total) + { + ThrowIfCancelled(); + int slice = Math.Min(100, total - elapsed); + Thread.Sleep(slice); + elapsed += slice; + } + } + + private void SetTimeout(string args) + { + var m = Regex.Match(args, @"=\s*(\d+)"); + _timeout = (m.Success ? ParseInt(m.Groups[1].Value) : ParseInt(args)) * 1000; + } + + private void LogOpen(string args) + { + var m = Regex.Match(args, @"['""]([^'""]+)['""]"); + string file = m.Success ? m.Groups[1].Value : args.Trim().Trim('\'', '"'); + if (string.IsNullOrWhiteSpace(file)) return; + _logWriter = new StreamWriter(file, false); + Output?.Invoke($"[log] opened: {file}"); + } + + private void LogWrite(string args) + { + if (_logWriter == null) return; + string text = args.Trim('\'', '"'); + _logWriter.WriteLine(text); + _logWriter.Flush(); + } + + private void LogClose() + { + if (_logWriter == null) return; + _logWriter.Flush(); + _logWriter.Dispose(); + _logWriter = null; + } + + private void ShowMessageBox(string args) + { + string message = ReplaceVariables(args.Trim('\'', '"')); + Output?.Invoke($"[messagebox] {message}"); + var main = System.Windows.Forms.Application.OpenForms.Count > 0 + ? System.Windows.Forms.Application.OpenForms[0] : null; + if (main != null && main.InvokeRequired) + main.Invoke(() => System.Windows.Forms.MessageBox.Show(main, message, "TTL Script", + System.Windows.Forms.MessageBoxButtons.OK, System.Windows.Forms.MessageBoxIcon.Information)); + else + System.Windows.Forms.MessageBox.Show(main, message, "TTL Script", + System.Windows.Forms.MessageBoxButtons.OK, System.Windows.Forms.MessageBoxIcon.Information); + } + + private void ExecuteWaitAll(string args) + { + if (_groupSync == null) + throw new InvalidOperationException("'waitall' can only be used in Group execution mode."); + // 先各自等到關鍵字出現 + string text = ReplaceVariables(args.Trim().Trim('"', '\'')); + if (!string.IsNullOrEmpty(text)) + Wait(text); + // 再等所有成員都完成 wait + Output?.Invoke("[waitall] waiting for all group members..."); + _groupSync.WaitAll(_cts.Token); + Output?.Invoke("[waitall] all members synchronized"); + } + + private void ExecuteSendlnAll(string args) + { + if (_groupSync == null) + throw new InvalidOperationException("'sendlnall' can only be used in Group execution mode."); + Output?.Invoke("[sendlnall] waiting for all group members..."); + _groupSync.WaitAll(_cts.Token); + Send(args, true); + } + + private void ExecuteSendlnGroup(string args) + { + if (_groupSync == null) + throw new InvalidOperationException("'sendlngroup' can only be used in Group execution mode."); + // 格式: sendlngroup A "show version" + var m = Regex.Match(args, @"^(\w+)\s+(.+)$"); + if (!m.Success) { Output?.Invoke("[sendlngroup] invalid syntax, expected: sendlngroup A \"command\""); return; } + string target = m.Groups[1].Value.ToUpper(); + string cmd = m.Groups[2].Value.Trim('\'', '"'); + if (string.Equals(target, _groupMemberLabel, StringComparison.OrdinalIgnoreCase)) + Send(cmd, true); + } + + // ── PDU Commands ── + + private void ExecutePduConnect(string args) + { + // pduconnect + var parts = args.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length != 2) { Output?.Invoke("[pduconnect] syntax: pduconnect "); _result = 0; return; } + int device = ParseIntDirect(parts[0]); + string ip = parts[1].Trim('\'', '"'); + var pdu = new PduController(ip); + if (pdu.CheckConnection()) + { + _pdus[device] = pdu; + _result = 1; + Output?.Invoke($"[pduconnect] connected to device {device} at {ip}"); + } + else + { + pdu.Dispose(); + _result = 0; + Output?.Invoke($"[pduconnect] failed to connect to {ip}"); + } + } + + private void ExecutePduCtrl(string args) + { + // pductrl + args = ReplaceVariables(args); + var parts = args.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length != 3) { Output?.Invoke("[pductrl] syntax: pductrl <0|1>"); _result = 0; return; } + int device = ParseIntDirect(parts[0]); + int port = ParseIntDirect(parts[1]); + int action = ParseIntDirect(parts[2]); + + if (!_pdus.TryGetValue(device, out var pdu)) + { + Output?.Invoke($"[pductrl] device {device} not connected. Use pduconnect first."); + _result = 0; return; + } + + bool ok = action == 1 ? pdu.SetPortOn(port) : pdu.SetPortOff(port); + _result = ok ? 1 : 0; + string act = action == 1 ? "ON" : "OFF"; + Output?.Invoke($"[pductrl] device {device} port {port} {act} → {(ok ? "OK" : "FAILED")}"); + _logWriter?.WriteLine($"[pductrl] device={device} port={port} action={act} result={_result}"); + } + + #endregion + + #region Helpers + + private static string GetCommand(string line) + { + int sp = line.IndexOf(' '); + return sp > 0 ? line.Substring(0, sp).Trim() : line.Trim(); + } + + private static string GetArgs(string line) + { + int sp = line.IndexOf(' '); + return sp > 0 ? line.Substring(sp + 1).Trim() : ""; + } + + private string ReplaceVariables(string text) + { + foreach (var kvp in _vars) + text = Regex.Replace(text, @"\b" + Regex.Escape(kvp.Key) + @"\b", kvp.Value?.ToString() ?? ""); + return Regex.Replace(text, @"\bresult\b", _result.ToString()); + } + + private int ParseInt(string value) => ParseIntDirect(ReplaceVariables(value.Trim())); + + private static int ParseIntDirect(string value) => + int.TryParse(value.Trim(), out int r) ? r : 0; + + #endregion + + public void Cancel() { _cancelled = true; _cts.Cancel(); } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + _cancelled = true; + _cts.Cancel(); + _cts.Dispose(); + LogClose(); + foreach (var pdu in _pdus.Values) pdu.Dispose(); + _pdus.Clear(); + _channel.DataReceived -= OnData; + } +} diff --git a/src/ETTerms/Sessions/HostKeyStore.cs b/src/ETTerms/Sessions/HostKeyStore.cs new file mode 100644 index 0000000..9796d2a --- /dev/null +++ b/src/ETTerms/Sessions/HostKeyStore.cs @@ -0,0 +1,39 @@ +namespace ETTerms.Sessions; + +/// +/// Host key 指紋的 trust-on-first-use 記錄:%LocalAppData%\ETTerms\known_hosts.txt +/// 每行 "host:port SHA256base64"。 +/// +public static class HostKeyStore +{ + private static readonly string _path = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "ETTerms", "known_hosts.txt"); + + private static readonly object _lock = new(); + + /// 取回已信任指紋;無則 null。 + public static string? Get(string hostKey) + { + lock (_lock) + { + if (!File.Exists(_path)) return null; + foreach (var line in File.ReadAllLines(_path)) + { + var sp = line.Split(' ', 2); + if (sp.Length == 2 && sp[0] == hostKey) return sp[1]; + } + return null; + } + } + + /// 記錄新信任的指紋。 + public static void Set(string hostKey, string fingerprint) + { + lock (_lock) + { + Directory.CreateDirectory(Path.GetDirectoryName(_path)!); + File.AppendAllText(_path, $"{hostKey} {fingerprint}{Environment.NewLine}"); + } + } +} diff --git a/src/ETTerms/Sessions/ISessionChannel.cs b/src/ETTerms/Sessions/ISessionChannel.cs new file mode 100644 index 0000000..8b45abe --- /dev/null +++ b/src/ETTerms/Sessions/ISessionChannel.cs @@ -0,0 +1,23 @@ +namespace ETTerms.Sessions; + +/// +/// 連線通道抽象:TerminalView / ScriptEngine 都只認得這個介面, +/// 因此 SSH 與 Serial 對上層一致(同一套腳本可驅動兩種連線)。 +/// +public interface ISessionChannel : IDisposable +{ + /// 收到遠端資料(背景執行緒觸發,訂閱者需自行 Invoke 回 UI thread)。 + event Action? DataReceived; + + /// 建立連線;失敗丟例外。 + void Open(); + + /// 送出位元組到遠端。 + void Write(byte[] data); + + /// 通知遠端終端機大小(PTY size);Serial 為 no-op。 + void Resize(int cols, int rows); + + /// 關閉連線並釋放底層資源。 + void Close(); +} diff --git a/src/ETTerms/Sessions/SerialChannel.cs b/src/ETTerms/Sessions/SerialChannel.cs new file mode 100644 index 0000000..a6e72dd --- /dev/null +++ b/src/ETTerms/Sessions/SerialChannel.cs @@ -0,0 +1,84 @@ +using System.IO.Ports; +using ETTerms.Connections; +using ETTerms.Infrastructure; + +namespace ETTerms.Sessions; + +/// +/// Serial 連線通道:包 。開啟前向 SessionManager 占用 COM port(互斥)。 +/// +public sealed class SerialChannel : ISessionChannel +{ + private readonly SerialPort _port; + private readonly string _portName; + private bool _opened; + + public event Action? DataReceived; + + public SerialChannel(SerialSettings s) + { + _portName = s.PortName; + _port = new SerialPort(s.PortName, s.BaudRate, s.Parity, s.DataBits, s.StopBits) + { + Handshake = s.Handshake, + NewLine = s.NewLine + }; + _port.DataReceived += OnData; + } + + public void Open() + { + if (!SessionManager.TryReservePort(_portName)) + throw new InvalidOperationException($"COM port {_portName} is already in use by another session."); + try + { + _port.Open(); + _opened = true; + AppLogger.Info($"Serial opened: {_portName} @ {_port.BaudRate}"); + } + catch + { + SessionManager.ReleasePort(_portName); + throw; + } + } + + private void OnData(object sender, SerialDataReceivedEventArgs e) + { + try + { + int n = _port.BytesToRead; + if (n <= 0) return; + var buf = new byte[n]; + int read = _port.Read(buf, 0, n); + if (read > 0) DataReceived?.Invoke(read == n ? buf : buf[..read]); + } + catch (Exception ex) + { + AppLogger.LogWarning($"Serial read error on {_portName}: {ex.Message}"); + } + } + + public void Write(byte[] data) + { + if (_port.IsOpen) _port.Write(data, 0, data.Length); + } + + public void Resize(int cols, int rows) { /* serial 無 PTY size */ } + + public void Close() + { + if (!_opened) return; + _opened = false; + try { if (_port.IsOpen) _port.Close(); } catch { /* 忽略關閉錯誤 */ } + SessionManager.ReleasePort(_portName); + AppLogger.Info($"Serial closed: {_portName}"); + } + + public void Dispose() + { + _port.DataReceived -= OnData; + Close(); + _port.Dispose(); + } +} diff --git a/src/ETTerms/Sessions/SessionManager.cs b/src/ETTerms/Sessions/SessionManager.cs new file mode 100644 index 0000000..3a0e4fd --- /dev/null +++ b/src/ETTerms/Sessions/SessionManager.cs @@ -0,0 +1,39 @@ +namespace ETTerms.Sessions; + +/// +/// 全域 session 登錄表 + Serial COM port 互斥管理。 +/// 一個 COM port 同時只能被一個 session 開啟。 +/// +public static class SessionManager +{ + private static readonly object _lock = new(); + private static readonly HashSet _busyPorts = new(StringComparer.OrdinalIgnoreCase); + private static readonly List _active = new(); + + /// 目前 active 的 session 快照。 + public static IReadOnlyList Active + { + get { lock (_lock) return _active.ToArray(); } + } + + /// 嘗試占用 COM port;已被占用回 false。 + public static bool TryReservePort(string portName) + { + lock (_lock) return _busyPorts.Add(portName); + } + + public static void ReleasePort(string portName) + { + lock (_lock) _busyPorts.Remove(portName); + } + + public static void Register(ISessionChannel channel) + { + lock (_lock) _active.Add(channel); + } + + public static void Unregister(ISessionChannel channel) + { + lock (_lock) _active.Remove(channel); + } +} diff --git a/src/ETTerms/Sessions/SessionPage.cs b/src/ETTerms/Sessions/SessionPage.cs new file mode 100644 index 0000000..dad40fd --- /dev/null +++ b/src/ETTerms/Sessions/SessionPage.cs @@ -0,0 +1,191 @@ +using System.Drawing; +using System.Windows.Forms; +using ETTerms.App; +using ETTerms.Infrastructure; +using ETTerms.Scripting; +using ETTerms.Terminal; + +namespace ETTerms.Sessions; + +/// +/// 一個連線分頁的內容:頂部腳本列(狀態 + 載入執行 / 停止)+ 自繪 VT100 +/// + 綁定的 。每個分頁各自對自己的 channel 跑 TTL 腳本。 +/// channel 在 handle 建立後才 Open,確保 DataReceived 能安全 Invoke 回 UI thread。 +/// +public sealed class SessionPage : UserControl +{ + private const int MaxStatus = 40; // 狀態列指令最大字數,超過以 ... 顯示 + + private readonly ISessionChannel _channel; + private readonly string _title; + private readonly TerminalView _term; + private readonly ScriptRunner _runner = new(); + private readonly Label _status; + private readonly Button _run, _stop; + private bool _started; + + /// 底層連線通道。 + public ISessionChannel Channel => _channel; + + /// 此分頁是否為 Serial 連線。 + public bool IsSerial => _channel is SerialChannel; + + /// 目前是否正在跑腳本。 + public bool IsScriptRunning => _runner.IsRunning; + + /// 所屬 Group(0=無, 1/2/3)。 + public int Group { get; set; } + + /// Group 內的編號標籤,如 "Group1-A"。外部設定。 + public string GroupLabel { get; set; } = ""; + + /// 連線標題(COM名稱)。 + public string Title => _title; + + /// 外部觸發腳本執行(供 Run All 使用)。 + public async Task RunScriptAsync(string content, string fileName) + { + if (_runner.IsRunning) return; + SetRunning(true); + await _runner.RunAsync(content, fileName, _channel); + } + + /// 以 Group 模式執行腳本(支援 waitall/sendlnall/sendlngroup 同步)。 + public async Task RunGroupScriptAsync(string content, string fileName, GroupSyncContext sync, string memberLabel) + { + if (_runner.IsRunning) return; + SetRunning(true); + await _runner.RunGroupAsync(content, fileName, _channel, sync, memberLabel); + } + + /// 同步開啟失敗(主要是 Serial 連不上 / 被占用)時觸發,附帶訊息。 + public event Action? ConnectFailed; + + public SessionPage(ISessionChannel channel, string title) + { + _channel = channel; + _title = title; + Dock = DockStyle.Fill; + + // ── 頂部腳本列 ── + var bar = new Panel { Dock = DockStyle.Top, Height = 24, BackColor = Theme.RailBack }; + _status = new Label + { + Dock = DockStyle.Fill, Text = "Idle", + ForeColor = Theme.TextDim, Font = Theme.UiFont, + TextAlign = ContentAlignment.MiddleLeft, Padding = new Padding(8, 0, 0, 0) + }; + _run = MakeBarButton("▶ Script", OnRunScript); + _stop = MakeBarButton("■ Stop", (_, _) => _runner.Cancel()); + _stop.Enabled = false; + bar.Controls.Add(_status); // Fill 先加 + bar.Controls.Add(_run); // Right + bar.Controls.Add(_stop); // Right + + _term = new TerminalView(new TerminalProfile()) { Dock = DockStyle.Fill }; + _term.SendData += data => _channel.Write(data); + _term.Resized += (cols, rows) => _channel.Resize(cols, rows); + + Controls.Add(_term); // Fill 先加 + Controls.Add(bar); // Top + + _runner.StatusChanged += (_, line, cmd) => Ui(() => _status.Text = $"line {line}: {Trunc(cmd)}"); + _runner.Finished += (_, msg) => Ui(() => { _status.Text = msg; SetRunning(false); }); + } + + private async void OnRunScript(object? sender, EventArgs e) + { + string content, name; + using (var dlg = new OpenFileDialog { Filter = "TTL scripts (*.ttl)|*.ttl|All files (*.*)|*.*" }) + { + if (dlg.ShowDialog(this) != DialogResult.OK) return; + content = File.ReadAllText(dlg.FileName); + name = Path.GetFileName(dlg.FileName); + } + if (ContainsGroupCommands(content)) + { + MessageBox.Show(this, + "Script contains 'waitall' or 'sendlnall' which require Group execution.\nPlease use Run Group buttons instead.", + "Script", MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + SetRunning(true); + await _runner.RunAsync(content, name, _channel); + } + + private static bool ContainsGroupCommands(string content) + { + foreach (var line in content.Split('\n')) + { + var t = line.Trim().ToLower(); + if (t.StartsWith("waitall") || t.StartsWith("sendlnall") || t.StartsWith("sendlngroup")) + return true; + } + return false; + } + + private void SetRunning(bool running) + { + _run.Enabled = !running; + _stop.Enabled = running; + } + + private static string Trunc(string s) => s.Length <= MaxStatus ? s : s.Substring(0, MaxStatus) + "..."; + + private void Ui(Action action) + { + if (IsDisposed || !IsHandleCreated) return; + if (InvokeRequired) BeginInvoke(action); + else action(); + } + + private static Button MakeBarButton(string text, EventHandler onClick) + { + var b = new Button + { + Text = text, Dock = DockStyle.Right, Width = 72, FlatStyle = FlatStyle.Flat, + ForeColor = Theme.Text, BackColor = Theme.TabBack, Font = Theme.UiFont, Cursor = Cursors.Hand + }; + b.FlatAppearance.BorderColor = Theme.Border; + b.FlatAppearance.MouseOverBackColor = Theme.Hover; + b.Click += onClick; + return b; + } + + protected override void OnHandleCreated(EventArgs e) + { + base.OnHandleCreated(e); + if (_started) return; + _started = true; + _channel.DataReceived += OnDataReceived; + try + { + _channel.Open(); + SessionManager.Register(_channel); + } + catch (Exception ex) + { + AppLogger.LogError($"Open channel failed: {_title}", ex); + ConnectFailed?.Invoke(ex.Message); + } + } + + private void OnDataReceived(byte[] data) + { + if (IsDisposed || !IsHandleCreated) return; + if (InvokeRequired) BeginInvoke(() => _term.Feed(data)); + else _term.Feed(data); + } + + protected override void Dispose(bool disposing) + { + if (disposing && _started) + { + _runner.Cancel(); + _channel.DataReceived -= OnDataReceived; + SessionManager.Unregister(_channel); + _channel.Dispose(); + } + base.Dispose(disposing); + } +} diff --git a/src/ETTerms/Sessions/ShellChannel.cs b/src/ETTerms/Sessions/ShellChannel.cs new file mode 100644 index 0000000..9e8081e --- /dev/null +++ b/src/ETTerms/Sessions/ShellChannel.cs @@ -0,0 +1,185 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text; +using ETTerms.Connections; +using Microsoft.Win32.SafeHandles; + +namespace ETTerms.Sessions; + +/// +/// Local shell channel using Windows ConPTY for proper PTY support. +/// This allows PowerShell/Bash/Cmd to behave like a real terminal with +/// arrow keys, history, backspace, PSReadLine, etc. +/// +public sealed class ShellChannel : ISessionChannel +{ + private readonly ShellSettings _settings; + private SafeFileHandle? _ptyOutput; + private SafeFileHandle? _ptyInput; + private FileStream? _writeStream; + private IntPtr _ptyHandle; + private Process? _proc; + private Thread? _reader; + private bool _closed; + + public event Action? DataReceived; + + public ShellChannel(ShellSettings settings) => _settings = settings; + + public void Open() + { + // Create pipes + CreatePipe(out var inputReadSide, out var inputWriteSide); + CreatePipe(out var outputReadSide, out var outputWriteSide); + + // Create pseudo console (ConPTY) + var size = new COORD { X = 120, Y = 30 }; + int hr = CreatePseudoConsole(size, inputReadSide, outputWriteSide, 0, out _ptyHandle); + if (hr != 0) throw new Exception($"CreatePseudoConsole failed: 0x{hr:X8}"); + + // Close handles that are now owned by the pseudo console + inputReadSide.Dispose(); + outputWriteSide.Dispose(); + + _ptyInput = inputWriteSide; + _ptyOutput = outputReadSide; + _writeStream = new FileStream(_ptyInput, FileAccess.Write, 256, false); + + // Start the shell process attached to the pseudo console + var (exe, args) = _settings.ShellType.ToLower() switch + { + "bash" => ("bash", "--login -i"), + "cmd" => ("cmd.exe", ""), + _ => ("powershell.exe", "-NoLogo") + }; + + var si = new STARTUPINFOEX(); + si.StartupInfo.cb = Marshal.SizeOf(); + + // Initialize thread attribute list + IntPtr attrSize = IntPtr.Zero; + InitializeProcThreadAttributeList(IntPtr.Zero, 1, 0, ref attrSize); + si.lpAttributeList = Marshal.AllocHGlobal(attrSize.ToInt32()); + InitializeProcThreadAttributeList(si.lpAttributeList, 1, 0, ref attrSize); + UpdateProcThreadAttribute(si.lpAttributeList, 0, (IntPtr)0x00020016 /* PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE */, + _ptyHandle, IntPtr.Size, IntPtr.Zero, IntPtr.Zero); + + string workDir = string.IsNullOrWhiteSpace(_settings.StartupDirectory) + ? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) + : _settings.StartupDirectory; + + bool ok = CreateProcess(null, $"{exe} {args}".TrimEnd(), IntPtr.Zero, IntPtr.Zero, false, + 0x00080000 /* EXTENDED_STARTUPINFO_PRESENT */, IntPtr.Zero, workDir, ref si, out var pi); + + if (!ok) throw new Exception($"CreateProcess failed: {Marshal.GetLastWin32Error()}"); + + CloseHandle(pi.hThread); + _proc = Process.GetProcessById(pi.dwProcessId); + + // Start reader thread + _reader = new Thread(ReadLoop) { IsBackground = true, Name = "ConPTY-Reader" }; + _reader.Start(); + } + + private void ReadLoop() + { + var buf = new byte[4096]; + try + { + using var stream = new FileStream(_ptyOutput!, FileAccess.Read, 4096, false); + while (!_closed) + { + int n = stream.Read(buf, 0, buf.Length); + if (n <= 0) break; + var data = new byte[n]; + Buffer.BlockCopy(buf, 0, data, 0, n); + DataReceived?.Invoke(data); + } + } + catch when (_closed) { } + } + + public void Write(byte[] data) + { + if (_closed || _writeStream == null) return; + _writeStream.Write(data, 0, data.Length); + _writeStream.Flush(); + } + + public void Resize(int cols, int rows) + { + if (_ptyHandle != IntPtr.Zero) + ResizePseudoConsole(_ptyHandle, new COORD { X = (short)cols, Y = (short)rows }); + } + + public void Close() + { + if (_closed) return; + _closed = true; + _writeStream?.Dispose(); + if (_ptyHandle != IntPtr.Zero) { ClosePseudoConsole(_ptyHandle); _ptyHandle = IntPtr.Zero; } + _ptyInput?.Dispose(); + _ptyOutput?.Dispose(); + if (_proc is { HasExited: false }) { try { _proc.Kill(); } catch { } } + _proc?.Dispose(); + } + + public void Dispose() => Close(); + + // ── Win32 Interop ── + + [StructLayout(LayoutKind.Sequential)] + private struct COORD { public short X, Y; } + + [StructLayout(LayoutKind.Sequential)] + private struct STARTUPINFOEX + { + public STARTUPINFO StartupInfo; + public IntPtr lpAttributeList; + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)] + private struct STARTUPINFO + { + public int cb; public IntPtr lpReserved, lpDesktop, lpTitle; + public int dwX, dwY, dwXSize, dwYSize, dwXCountChars, dwYCountChars, dwFillAttribute, dwFlags; + public short wShowWindow, cbReserved2; public IntPtr lpReserved2, hStdInput, hStdOutput, hStdError; + } + + [StructLayout(LayoutKind.Sequential)] + private struct PROCESS_INFORMATION + { + public IntPtr hProcess, hThread; + public int dwProcessId, dwThreadId; + } + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern int CreatePseudoConsole(COORD size, SafeFileHandle hInput, SafeFileHandle hOutput, uint dwFlags, out IntPtr phPC); + + [DllImport("kernel32.dll")] + private static extern int ResizePseudoConsole(IntPtr hPC, COORD size); + + [DllImport("kernel32.dll")] + private static extern void ClosePseudoConsole(IntPtr hPC); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool CreatePipe(out SafeFileHandle hReadPipe, out SafeFileHandle hWritePipe, IntPtr lpPipeAttributes, int nSize); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool InitializeProcThreadAttributeList(IntPtr lpAttributeList, int dwAttributeCount, int dwFlags, ref IntPtr lpSize); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool UpdateProcThreadAttribute(IntPtr lpAttributeList, uint dwFlags, IntPtr Attribute, IntPtr lpValue, IntPtr cbSize, IntPtr lpPreviousValue, IntPtr lpReturnSize); + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)] + private static extern bool CreateProcess(string? lpApplicationName, string lpCommandLine, IntPtr lpProcessAttributes, IntPtr lpThreadAttributes, bool bInheritHandles, uint dwCreationFlags, IntPtr lpEnvironment, string lpCurrentDirectory, ref STARTUPINFOEX lpStartupInfo, out PROCESS_INFORMATION lpProcessInformation); + + [DllImport("kernel32.dll")] + private static extern bool CloseHandle(IntPtr hObject); + + private static void CreatePipe(out SafeFileHandle read, out SafeFileHandle write) + { + if (!CreatePipe(out read, out write, IntPtr.Zero, 0)) + throw new Exception($"CreatePipe failed: {Marshal.GetLastWin32Error()}"); + } +} diff --git a/src/ETTerms/Sessions/SshChannel.cs b/src/ETTerms/Sessions/SshChannel.cs new file mode 100644 index 0000000..47d0807 --- /dev/null +++ b/src/ETTerms/Sessions/SshChannel.cs @@ -0,0 +1,133 @@ +using System.Reflection; +using System.Text; +using ETTerms.Connections; +using ETTerms.Infrastructure; +using Renci.SshNet; +using Renci.SshNet.Common; + +namespace ETTerms.Sessions; + +/// +/// SSH 連線通道:SSH.NET SshClient + ShellStream,實作 。 +/// 支援 password / private key(passphrase 走 CredentialVault)/ keyboard-interactive; +/// host key 指紋 TOFU(首次記錄、之後比對、不符中止)。連線在背景執行緒進行避免凍結 UI。 +/// +public sealed class SshChannel : ISessionChannel +{ + private readonly SshSettings _ssh; + private readonly string? _secret; + private SshClient? _client; + private ShellStream? _shell; + private int _cols = 80, _rows = 24; + private bool _closed; + + public event Action? DataReceived; + + public SshChannel(Connection conn) + { + _ssh = conn.Ssh ?? new SshSettings(); + _secret = CredentialVault.Get(conn.CredentialKey); + } + + public void Open() => Task.Run(Connect); + + private void Connect() + { + try + { + var ci = BuildConnectionInfo(); + _client = new SshClient(ci); + _client.HostKeyReceived += OnHostKey; + _client.Connect(); + _shell = _client.CreateShellStream("xterm-256color", (uint)_cols, (uint)_rows, 0, 0, 4096); + _shell.DataReceived += (_, e) => DataReceived?.Invoke(e.Data); + AppLogger.Info($"SSH connected: {_ssh.Username}@{_ssh.Host}:{_ssh.Port}"); + } + catch (Exception ex) + { + Emit($"\r\n[SSH 連線失敗] {ex.Message}\r\n"); + AppLogger.LogError($"SSH connect failed {_ssh.Host}", ex); + } + } + + private ConnectionInfo BuildConnectionInfo() + { + AuthenticationMethod method = _ssh.AuthMethod switch + { + SshAuthMethod.PrivateKey => new PrivateKeyAuthenticationMethod(_ssh.Username, LoadKey()), + SshAuthMethod.KeyboardInteractive => KeyboardInteractive(), + _ => new PasswordAuthenticationMethod(_ssh.Username, _secret ?? "") + }; + return new ConnectionInfo(_ssh.Host, _ssh.Port, _ssh.Username, method); + } + + private PrivateKeyFile LoadKey() => string.IsNullOrEmpty(_secret) + ? new PrivateKeyFile(_ssh.PrivateKeyPath!) + : new PrivateKeyFile(_ssh.PrivateKeyPath!, _secret); + + private KeyboardInteractiveAuthenticationMethod KeyboardInteractive() + { + var ki = new KeyboardInteractiveAuthenticationMethod(_ssh.Username); + ki.AuthenticationPrompt += (_, e) => + { + foreach (AuthenticationPrompt p in e.Prompts) p.Response = _secret ?? ""; + }; + return ki; + } + + private void OnHostKey(object? sender, HostKeyEventArgs e) + { + string key = $"{_ssh.Host}:{_ssh.Port}"; + string fp = e.FingerPrintSHA256; + string? known = HostKeyStore.Get(key); + if (known == null) + { + HostKeyStore.Set(key, fp); + Emit($"\r\n[host key TOFU] 首次連線,已記錄 {key}\r\nSHA256:{fp}\r\n"); + e.CanTrust = true; + } + else if (known == fp) + { + e.CanTrust = true; + } + else + { + Emit($"\r\n[警告] {key} host key 不符,可能遭中間人攻擊!連線中止。\r\n預期 SHA256:{known}\r\n實際 SHA256:{fp}\r\n"); + e.CanTrust = false; + } + } + + public void Write(byte[] data) + { + if (_shell == null) return; + _shell.Write(data, 0, data.Length); + _shell.Flush(); + } + + public void Resize(int cols, int rows) + { + _cols = cols; _rows = rows; + if (_shell == null) return; + // SSH.NET ShellStream 未公開 resize;反射呼叫底層 channel 的 SendWindowChangeRequest + try + { + var channel = _shell.GetType() + .GetField("_channel", BindingFlags.NonPublic | BindingFlags.Instance)?.GetValue(_shell); + channel?.GetType().GetMethod("SendWindowChangeRequest", BindingFlags.Public | BindingFlags.Instance)? + .Invoke(channel, new object[] { (uint)cols, (uint)rows, 0u, 0u }); + } + catch (Exception ex) { AppLogger.LogWarning($"SSH resize failed: {ex.Message}"); } + } + + public void Close() + { + if (_closed) return; + _closed = true; + try { _shell?.Dispose(); } catch { } + try { _client?.Disconnect(); _client?.Dispose(); } catch { } + } + + public void Dispose() => Close(); + + private void Emit(string text) => DataReceived?.Invoke(Encoding.UTF8.GetBytes(text)); +} diff --git a/src/ETTerms/Terminal/AnsiParser.cs b/src/ETTerms/Terminal/AnsiParser.cs new file mode 100644 index 0000000..179897c --- /dev/null +++ b/src/ETTerms/Terminal/AnsiParser.cs @@ -0,0 +1,217 @@ +using System.Drawing; +using System.Text; + +namespace ETTerms.Terminal; + +/// VT100 / 常見 ANSI escape 狀態機,驅動 +public sealed class AnsiParser +{ + private enum State { Ground, Esc, Csi, Osc } + + private readonly ScreenBuffer _b; + private readonly Decoder _dec = Encoding.UTF8.GetDecoder(); + private State _state = State.Ground; + private readonly StringBuilder _params = new(); + private bool _priv; + private bool _oscEsc; + private int _skipCharset; + + /// DECCKM:application cursor keys(影響方向鍵送出序列)。 + public bool AppCursorKeys { get; private set; } + + public AnsiParser(ScreenBuffer buffer) => _b = buffer; + + public void Feed(byte[] data) + { + var chars = new char[data.Length]; + int n = _dec.GetChars(data, 0, data.Length, chars, 0); + for (int i = 0; i < n; i++) Step(chars[i]); + } + + private void Step(char ch) + { + if (_skipCharset > 0) { _skipCharset--; return; } + switch (_state) + { + case State.Ground: Ground(ch); break; + case State.Esc: Esc(ch); break; + case State.Csi: Csi(ch); break; + case State.Osc: Osc(ch); break; + } + } + + private void Ground(char ch) + { + switch (ch) + { + case '\x1b': _state = State.Esc; break; + case '\r': _b.CarriageReturn(); break; + case '\n': case '\v': case '\f': _b.LineFeed(); break; + case '\b': _b.Backspace(); break; + case '\t': _b.Tab(); break; + case '\a': break; + default: if (ch >= ' ') _b.Print(ch); break; + } + } + + private void Esc(char ch) + { + switch (ch) + { + case '[': _params.Clear(); _priv = false; _state = State.Csi; break; + case ']': _oscEsc = false; _state = State.Osc; break; + case '(': case ')': case '*': case '+': _skipCharset = 1; _state = State.Ground; break; + case '7': _b.SaveCursor(); _state = State.Ground; break; + case '8': _b.RestoreCursor(); _state = State.Ground; break; + case 'M': _b.ReverseLineFeed(); _state = State.Ground; break; + case 'D': _b.LineFeed(); _state = State.Ground; break; + case 'E': _b.NextLine(); _state = State.Ground; break; + case 'c': _b.ResetAttrs(); _b.EraseInDisplay(2); _b.MoveCursor(0, 0); _state = State.Ground; break; + default: _state = State.Ground; break; + } + } + + private void Csi(char ch) + { + if (ch == '?' || ch == '>' || ch == '!') { _priv = true; return; } + if ((ch >= '0' && ch <= '9') || ch == ';') { _params.Append(ch); return; } + if (ch >= 0x40 && ch <= 0x7e) { Dispatch(ch); _state = State.Ground; return; } + // 其餘 intermediate 忽略 + } + + private void Osc(char ch) + { + if (ch == '\a') { _state = State.Ground; return; } + if (_oscEsc && ch == '\\') { _state = State.Ground; return; } + _oscEsc = ch == '\x1b'; + } + + private int[] Params() + { + if (_params.Length == 0) return Array.Empty(); + var parts = _params.ToString().Split(';'); + var r = new int[parts.Length]; + for (int i = 0; i < parts.Length; i++) r[i] = int.TryParse(parts[i], out var v) ? v : 0; + return r; + } + + private int P(int[] p, int i, int def) => (i < p.Length && p[i] > 0) ? p[i] : def; + + private void Dispatch(char f) + { + var p = Params(); + switch (f) + { + case 'A': _b.CursorUp(P(p, 0, 1)); break; + case 'B': _b.CursorDown(P(p, 0, 1)); break; + case 'C': _b.CursorRight(P(p, 0, 1)); break; + case 'D': _b.CursorLeft(P(p, 0, 1)); break; + case 'E': _b.MoveCursor(_b.CursorRow + P(p, 0, 1), 0); break; + case 'F': _b.MoveCursor(_b.CursorRow - P(p, 0, 1), 0); break; + case 'G': case '`': _b.SetColumn(P(p, 0, 1) - 1); break; + case 'd': _b.SetRow(P(p, 0, 1) - 1); break; + case 'H': case 'f': _b.MoveCursor(P(p, 0, 1) - 1, P(p, 1, 1) - 1); break; + case 'J': _b.EraseInDisplay(p.Length > 0 ? p[0] : 0); break; + case 'K': _b.EraseInLine(p.Length > 0 ? p[0] : 0); break; + case 'L': _b.InsertLines(P(p, 0, 1)); break; + case 'M': _b.DeleteLines(P(p, 0, 1)); break; + case 'P': _b.DeleteChars(P(p, 0, 1)); break; + case '@': _b.InsertChars(P(p, 0, 1)); break; + case 'X': _b.EraseChars(P(p, 0, 1)); break; + case 'S': _b.ScrollUp(P(p, 0, 1)); break; + case 'T': _b.ScrollDown(P(p, 0, 1)); break; + case 'r': _b.SetScrollRegion(P(p, 0, 1) - 1, p.Length > 1 && p[1] > 0 ? p[1] - 1 : _b.Rows - 1); break; + case 's': _b.SaveCursor(); break; + case 'u': _b.RestoreCursor(); break; + case 'h': SetMode(p, true); break; + case 'l': SetMode(p, false); break; + case 'm': Sgr(p); break; + } + } + + private void SetMode(int[] p, bool set) + { + if (!_priv) return; + foreach (var code in p) + switch (code) + { + case 25: _b.CursorVisible = set; break; + case 7: _b.AutoWrap = set; break; + case 1: AppCursorKeys = set; break; + case 47: case 1047: case 1049: + if (set) _b.EnterAlt(); else _b.ExitAlt(); break; + } + } + + private void Sgr(int[] p) + { + if (p.Length == 0) { _b.ResetAttrs(); return; } + for (int i = 0; i < p.Length; i++) + { + int c = p[i]; + switch (c) + { + case 0: _b.ResetAttrs(); break; + case 1: _b.PenAttr |= CellAttr.Bold; break; + case 4: _b.PenAttr |= CellAttr.Underline; break; + case 7: _b.PenAttr |= CellAttr.Inverse; break; + case 22: _b.PenAttr &= ~CellAttr.Bold; break; + case 24: _b.PenAttr &= ~CellAttr.Underline; break; + case 27: _b.PenAttr &= ~CellAttr.Inverse; break; + case 39: _b.PenFg = _b.DefaultFg; break; + case 49: _b.PenBg = _b.DefaultBg; break; + case >= 30 and <= 37: _b.PenFg = Palette.Ansi(c - 30); break; + case >= 40 and <= 47: _b.PenBg = Palette.Ansi(c - 40); break; + case >= 90 and <= 97: _b.PenFg = Palette.Ansi(c - 90 + 8); break; + case >= 100 and <= 107: _b.PenBg = Palette.Ansi(c - 100 + 8); break; + case 38: i = Ext(p, i, true); break; + case 48: i = Ext(p, i, false); break; + } + } + } + + private int Ext(int[] p, int i, bool fg) + { + if (i + 1 < p.Length && p[i + 1] == 5 && i + 2 < p.Length) + { + var col = Palette.Xterm256(p[i + 2]); + if (fg) _b.PenFg = col; else _b.PenBg = col; + return i + 2; + } + if (i + 1 < p.Length && p[i + 1] == 2 && i + 4 < p.Length) + { + var col = Color.FromArgb(p[i + 2], p[i + 3], p[i + 4]); + if (fg) _b.PenFg = col; else _b.PenBg = col; + return i + 4; + } + return i; + } +} + +/// ANSI 16 色 + xterm-256 色盤。 +public static class Palette +{ + private static readonly Color[] Base16 = + { + Color.FromArgb(0,0,0), Color.FromArgb(205,49,49), Color.FromArgb(13,188,121), Color.FromArgb(229,229,16), + Color.FromArgb(36,114,200), Color.FromArgb(188,63,188), Color.FromArgb(17,168,205), Color.FromArgb(229,229,229), + Color.FromArgb(102,102,102), Color.FromArgb(241,76,76), Color.FromArgb(35,209,139), Color.FromArgb(245,245,67), + Color.FromArgb(59,142,234), Color.FromArgb(214,112,214), Color.FromArgb(41,184,219), Color.FromArgb(255,255,255), + }; + + public static Color Ansi(int i) => Base16[Math.Clamp(i, 0, 15)]; + + public static Color Xterm256(int n) + { + if (n < 16) return Base16[n]; + if (n < 232) + { + n -= 16; + int r = n / 36, g = (n / 6) % 6, b = n % 6; + static int V(int x) => x == 0 ? 0 : 55 + x * 40; + return Color.FromArgb(V(r), V(g), V(b)); + } + int v = 8 + (n - 232) * 10; + return Color.FromArgb(v, v, v); + } +} diff --git a/src/ETTerms/Terminal/ScreenBuffer.cs b/src/ETTerms/Terminal/ScreenBuffer.cs new file mode 100644 index 0000000..b3156aa --- /dev/null +++ b/src/ETTerms/Terminal/ScreenBuffer.cs @@ -0,0 +1,263 @@ +using System.Drawing; + +namespace ETTerms.Terminal; + +[Flags] +public enum CellAttr : byte { None = 0, Bold = 1, Underline = 2, Inverse = 4 } + +public struct Cell +{ + public char Ch; + public Color Fg; + public Color Bg; + public CellAttr Attr; +} + +/// +/// 終端機字格緩衝:rows×cols 畫面 + scrollback + 滾動區(DECSTBM)+ alt screen。 +/// 由 驅動; 讀取繪製。座標 0-based。 +/// +public sealed class ScreenBuffer +{ + public int Rows { get; private set; } + public int Cols { get; private set; } + public Color DefaultFg, DefaultBg; + + // 目前畫筆(SGR) + public Color PenFg, PenBg; + public CellAttr PenAttr; + + public int CursorRow { get; private set; } + public int CursorCol { get; private set; } + public bool CursorVisible = true; + public bool AutoWrap = true; + + private Cell[][] _screen; + private readonly List _scrollback = new(); + private readonly int _maxScroll; + private int _top, _bottom; // 滾動區(含) + private bool _wrapPending; + + // 儲存游標(ESC 7 / CSI s) + private int _scx, _scy; private Color _sfg, _sbg; private CellAttr _sattr; + + // alt screen + private Cell[][]? _mainScreen; + public bool AltActive { get; private set; } + + public ScreenBuffer(int cols, int rows, Color fg, Color bg, int maxScrollback) + { + Cols = Math.Max(1, cols); Rows = Math.Max(1, rows); + DefaultFg = fg; DefaultBg = bg; PenFg = fg; PenBg = bg; + _maxScroll = maxScrollback; + _screen = NewGrid(Rows, Cols); + _top = 0; _bottom = Rows - 1; + } + + // ── 給 TerminalView 讀取 ───────────────────────────────── + public int ScrollbackCount => _scrollback.Count; + public int TotalRows => _scrollback.Count + Rows; + public Cell[] LineAt(int abs) => abs < _scrollback.Count ? _scrollback[abs] : _screen[abs - _scrollback.Count]; + + // ── 內部建構工具 ───────────────────────────────────────── + private Cell BlankPen() => new() { Ch = ' ', Fg = PenFg, Bg = PenBg, Attr = CellAttr.None }; + private Cell BlankDefault() => new() { Ch = ' ', Fg = DefaultFg, Bg = DefaultBg, Attr = CellAttr.None }; + private Cell[] BlankLine() { var l = new Cell[Cols]; for (int i = 0; i < Cols; i++) l[i] = BlankPen(); return l; } + + private Cell[][] NewGrid(int r, int c) + { + var g = new Cell[r][]; + for (int i = 0; i < r; i++) { g[i] = new Cell[c]; for (int j = 0; j < c; j++) g[i][j] = BlankDefault(); } + return g; + } + + private void PushScroll(Cell[] line) + { + _scrollback.Add(line); + if (_scrollback.Count > _maxScroll) _scrollback.RemoveRange(0, _scrollback.Count - _maxScroll); + } + + // ── 輸出字元 ───────────────────────────────────────────── + public void Print(char ch) + { + if (_wrapPending) { _wrapPending = false; CursorCol = 0; LineFeed(); } + if (CursorRow < 0 || CursorRow >= Rows) CursorRow = Math.Clamp(CursorRow, 0, Rows - 1); + _screen[CursorRow][CursorCol] = new Cell { Ch = ch, Fg = PenFg, Bg = PenBg, Attr = PenAttr }; + if (CursorCol >= Cols - 1) { if (AutoWrap) _wrapPending = true; } + else CursorCol++; + } + + // ── 游標 / 換行 ────────────────────────────────────────── + public void CarriageReturn() { _wrapPending = false; CursorCol = 0; } + + public void LineFeed() + { + _wrapPending = false; + if (CursorRow == _bottom) ScrollUp(1); + else if (CursorRow < Rows - 1) CursorRow++; + } + + public void ReverseLineFeed() + { + _wrapPending = false; + if (CursorRow == _top) ScrollDown(1); + else if (CursorRow > 0) CursorRow--; + } + + public void NextLine() { CarriageReturn(); LineFeed(); } + public void Backspace() { _wrapPending = false; if (CursorCol > 0) CursorCol--; } + public void Tab() { _wrapPending = false; CursorCol = Math.Min(Cols - 1, (CursorCol / 8 + 1) * 8); } + + public void MoveCursor(int row, int col) + { + _wrapPending = false; + CursorRow = Math.Clamp(row, 0, Rows - 1); + CursorCol = Math.Clamp(col, 0, Cols - 1); + } + public void CursorUp(int n) { _wrapPending = false; CursorRow = Math.Max(_top, CursorRow - n); } + public void CursorDown(int n) { _wrapPending = false; CursorRow = Math.Min(_bottom, CursorRow + n); } + public void CursorLeft(int n) { _wrapPending = false; CursorCol = Math.Max(0, CursorCol - n); } + public void CursorRight(int n) { _wrapPending = false; CursorCol = Math.Min(Cols - 1, CursorCol + n); } + public void SetColumn(int col) { _wrapPending = false; CursorCol = Math.Clamp(col, 0, Cols - 1); } + public void SetRow(int row) { _wrapPending = false; CursorRow = Math.Clamp(row, 0, Rows - 1); } + + public void SaveCursor() { _scx = CursorCol; _scy = CursorRow; _sfg = PenFg; _sbg = PenBg; _sattr = PenAttr; } + public void RestoreCursor() { CursorCol = _scx; CursorRow = _scy; PenFg = _sfg; PenBg = _sbg; PenAttr = _sattr; _wrapPending = false; } + + // ── 滾動 ───────────────────────────────────────────────── + public void SetScrollRegion(int top, int bottom) + { + _top = Math.Clamp(top, 0, Rows - 1); + _bottom = Math.Clamp(bottom, _top, Rows - 1); + MoveCursor(0, 0); + } + + public void ScrollUp(int n) + { + for (int k = 0; k < n; k++) + { + var line = _screen[_top]; + if (_top == 0 && !AltActive) PushScroll(line); + for (int r = _top; r < _bottom; r++) _screen[r] = _screen[r + 1]; + _screen[_bottom] = BlankLine(); + } + } + + public void ScrollDown(int n) + { + for (int k = 0; k < n; k++) + { + for (int r = _bottom; r > _top; r--) _screen[r] = _screen[r - 1]; + _screen[_top] = BlankLine(); + } + } + + public void InsertLines(int n) + { + if (CursorRow < _top || CursorRow > _bottom) return; + for (int k = 0; k < n; k++) + { + for (int r = _bottom; r > CursorRow; r--) _screen[r] = _screen[r - 1]; + _screen[CursorRow] = BlankLine(); + } + } + + public void DeleteLines(int n) + { + if (CursorRow < _top || CursorRow > _bottom) return; + for (int k = 0; k < n; k++) + { + for (int r = CursorRow; r < _bottom; r++) _screen[r] = _screen[r + 1]; + _screen[_bottom] = BlankLine(); + } + } + + // ── 行內字元操作 ───────────────────────────────────────── + public void InsertChars(int n) + { + var row = _screen[CursorRow]; + for (int c = Cols - 1; c >= CursorCol + n; c--) row[c] = row[c - n]; + for (int c = CursorCol; c < Math.Min(Cols, CursorCol + n); c++) row[c] = BlankPen(); + } + + public void DeleteChars(int n) + { + var row = _screen[CursorRow]; + for (int c = CursorCol; c < Cols; c++) row[c] = (c + n < Cols) ? row[c + n] : BlankPen(); + } + + public void EraseChars(int n) + { + var row = _screen[CursorRow]; + for (int c = CursorCol; c < Math.Min(Cols, CursorCol + n); c++) row[c] = BlankPen(); + } + + // ── 清除 ───────────────────────────────────────────────── + public void EraseInLine(int mode) + { + var row = _screen[CursorRow]; + int from = mode == 1 ? 0 : CursorCol; + int to = mode == 0 ? Cols - 1 : (mode == 1 ? CursorCol : Cols - 1); + for (int c = from; c <= to; c++) row[c] = BlankPen(); + } + + public void EraseInDisplay(int mode) + { + if (mode == 3) { _scrollback.Clear(); return; } + if (mode == 2) { for (int r = 0; r < Rows; r++) _screen[r] = BlankLine(); return; } + if (mode == 0) + { + EraseInLine(0); + for (int r = CursorRow + 1; r < Rows; r++) _screen[r] = BlankLine(); + } + else // mode 1 + { + EraseInLine(1); + for (int r = 0; r < CursorRow; r++) _screen[r] = BlankLine(); + } + } + + // ── alt screen ─────────────────────────────────────────── + public void EnterAlt() + { + if (AltActive) return; + _mainScreen = _screen; + _screen = NewGrid(Rows, Cols); + AltActive = true; + _top = 0; _bottom = Rows - 1; + MoveCursor(0, 0); + } + + public void ExitAlt() + { + if (!AltActive || _mainScreen == null) return; + _screen = _mainScreen; + _mainScreen = null; + AltActive = false; + _top = 0; _bottom = Rows - 1; + } + + public void ResetAttrs() { PenFg = DefaultFg; PenBg = DefaultBg; PenAttr = CellAttr.None; } + + // ── resize(簡單 reflow:保留左上重疊區)────────────────── + public void Resize(int cols, int rows) + { + cols = Math.Max(1, cols); rows = Math.Max(1, rows); + if (cols == Cols && rows == Rows) return; + var ng = new Cell[rows][]; + for (int r = 0; r < rows; r++) + { + ng[r] = new Cell[cols]; + for (int c = 0; c < cols; c++) + ng[r][c] = (r < Rows && c < Cols) ? _screen[r][c] : BlankDefault(); + } + _screen = ng; + Rows = rows; Cols = cols; + _top = 0; _bottom = Rows - 1; + CursorRow = Math.Clamp(CursorRow, 0, Rows - 1); + CursorCol = Math.Clamp(CursorCol, 0, Cols - 1); + _wrapPending = false; + _mainScreen = null; + if (AltActive) { /* alt 下 resize:重建 alt 畫面 */ } + } +} diff --git a/src/ETTerms/Terminal/TerminalInput.cs b/src/ETTerms/Terminal/TerminalInput.cs new file mode 100644 index 0000000..081eeaf --- /dev/null +++ b/src/ETTerms/Terminal/TerminalInput.cs @@ -0,0 +1,44 @@ +using System.Text; +using System.Windows.Forms; + +namespace ETTerms.Terminal; + +/// 把特殊鍵(方向鍵 / Fn / Home/End… / Enter/Tab/Backspace/Esc)對應成終端機 byte 序列。 +/// 一般可列印字元回傳 null,交給 KeyPress 處理。 +public static class TerminalInput +{ + public static byte[]? Map(KeyEventArgs e, bool appCursor) + { + string? seq = e.KeyCode switch + { + Keys.Up => appCursor ? "\x1bOA" : "\x1b[A", + Keys.Down => appCursor ? "\x1bOB" : "\x1b[B", + Keys.Right => appCursor ? "\x1bOC" : "\x1b[C", + Keys.Left => appCursor ? "\x1bOD" : "\x1b[D", + Keys.Home => "\x1b[H", + Keys.End => "\x1b[F", + Keys.Insert => "\x1b[2~", + Keys.Delete => "\x1b[3~", + Keys.PageUp => "\x1b[5~", + Keys.PageDown => "\x1b[6~", + Keys.Enter => "\r", + Keys.Tab => "\t", + Keys.Escape => "\x1b", + Keys.Back => "\x7f", + Keys.F1 => "\x1bOP", + Keys.F2 => "\x1bOQ", + Keys.F3 => "\x1bOR", + Keys.F4 => "\x1bOS", + Keys.F5 => "\x1b[15~", + Keys.F6 => "\x1b[17~", + Keys.F7 => "\x1b[18~", + Keys.F8 => "\x1b[19~", + Keys.F9 => "\x1b[20~", + Keys.F10 => "\x1b[21~", + Keys.F11 => "\x1b[23~", + Keys.F12 => "\x1b[24~", + _ => null + }; + return seq is null ? null : Encoding.ASCII.GetBytes(seq); + } +} diff --git a/src/ETTerms/Terminal/TerminalProfile.cs b/src/ETTerms/Terminal/TerminalProfile.cs new file mode 100644 index 0000000..13e83eb --- /dev/null +++ b/src/ETTerms/Terminal/TerminalProfile.cs @@ -0,0 +1,13 @@ +using ETTerms.Infrastructure; + +namespace ETTerms.Terminal; + +/// 終端機顯示偏好,從 AppSettings 載入。 +public sealed class TerminalProfile +{ + public string FontFamily { get; set; } = AppSettings.Instance.FontFamily; + public float FontSize { get; set; } = AppSettings.Instance.FontSize; + public int Cols { get; set; } = 80; + public int Rows { get; set; } = 24; + public int ScrollbackLines { get; set; } = AppSettings.Instance.ScrollbackLines; +} diff --git a/src/ETTerms/Terminal/TerminalView.cs b/src/ETTerms/Terminal/TerminalView.cs new file mode 100644 index 0000000..f6b62ed --- /dev/null +++ b/src/ETTerms/Terminal/TerminalView.cs @@ -0,0 +1,242 @@ +using System.Drawing; +using System.Text; +using System.Windows.Forms; + +namespace ETTerms.Terminal; + +/// +/// 自繪 VT100 終端機控制項:bytes → AnsiParser → ScreenBuffer → 雙緩衝繪製。 +/// 支援 scrollback(滑鼠滾輪)、選取 / 複製(Ctrl+C)/ 貼上(Ctrl+V、右鍵、Shift+Insert)、resize 通知。 +/// +public sealed class TerminalView : UserControl +{ + public event Action? SendData; // 鍵盤 / 貼上 → channel + public event Action? Resized; // cols, rows(PTY size) + + private readonly ScreenBuffer _buf; + private readonly AnsiParser _parser; + private readonly Font _font; + private int _cellW, _cellH; + private int _scrollOffset; // 0 = 貼底;>0 = 往上看 scrollback + private int _lastCols = -1, _lastRows = -1; + + // 選取(以絕對行 abs、欄 col 表示) + private bool _selecting; + private (int row, int col) _selStart, _selEnd; + private bool _hasSel; + + public TerminalView(TerminalProfile profile) + { + SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint + | ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true); + BackColor = Color.FromArgb(18, 18, 22); + _font = new Font(profile.FontFamily, profile.FontSize); + using (var g = CreateGraphics()) + { + var sz = TextRenderer.MeasureText(g, "W", _font, Size.Empty, TextFormatFlags.NoPadding); + _cellW = Math.Max(1, sz.Width); + _cellH = Math.Max(1, _font.Height); + } + _buf = new ScreenBuffer(profile.Cols, profile.Rows, + Color.FromArgb(220, 220, 220), BackColor, profile.ScrollbackLines); + _parser = new AnsiParser(_buf); + } + + /// 餵入遠端資料(須在 UI thread 呼叫)。 + public void Feed(byte[] data) + { + _parser.Feed(data); + _scrollOffset = 0; // 新輸出貼底 + Invalidate(); + } + + private int VisibleRows => Math.Max(1, ClientSize.Height / _cellH); + private int VisibleCols => Math.Max(1, ClientSize.Width / _cellW); + + // ── resize → 通知 PTY ──────────────────────────────────── + protected override void OnSizeChanged(EventArgs e) + { + base.OnSizeChanged(e); + int cols = VisibleCols, rows = VisibleRows; + if (cols == _lastCols && rows == _lastRows) return; + _lastCols = cols; _lastRows = rows; + _buf.Resize(cols, rows); + Resized?.Invoke(cols, rows); + Invalidate(); + } + + // ── 繪製 ───────────────────────────────────────────────── + protected override void OnPaint(PaintEventArgs e) + { + var g = e.Graphics; + g.Clear(BackColor); + int rows = VisibleRows; + int top = _buf.ScrollbackCount - _scrollOffset; // 視窗第一列的絕對 index + if (top < 0) top = 0; + + for (int vr = 0; vr < rows; vr++) + { + int abs = top + vr; + if (abs >= _buf.TotalRows) break; + DrawLine(g, _buf.LineAt(abs), vr, abs); + } + DrawCursor(g, top, rows); + } + + private void DrawLine(Graphics g, Cell[] line, int vr, int abs) + { + int y = vr * _cellH; + int c = 0; + while (c < line.Length) + { + var cell = line[c]; + ResolveColors(cell, out var fg, out var bg, abs, c); + // 合併同屬性連續格 + int start = c; + var sb = new StringBuilder(); + while (c < line.Length) + { + var cur = line[c]; + ResolveColors(cur, out var f2, out var b2, abs, c); + if (f2 != fg || b2 != bg || (cur.Attr & CellAttr.Bold) != (cell.Attr & CellAttr.Bold)) break; + sb.Append(cur.Ch == '\0' ? ' ' : cur.Ch); + c++; + } + var rect = new Rectangle(start * _cellW, y, (c - start) * _cellW, _cellH); + using (var bb = new SolidBrush(bg)) g.FillRectangle(bb, rect); + var style = (cell.Attr & CellAttr.Bold) != 0 ? FontStyle.Bold : FontStyle.Regular; + if ((cell.Attr & CellAttr.Underline) != 0) style |= FontStyle.Underline; + using var fnt = style == FontStyle.Regular ? _font : new Font(_font, style); + TextRenderer.DrawText(g, sb.ToString(), fnt, rect, fg, + TextFormatFlags.NoPadding | TextFormatFlags.NoPrefix | TextFormatFlags.Left); + } + } + + private void ResolveColors(Cell cell, out Color fg, out Color bg, int abs, int col) + { + fg = cell.Fg.A == 0 ? _buf.DefaultFg : cell.Fg; + bg = cell.Bg.A == 0 ? _buf.DefaultBg : cell.Bg; + if ((cell.Attr & CellAttr.Inverse) != 0) (fg, bg) = (bg, fg); + if (_hasSel && InSelection(abs, col)) (fg, bg) = (bg, Color.FromArgb(70, 90, 140)); + } + + private void DrawCursor(Graphics g, int top, int rows) + { + if (!_buf.CursorVisible || _scrollOffset != 0 || !Focused) return; + int vr = (_buf.ScrollbackCount + _buf.CursorRow) - top; + if (vr < 0 || vr >= rows) return; + var rect = new Rectangle(_buf.CursorCol * _cellW, vr * _cellH, _cellW, _cellH); + using var b = new SolidBrush(Color.FromArgb(160, 200, 200, 200)); + g.FillRectangle(b, rect); + } + + // ── 鍵盤 ───────────────────────────────────────────────── + protected override bool IsInputKey(Keys keyData) => true; // 攔截方向鍵 / Tab + + protected override void OnKeyDown(KeyEventArgs e) + { + if (e.Control && e.KeyCode == Keys.C && _hasSel) { CopySelection(); e.Handled = e.SuppressKeyPress = true; return; } + if ((e.Control && e.KeyCode == Keys.V) || (e.Shift && e.KeyCode == Keys.Insert)) { Paste(); e.Handled = e.SuppressKeyPress = true; return; } + + var bytes = TerminalInput.Map(e, _parser.AppCursorKeys); + if (bytes != null) { SendData?.Invoke(bytes); e.Handled = e.SuppressKeyPress = true; } + base.OnKeyDown(e); + } + + protected override void OnKeyPress(KeyPressEventArgs e) + { + if (!char.IsControl(e.KeyChar) || e.KeyChar is '\r' or '\t' or '\b' or '\x1b') + { + // \r,\t,\b,\x1b 已由 OnKeyDown 送出,這裡只送一般可列印字元與 Ctrl 組合碼 + } + if (e.KeyChar >= ' ' && e.KeyChar != '\x7f') + SendData?.Invoke(Encoding.UTF8.GetBytes(e.KeyChar.ToString())); + else if (char.IsControl(e.KeyChar) && e.KeyChar is not ('\r' or '\t' or '\b' or '\x1b')) + SendData?.Invoke(new[] { (byte)e.KeyChar }); // Ctrl+letter 控制碼 + e.Handled = true; + } + + // ── 滑鼠:scrollback / 選取 / 貼上 ─────────────────────── + protected override void OnMouseWheel(MouseEventArgs e) + { + int delta = e.Delta / 120 * 3; + _scrollOffset = Math.Clamp(_scrollOffset + delta, 0, _buf.ScrollbackCount); + Invalidate(); + } + + protected override void OnMouseDown(MouseEventArgs e) + { + Focus(); + if (e.Button == MouseButtons.Left) + { + _selecting = true; _hasSel = false; + _selStart = _selEnd = HitCell(e.Location); + Invalidate(); + } + else if (e.Button == MouseButtons.Right) + { + if (_hasSel) CopySelection(); else Paste(); + } + } + + protected override void OnMouseMove(MouseEventArgs e) + { + if (!_selecting) return; + _selEnd = HitCell(e.Location); + _hasSel = _selStart != _selEnd; + Invalidate(); + } + + protected override void OnMouseUp(MouseEventArgs e) => _selecting = false; + + private (int row, int col) HitCell(Point p) + { + int top = _buf.ScrollbackCount - _scrollOffset; + int row = top + Math.Clamp(p.Y / _cellH, 0, VisibleRows - 1); + int col = Math.Clamp(p.X / _cellW, 0, _buf.Cols); + return (row, col); + } + + private bool InSelection(int abs, int col) + { + var (a, b) = Ordered(); + if (abs < a.row || abs > b.row) return false; + if (abs == a.row && col < a.col) return false; + if (abs == b.row && col >= b.col) return false; + return true; + } + + private ((int row, int col) a, (int row, int col) b) Ordered() + { + var s = _selStart; var e = _selEnd; + bool sFirst = s.row < e.row || (s.row == e.row && s.col <= e.col); + return sFirst ? (s, e) : (e, s); + } + + private void CopySelection() + { + var (a, b) = Ordered(); + var sb = new StringBuilder(); + for (int abs = a.row; abs <= b.row && abs < _buf.TotalRows; abs++) + { + var line = _buf.LineAt(abs); + int from = abs == a.row ? a.col : 0; + int to = abs == b.row ? b.col : line.Length; + for (int c = from; c < Math.Min(to, line.Length); c++) + sb.Append(line[c].Ch == '\0' ? ' ' : line[c].Ch); + if (abs < b.row) sb.Append("\r\n"); + } + var text = sb.ToString(); + if (text.Length > 0) { try { Clipboard.SetText(text); } catch { } } + } + + private void Paste() + { + try + { + if (Clipboard.ContainsText()) + SendData?.Invoke(Encoding.UTF8.GetBytes(Clipboard.GetText().Replace("\r\n", "\r"))); + } + catch { } + } +} diff --git a/tools/scripts/test-echo.ttl b/tools/scripts/test-echo.ttl new file mode 100644 index 0000000..062f62d --- /dev/null +++ b/tools/scripts/test-echo.ttl @@ -0,0 +1,22 @@ +; test-echo.ttl — 最簡單的收發測試 +; 適用:Serial loopback(TX 接 RX)或任何會回顯的連線 +; 送出的字會被回顯回來,wait 命中後寫進 log。 + +prompt = '6000#' + +wait '6000 login:' +sendln 'admin' +wait 'Password: ' +sendln '' + +wait prompt +sendln 'show version' + +wait prompt +sendln 'show int b' + +wait prompt +sendln 'show int b' + + +messagebox 'Echo test done' diff --git a/tools/scripts/test-group.ttl b/tools/scripts/test-group.ttl new file mode 100644 index 0000000..ca9b2df --- /dev/null +++ b/tools/scripts/test-group.ttl @@ -0,0 +1,17 @@ + +prompt = '6000#' + +wait '6000 login:' +sendln 'admin' +wait 'Password: ' +sendln '' + +waitall prompt +sendlngroup A "show version" +sendlngroup B "show int b" +waitall prompt +sendlngroup A "show version" +sendlngroup B "show int b" +waitall prompt +sendlngroup A "show int b" +sendlngroup B "show version" diff --git a/tools/scripts/test-loop.ttl b/tools/scripts/test-loop.ttl new file mode 100644 index 0000000..51a20b6 --- /dev/null +++ b/tools/scripts/test-loop.ttl @@ -0,0 +1,17 @@ +; test-loop.ttl — while 迴圈 + 變數 + pause 測試 +; 不需遠端回應;觀察 Scripts 檢視的進度(行號/指令)與 log。 +; 執行中可按 Stop 中止。 + +logopen 'loop.log' + +idx = 0 +while idx < 5 + sendln 'echo loop' + logwrite 'iteration' + pause 1 + idx = idx + 1 +endwhile + +logwrite 'loop finished' +logclose +messagebox 'Loop test done' diff --git a/tools/scripts/test-ssh.ttl b/tools/scripts/test-ssh.ttl new file mode 100644 index 0000000..fe713d2 --- /dev/null +++ b/tools/scripts/test-ssh.ttl @@ -0,0 +1,19 @@ +; test-ssh.ttl — SSH 互動式自動化範例 +; 先在 Terminal 檢視開好 SSH 連線(已登入到 shell),再切到 Scripts 執行。 +; 依你的提示字元調整 wait 目標($ 一般使用者 / # root)。 + +timeout = 10 +flushrecv + +logopen 'ssh.log' + +sendln 'uname -a' +wait '$' +sendln 'uptime' +wait '$' +sendln 'whoami' +wait '$' + +logwrite 'commands sent' +logclose +messagebox 'SSH test done'