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)
This commit is contained in:
+16
@@ -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/
|
||||||
+535
@@ -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(手動確認)
|
||||||
|
# <TargetFramework>net8.0-windows</TargetFramework>
|
||||||
|
# <UseWindowsForms>true</UseWindowsForms>
|
||||||
|
# <Nullable>enable</Nullable>
|
||||||
|
|
||||||
|
# 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<byte[]> 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`。
|
||||||
@@ -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。
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<Solution>
|
||||||
|
<Folder Name="/src/">
|
||||||
|
<Project Path="src/ETTerms/ETTerms.csproj" />
|
||||||
|
</Folder>
|
||||||
|
</Solution>
|
||||||
@@ -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,目前尚未支援。
|
||||||
@@ -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 <host>` from cmd.
|
||||||
|
2. Check if SSH port (default 22) is open: `Test-NetConnection <host> -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 <ip>`
|
||||||
|
|
||||||
|
**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.
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
using System.Drawing;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Windows.Forms;
|
||||||
|
|
||||||
|
namespace ETTerms.App;
|
||||||
|
|
||||||
|
/// <summary>About page: app info (left) + changelog (right).</summary>
|
||||||
|
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<FlowLayoutPanel> 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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
using System.Drawing;
|
||||||
|
using System.Windows.Forms;
|
||||||
|
|
||||||
|
namespace ETTerms.App;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 左側圖示列(仿 KKTerm ActivityRail)。
|
||||||
|
/// 三個檢視:Terminal / Scripts / Settings。
|
||||||
|
/// 點擊會觸發 <see cref="ViewSelected"/>。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ActivityRail : UserControl
|
||||||
|
{
|
||||||
|
public enum RailView { Terminal, Settings, About }
|
||||||
|
|
||||||
|
public event EventHandler<RailView>? 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,663 @@
|
|||||||
|
using System.Drawing;
|
||||||
|
using System.Windows.Forms;
|
||||||
|
using ETTerms.App.Dialogs;
|
||||||
|
using ETTerms.Connections;
|
||||||
|
|
||||||
|
namespace ETTerms.App;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Saved Connections 側欄(仿 KKTerm):可自建資料夾、把連線分類進去。
|
||||||
|
/// 功能:搜尋、Quick Connect、新增資料夾 / 連線、改名、刪除、拖曳分類、展開 / 收合。
|
||||||
|
/// Phase 2:連線資料綁 <see cref="ConnectionStore"/>(SQLite)持久化,密碼走 <see cref="CredentialVault"/>。
|
||||||
|
/// 資料夾以連線的 GroupName('/'-join 路徑)持久化;空資料夾為 session-only。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ConnectionSidebar : UserControl
|
||||||
|
{
|
||||||
|
/// <summary>使用者啟用(雙擊 / Open / Quick Connect)一條連線時觸發。</summary>
|
||||||
|
public event EventHandler<Connection>? 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<Node> 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<Renci.SshNet.AuthenticationMethod>();
|
||||||
|
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<string>();
|
||||||
|
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<TreeNode>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>把資料夾子樹下所有連線的 GroupName 重設為現在的路徑並存檔。</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
using System.Drawing;
|
||||||
|
using System.IO.Ports;
|
||||||
|
using System.Windows.Forms;
|
||||||
|
using ETTerms.Connections;
|
||||||
|
|
||||||
|
namespace ETTerms.App.Dialogs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 連線新增 / 編輯對話框:依類型切換 SSH(host/port/user/auth/key/password)
|
||||||
|
/// 與 Serial(COM/baud/databits/parity/stopbits/handshake)表單。
|
||||||
|
/// 輸出 <see cref="Result"/>(Connection)與 <see cref="Password"/>(空 = 不變更)。
|
||||||
|
/// </summary>
|
||||||
|
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<Parity>(s?.Parity ?? Parity.None); _parity.SetBounds(208, 80, 192, 26);
|
||||||
|
|
||||||
|
var lblStop = MakeLabel("Stop Bits"); lblStop.Location = new Point(4, 112);
|
||||||
|
_stopBits = MakeEnumCombo<StopBits>(s?.StopBits ?? StopBits.One); _stopBits.SetBounds(4, 134, 196, 26);
|
||||||
|
|
||||||
|
var lblHand = MakeLabel("Handshake"); lblHand.Location = new Point(208, 112);
|
||||||
|
_handshake = MakeEnumCombo<Handshake>(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>(_parity.Text, out var p) ? p : Parity.None,
|
||||||
|
StopBits = Enum.TryParse<StopBits>(_stopBits.Text, out var sb) ? sb : StopBits.One,
|
||||||
|
Handshake = Enum.TryParse<Handshake>(_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>(TEnum selected) where TEnum : struct, Enum
|
||||||
|
{
|
||||||
|
var cb = MakeCombo(false);
|
||||||
|
cb.Items.AddRange(Enum.GetNames<TEnum>());
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
using System.Drawing;
|
||||||
|
using System.Windows.Forms;
|
||||||
|
using ETTerms.Infrastructure;
|
||||||
|
|
||||||
|
namespace ETTerms.App.Dialogs;
|
||||||
|
|
||||||
|
/// <summary>深色對話框基底:統一配色、深色標題列、固定大小、Enter/Esc。</summary>
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using System.Drawing;
|
||||||
|
using System.Windows.Forms;
|
||||||
|
|
||||||
|
namespace ETTerms.App.Dialogs;
|
||||||
|
|
||||||
|
/// <summary>單行文字輸入對話框(資料夾命名 / 改名用)。</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>顯示對話框;回傳輸入值,取消或空白回傳 null。</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+33
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
using System.Windows.Forms;
|
||||||
|
using ETTerms.App.Workspace;
|
||||||
|
using ETTerms.Infrastructure;
|
||||||
|
|
||||||
|
namespace ETTerms.App;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 主視窗:三欄佈局 = ActivityRail(左圖示列)+ ConnectionSidebar(連線清單)
|
||||||
|
/// + WorkspaceTabs(分頁工作區)。Phase 1 只做外殼,連線為佔位。
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
using System.Drawing;
|
||||||
|
using System.Windows.Forms;
|
||||||
|
using ETTerms.Infrastructure;
|
||||||
|
using ETTerms.Scripting.Pdu;
|
||||||
|
|
||||||
|
namespace ETTerms.App;
|
||||||
|
|
||||||
|
/// <summary>Settings page with tabs: Terminal / PDU.</summary>
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using System.Drawing;
|
||||||
|
|
||||||
|
namespace ETTerms.App;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 全域深色配色(KKTerm 風格)。Phase 1 先集中在這裡,
|
||||||
|
/// 後續 Phase 5 的 TerminalProfile / Settings 可覆寫。
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,413 @@
|
|||||||
|
using System.Drawing;
|
||||||
|
using System.Windows.Forms;
|
||||||
|
using ETTerms.Connections;
|
||||||
|
using ETTerms.Scripting;
|
||||||
|
using ETTerms.Sessions;
|
||||||
|
|
||||||
|
namespace ETTerms.App.Workspace;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 工作區(MobaXterm 風格):所有連線是頂部「分頁」,預設單一視圖(1×1)。
|
||||||
|
/// 點 Layout(1×1 / 1×2 / 2×1 / 2×2 / 2×3 / 3×3)會把已開好的分頁自動鋪進格子,
|
||||||
|
/// 每格一個 session(下方顯示標題)。無 per-pane 動作鈕,開分頁只能從側欄連線。
|
||||||
|
/// </summary>
|
||||||
|
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<Session> _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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>從側欄開啟連線:新增一個分頁並設為 active。</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
|
||||||
|
/// <summary>SSH 連線設定(Type == Ssh 時有效)。passphrase / password 走 CredentialVault。</summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Serial 連線設定(Type == Serial 時有效)。</summary>
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Local shell settings (Type == Shell).</summary>
|
||||||
|
public sealed class ShellSettings
|
||||||
|
{
|
||||||
|
public string ShellType { get; set; } = "PowerShell"; // "PowerShell", "Bash", "Cmd"
|
||||||
|
public string StartupDirectory { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>連線 metadata。明碼密碼絕不存這裡,只存指向 Credential Manager 的 <see cref="CredentialKey"/>。</summary>
|
||||||
|
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;
|
||||||
|
|
||||||
|
/// <summary>sidebar 顯示用摘要。</summary>
|
||||||
|
public string Detail => IsSsh
|
||||||
|
? $"{Ssh?.Host}:{Ssh?.Port}"
|
||||||
|
: $"{Serial?.PortName} @ {Serial?.BaudRate}";
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using ETTerms.Infrastructure;
|
||||||
|
|
||||||
|
namespace ETTerms.Connections;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 連線 metadata 的 SQLite 持久化(單表 Connections)。
|
||||||
|
/// 路徑:%LocalAppData%\ETTerms\ettermsdb.sqlite,首次使用自動建表。
|
||||||
|
/// 密碼不存這裡——只存 CredentialKey,明碼走 CredentialVault。
|
||||||
|
/// </summary>
|
||||||
|
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<Connection> GetAll()
|
||||||
|
{
|
||||||
|
var list = new List<Connection>();
|
||||||
|
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<SettingsBlob>(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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>新增或更新一條連線(以 Id 為鍵)。</summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text;
|
||||||
|
using ETTerms.Infrastructure;
|
||||||
|
|
||||||
|
namespace ETTerms.Connections;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Windows Credential Manager 封裝(advapi32 P/Invoke)。
|
||||||
|
/// 連線密碼 / passphrase 一律存這裡,SQLite 只存 CredentialKey。明碼絕不落地。
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
|
||||||
|
/// <summary>寫入 / 覆寫一筆密碼。</summary>
|
||||||
|
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); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>讀取密碼;不存在回傳 null。</summary>
|
||||||
|
public static string? Get(string key)
|
||||||
|
{
|
||||||
|
if (!CredRead(key, CRED_TYPE_GENERIC, 0, out IntPtr ptr)) return null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cred = Marshal.PtrToStructure<CREDENTIAL>(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); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>刪除密碼(不存在則忽略)。</summary>
|
||||||
|
public static void Delete(string key) => CredDelete(key, CRED_TYPE_GENERIC, 0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>WinExe</OutputType>
|
||||||
|
<TargetFramework>net8.0-windows</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<UseWindowsForms>true</UseWindowsForms>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<RootNamespace>ETTerms</RootNamespace>
|
||||||
|
<AssemblyName>ETTerms</AssemblyName>
|
||||||
|
|
||||||
|
<!-- 版本資訊 -->
|
||||||
|
<Version>0.1.0</Version>
|
||||||
|
<Product>ETTerms</Product>
|
||||||
|
<Company>ETTerms Project</Company>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.10" />
|
||||||
|
<PackageReference Include="SnmpSharpNet" Version="0.9.7">
|
||||||
|
<NoWarn>NU1701</NoWarn>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="SSH.NET" Version="2024.2.0" />
|
||||||
|
<PackageReference Include="System.IO.Ports" Version="8.0.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
|
||||||
|
namespace ETTerms.Infrastructure;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 應用程式 Logger(Phase 1 精簡版,移植自 MyTeraTerm.AppLogger)。
|
||||||
|
/// - 永遠寫入檔案:%LocalAppData%\ETTerms\logs\ETTerms_yyyyMMdd.log
|
||||||
|
/// - 同時輸出到 Debug(VS 輸出視窗)
|
||||||
|
/// 後續 Phase 可再補 Console 即時視窗 / log 輪替清理。
|
||||||
|
/// </summary>
|
||||||
|
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"; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
using System.Drawing;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace ETTerms.Infrastructure;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// User settings persisted to %LocalAppData%\ETTerms\settings.json.
|
||||||
|
/// Loaded once at startup; saved on change.
|
||||||
|
/// </summary>
|
||||||
|
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<AppSettings>(json) ?? new();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
return new();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Windows.Forms;
|
||||||
|
|
||||||
|
namespace ETTerms.Infrastructure;
|
||||||
|
|
||||||
|
/// <summary>Windows 原生外觀輔助(深色標題列)。</summary>
|
||||||
|
public static class NativeTheme
|
||||||
|
{
|
||||||
|
[DllImport("dwmapi.dll")]
|
||||||
|
private static extern int DwmSetWindowAttribute(IntPtr hwnd, int attr, ref int value, int size);
|
||||||
|
|
||||||
|
/// <summary>把視窗標題列改成深色(Windows 10 1809+ / 11)。</summary>
|
||||||
|
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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using ETTerms.App;
|
||||||
|
using ETTerms.Infrastructure;
|
||||||
|
|
||||||
|
namespace ETTerms;
|
||||||
|
|
||||||
|
static class Program
|
||||||
|
{
|
||||||
|
/// <summary>The main entry point for the application.</summary>
|
||||||
|
[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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
namespace ETTerms.Scripting;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Group 成員間的同步上下文。waitall 會等所有成員都到達同一個 barrier 後才繼續。
|
||||||
|
/// sendlnall 會等所有成員到達後,每個成員各自對自己的 channel 送出指令再繼續。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GroupSyncContext
|
||||||
|
{
|
||||||
|
private readonly int _memberCount;
|
||||||
|
private readonly Barrier _barrier;
|
||||||
|
|
||||||
|
public GroupSyncContext(int memberCount)
|
||||||
|
{
|
||||||
|
_memberCount = memberCount;
|
||||||
|
_barrier = new Barrier(memberCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>等待所有 group 成員到達此同步點。</summary>
|
||||||
|
public void WaitAll(CancellationToken ct)
|
||||||
|
{
|
||||||
|
_barrier.SignalAndWait(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
using System.Net;
|
||||||
|
using SnmpSharpNet;
|
||||||
|
|
||||||
|
namespace ETTerms.Scripting.Pdu;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PDU Controller for iPoMan II/III models via SNMP.
|
||||||
|
/// Ported from MyTeraTerm's PDUControlLib.
|
||||||
|
/// </summary>
|
||||||
|
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() { }
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
using ETTerms.Sessions;
|
||||||
|
|
||||||
|
namespace ETTerms.Scripting;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 非同步驅動 <see cref="TTLInterpreter"/>:背景執行緒執行腳本,
|
||||||
|
/// 轉發 StatusChanged / Output,並在結束時觸發 Finished。可隨時 <see cref="Cancel"/>。
|
||||||
|
/// 事件可能在背景執行緒觸發,UI 訂閱者需自行 marshal 回 UI thread。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ScriptRunner
|
||||||
|
{
|
||||||
|
private TTLInterpreter? _interp;
|
||||||
|
|
||||||
|
/// <summary>(檔名, 行號, 指令) 進度。</summary>
|
||||||
|
public event Action<string, int, string>? StatusChanged;
|
||||||
|
|
||||||
|
/// <summary>輸出訊息(log / 提示 / 錯誤)。</summary>
|
||||||
|
public event Action<string>? Output;
|
||||||
|
|
||||||
|
/// <summary>結束:(成功, 訊息)。</summary>
|
||||||
|
public event Action<bool, string>? 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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,565 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using ETTerms.Scripting.Pdu;
|
||||||
|
using ETTerms.Sessions;
|
||||||
|
|
||||||
|
namespace ETTerms.Scripting;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// TTL (Tera Term Language) 直譯器,移植自 MyTeraTerm,改驅動 <see cref="ISessionChannel"/>
|
||||||
|
/// (取代舊版的 ComPortBridge)。送出走 <c>channel.Write</c>,接收監聽 <c>channel.DataReceived</c>。
|
||||||
|
/// 支援:send / sendln / pause / wait / timeout / flushrecv / logopen / logwrite / logclose /
|
||||||
|
/// messagebox / if-elseif-else-endif / while-endwhile / 變數指派。
|
||||||
|
/// 阻塞執行(wait/pause 用 Thread.Sleep),由 <see cref="ScriptRunner"/> 放到背景執行緒驅動。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TTLInterpreter : IDisposable
|
||||||
|
{
|
||||||
|
private readonly ISessionChannel _channel;
|
||||||
|
private readonly Encoding _enc = new UTF8Encoding(false);
|
||||||
|
private readonly Dictionary<string, object> _vars = new();
|
||||||
|
private readonly StringBuilder _recv = new();
|
||||||
|
private readonly CancellationTokenSource _cts = new();
|
||||||
|
|
||||||
|
private GroupSyncContext? _groupSync;
|
||||||
|
private string _groupMemberLabel = "";
|
||||||
|
private readonly Dictionary<int, PduController> _pdus = new();
|
||||||
|
private StreamWriter? _logWriter;
|
||||||
|
private int _timeout; // 0 = 無限等待(wait 會一直等到關鍵字出現或被取消)
|
||||||
|
private int _result;
|
||||||
|
private string[] _lines = Array.Empty<string>();
|
||||||
|
private int _line;
|
||||||
|
private string _scriptFile = "";
|
||||||
|
private volatile bool _cancelled;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
/// <summary>(檔名, 行號, 當前指令) 進度更新。</summary>
|
||||||
|
public event Action<string, int, string>? StatusChanged;
|
||||||
|
|
||||||
|
/// <summary>輸出訊息(log / 提示 / 錯誤),供 UI 顯示。</summary>
|
||||||
|
public event Action<string>? 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<string>();
|
||||||
|
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>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>找出與 <paramref name="open"/> 配對的 <paramref name="close"/>(支援巢狀)。</summary>
|
||||||
|
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 <device> <ip>
|
||||||
|
var parts = args.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
if (parts.Length != 2) { Output?.Invoke("[pduconnect] syntax: pduconnect <device> <ip>"); _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 <device> <port> <action: 1=on, 0=off>
|
||||||
|
args = ReplaceVariables(args);
|
||||||
|
var parts = args.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
if (parts.Length != 3) { Output?.Invoke("[pductrl] syntax: pductrl <device> <port> <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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
namespace ETTerms.Sessions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Host key 指紋的 trust-on-first-use 記錄:%LocalAppData%\ETTerms\known_hosts.txt
|
||||||
|
/// 每行 "host:port SHA256base64"。
|
||||||
|
/// </summary>
|
||||||
|
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();
|
||||||
|
|
||||||
|
/// <summary>取回已信任指紋;無則 null。</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>記錄新信任的指紋。</summary>
|
||||||
|
public static void Set(string hostKey, string fingerprint)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(_path)!);
|
||||||
|
File.AppendAllText(_path, $"{hostKey} {fingerprint}{Environment.NewLine}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
namespace ETTerms.Sessions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 連線通道抽象:TerminalView / ScriptEngine 都只認得這個介面,
|
||||||
|
/// 因此 SSH 與 Serial 對上層一致(同一套腳本可驅動兩種連線)。
|
||||||
|
/// </summary>
|
||||||
|
public interface ISessionChannel : IDisposable
|
||||||
|
{
|
||||||
|
/// <summary>收到遠端資料(背景執行緒觸發,訂閱者需自行 Invoke 回 UI thread)。</summary>
|
||||||
|
event Action<byte[]>? DataReceived;
|
||||||
|
|
||||||
|
/// <summary>建立連線;失敗丟例外。</summary>
|
||||||
|
void Open();
|
||||||
|
|
||||||
|
/// <summary>送出位元組到遠端。</summary>
|
||||||
|
void Write(byte[] data);
|
||||||
|
|
||||||
|
/// <summary>通知遠端終端機大小(PTY size);Serial 為 no-op。</summary>
|
||||||
|
void Resize(int cols, int rows);
|
||||||
|
|
||||||
|
/// <summary>關閉連線並釋放底層資源。</summary>
|
||||||
|
void Close();
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
using System.IO.Ports;
|
||||||
|
using ETTerms.Connections;
|
||||||
|
using ETTerms.Infrastructure;
|
||||||
|
|
||||||
|
namespace ETTerms.Sessions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serial 連線通道:包 <see cref="SerialPort"/>。開啟前向 SessionManager 占用 COM port(互斥)。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SerialChannel : ISessionChannel
|
||||||
|
{
|
||||||
|
private readonly SerialPort _port;
|
||||||
|
private readonly string _portName;
|
||||||
|
private bool _opened;
|
||||||
|
|
||||||
|
public event Action<byte[]>? 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
namespace ETTerms.Sessions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 全域 session 登錄表 + Serial COM port 互斥管理。
|
||||||
|
/// 一個 COM port 同時只能被一個 session 開啟。
|
||||||
|
/// </summary>
|
||||||
|
public static class SessionManager
|
||||||
|
{
|
||||||
|
private static readonly object _lock = new();
|
||||||
|
private static readonly HashSet<string> _busyPorts = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private static readonly List<ISessionChannel> _active = new();
|
||||||
|
|
||||||
|
/// <summary>目前 active 的 session 快照。</summary>
|
||||||
|
public static IReadOnlyList<ISessionChannel> Active
|
||||||
|
{
|
||||||
|
get { lock (_lock) return _active.ToArray(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>嘗試占用 COM port;已被占用回 false。</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 一個連線分頁的內容:頂部腳本列(狀態 + 載入執行 / 停止)+ 自繪 VT100 <see cref="TerminalView"/>
|
||||||
|
/// + 綁定的 <see cref="ISessionChannel"/>。每個分頁各自對自己的 channel 跑 TTL 腳本。
|
||||||
|
/// channel 在 handle 建立後才 Open,確保 DataReceived 能安全 Invoke 回 UI thread。
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
|
||||||
|
/// <summary>底層連線通道。</summary>
|
||||||
|
public ISessionChannel Channel => _channel;
|
||||||
|
|
||||||
|
/// <summary>此分頁是否為 Serial 連線。</summary>
|
||||||
|
public bool IsSerial => _channel is SerialChannel;
|
||||||
|
|
||||||
|
/// <summary>目前是否正在跑腳本。</summary>
|
||||||
|
public bool IsScriptRunning => _runner.IsRunning;
|
||||||
|
|
||||||
|
/// <summary>所屬 Group(0=無, 1/2/3)。</summary>
|
||||||
|
public int Group { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Group 內的編號標籤,如 "Group1-A"。外部設定。</summary>
|
||||||
|
public string GroupLabel { get; set; } = "";
|
||||||
|
|
||||||
|
/// <summary>連線標題(COM名稱)。</summary>
|
||||||
|
public string Title => _title;
|
||||||
|
|
||||||
|
/// <summary>外部觸發腳本執行(供 Run All 使用)。</summary>
|
||||||
|
public async Task RunScriptAsync(string content, string fileName)
|
||||||
|
{
|
||||||
|
if (_runner.IsRunning) return;
|
||||||
|
SetRunning(true);
|
||||||
|
await _runner.RunAsync(content, fileName, _channel);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>以 Group 模式執行腳本(支援 waitall/sendlnall/sendlngroup 同步)。</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>同步開啟失敗(主要是 Serial 連不上 / 被占用)時觸發,附帶訊息。</summary>
|
||||||
|
public event Action<string>? 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text;
|
||||||
|
using ETTerms.Connections;
|
||||||
|
using Microsoft.Win32.SafeHandles;
|
||||||
|
|
||||||
|
namespace ETTerms.Sessions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<byte[]>? 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<STARTUPINFOEX>();
|
||||||
|
|
||||||
|
// 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()}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SSH 連線通道:SSH.NET SshClient + ShellStream,實作 <see cref="ISessionChannel"/>。
|
||||||
|
/// 支援 password / private key(passphrase 走 CredentialVault)/ keyboard-interactive;
|
||||||
|
/// host key 指紋 TOFU(首次記錄、之後比對、不符中止)。連線在背景執行緒進行避免凍結 UI。
|
||||||
|
/// </summary>
|
||||||
|
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<byte[]>? 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));
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
using System.Drawing;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace ETTerms.Terminal;
|
||||||
|
|
||||||
|
/// <summary>VT100 / 常見 ANSI escape 狀態機,驅動 <see cref="ScreenBuffer"/>。</summary>
|
||||||
|
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;
|
||||||
|
|
||||||
|
/// <summary>DECCKM:application cursor keys(影響方向鍵送出序列)。</summary>
|
||||||
|
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<int>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>ANSI 16 色 + xterm-256 色盤。</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 終端機字格緩衝:rows×cols 畫面 + scrollback + 滾動區(DECSTBM)+ alt screen。
|
||||||
|
/// 由 <see cref="AnsiParser"/> 驅動;<see cref="TerminalView"/> 讀取繪製。座標 0-based。
|
||||||
|
/// </summary>
|
||||||
|
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<Cell[]> _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 畫面 */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Windows.Forms;
|
||||||
|
|
||||||
|
namespace ETTerms.Terminal;
|
||||||
|
|
||||||
|
/// <summary>把特殊鍵(方向鍵 / Fn / Home/End… / Enter/Tab/Backspace/Esc)對應成終端機 byte 序列。
|
||||||
|
/// 一般可列印字元回傳 null,交給 KeyPress 處理。</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
using ETTerms.Infrastructure;
|
||||||
|
|
||||||
|
namespace ETTerms.Terminal;
|
||||||
|
|
||||||
|
/// <summary>終端機顯示偏好,從 AppSettings 載入。</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
using System.Drawing;
|
||||||
|
using System.Text;
|
||||||
|
using System.Windows.Forms;
|
||||||
|
|
||||||
|
namespace ETTerms.Terminal;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 自繪 VT100 終端機控制項:bytes → AnsiParser → ScreenBuffer → 雙緩衝繪製。
|
||||||
|
/// 支援 scrollback(滑鼠滾輪)、選取 / 複製(Ctrl+C)/ 貼上(Ctrl+V、右鍵、Shift+Insert)、resize 通知。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TerminalView : UserControl
|
||||||
|
{
|
||||||
|
public event Action<byte[]>? SendData; // 鍵盤 / 貼上 → channel
|
||||||
|
public event Action<int, int>? 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>餵入遠端資料(須在 UI thread 呼叫)。</summary>
|
||||||
|
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 { }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
@@ -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"
|
||||||
@@ -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'
|
||||||
@@ -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'
|
||||||
Reference in New Issue
Block a user