From c7f5bb15ff85b0ba54655548dd6a461a8f8de2d2 Mon Sep 17 00:00:00 2001 From: "yan.gao1" Date: Sun, 20 Sep 2026 19:01:51 +0800 Subject: [PATCH 1/5] Add Windows port: tray app + CLI sharing a C# engine - LeftOpen.Core: TCP listeners via GetExtendedTcpTable, process facts via WMI, working directories via PEB reads, project marker walk-up with rejection rules, ownership inference with confidence and reasons, and a three-stage gentle close (plan -> verify PID identity -> signal and poll) - LeftOpen.Tray: WinForms tray app with a door icon and an on-demand popup panel (scan on open, grouped rows, two-step close confirmation) - LeftOpen.Cli: leftopen list / / open / close / --json - Gentle close ladder on Windows: WM_CLOSE -> console Ctrl+C -> Ctrl+Break, with a self-protecting ctrl handler; never force-kills - 56 unit tests --- README.md | 12 + README.zh-CN.md | 12 + windows/.gitignore | 15 + windows/LeftOpen.sln | 50 ++ windows/README.md | 147 ++++ windows/Scripts/publish.ps1 | 38 + windows/src/LeftOpen.Cli/LeftOpen.Cli.csproj | 17 + windows/src/LeftOpen.Cli/Program.cs | 479 ++++++++++++ windows/src/LeftOpen.Core/CloseService.cs | 263 +++++++ .../src/LeftOpen.Core/LeftOpen.Core.csproj | 13 + windows/src/LeftOpen.Core/Models.cs | 94 +++ windows/src/LeftOpen.Core/OwnerInference.cs | 190 +++++ windows/src/LeftOpen.Core/ProcessCwdReader.cs | 174 +++++ windows/src/LeftOpen.Core/ProcessIdentity.cs | 115 +++ windows/src/LeftOpen.Core/ProcessTable.cs | 83 +++ .../src/LeftOpen.Core/ProcessTerminator.cs | 114 +++ windows/src/LeftOpen.Core/ProjectDetector.cs | 215 ++++++ windows/src/LeftOpen.Core/Scanner.cs | 157 ++++ windows/src/LeftOpen.Core/TcpTable.cs | 153 ++++ windows/src/LeftOpen.Tray/AppIcons.cs | 61 ++ windows/src/LeftOpen.Tray/DoorIcon.cs | 100 +++ windows/src/LeftOpen.Tray/GroupRowControl.cs | 246 +++++++ .../src/LeftOpen.Tray/LeftOpen.Tray.csproj | 18 + windows/src/LeftOpen.Tray/PanelForm.cs | 679 ++++++++++++++++++ windows/src/LeftOpen.Tray/Program.cs | 196 +++++ .../LeftOpen.Core.Tests/CloseServiceTests.cs | 318 ++++++++ .../LeftOpen.Core.Tests.csproj | 27 + .../LeftOpen.Core.Tests/OwnershipTests.cs | 173 +++++ .../ProjectDetectorTests.cs | 157 ++++ .../LeftOpen.Core.Tests/TcpTableTests.cs | 23 + 30 files changed, 4339 insertions(+) create mode 100644 windows/.gitignore create mode 100644 windows/LeftOpen.sln create mode 100644 windows/README.md create mode 100644 windows/Scripts/publish.ps1 create mode 100644 windows/src/LeftOpen.Cli/LeftOpen.Cli.csproj create mode 100644 windows/src/LeftOpen.Cli/Program.cs create mode 100644 windows/src/LeftOpen.Core/CloseService.cs create mode 100644 windows/src/LeftOpen.Core/LeftOpen.Core.csproj create mode 100644 windows/src/LeftOpen.Core/Models.cs create mode 100644 windows/src/LeftOpen.Core/OwnerInference.cs create mode 100644 windows/src/LeftOpen.Core/ProcessCwdReader.cs create mode 100644 windows/src/LeftOpen.Core/ProcessIdentity.cs create mode 100644 windows/src/LeftOpen.Core/ProcessTable.cs create mode 100644 windows/src/LeftOpen.Core/ProcessTerminator.cs create mode 100644 windows/src/LeftOpen.Core/ProjectDetector.cs create mode 100644 windows/src/LeftOpen.Core/Scanner.cs create mode 100644 windows/src/LeftOpen.Core/TcpTable.cs create mode 100644 windows/src/LeftOpen.Tray/AppIcons.cs create mode 100644 windows/src/LeftOpen.Tray/DoorIcon.cs create mode 100644 windows/src/LeftOpen.Tray/GroupRowControl.cs create mode 100644 windows/src/LeftOpen.Tray/LeftOpen.Tray.csproj create mode 100644 windows/src/LeftOpen.Tray/PanelForm.cs create mode 100644 windows/src/LeftOpen.Tray/Program.cs create mode 100644 windows/tests/LeftOpen.Core.Tests/CloseServiceTests.cs create mode 100644 windows/tests/LeftOpen.Core.Tests/LeftOpen.Core.Tests.csproj create mode 100644 windows/tests/LeftOpen.Core.Tests/OwnershipTests.cs create mode 100644 windows/tests/LeftOpen.Core.Tests/ProjectDetectorTests.cs create mode 100644 windows/tests/LeftOpen.Core.Tests/TcpTableTests.cs diff --git a/README.md b/README.md index 7540693..c5856dc 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,18 @@ LEFTOPEN_OUTPUT_DIR=dist/dev Scripts/build-app.sh --- +## Windows port + +A Windows port lives in [`windows/`](windows/README.md): a system-tray app plus a `leftopen` CLI that share one C# engine (`LeftOpen.Core`). It keeps the same philosophy and safety model — scan only when the panel opens, explainable ownership inference with confidence and reasons, and gentle close only (WM_CLOSE / console Ctrl+C / Ctrl+Break, never a force-kill). + +```powershell +dotnet build windows\LeftOpen.sln +windows\src\LeftOpen.Tray\bin\Debug\net8.0-windows\LeftOpenApp.exe # tray app +windows\src\LeftOpen.Cli\bin\Debug\net8.0-windows\leftopen.exe # CLI +``` + +--- + ## License [MIT License](LICENSE) diff --git a/README.zh-CN.md b/README.zh-CN.md index afc45a0..a134360 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -123,6 +123,18 @@ LEFTOPEN_OUTPUT_DIR=dist/dev Scripts/build-app.sh --- +## Windows 版 + +Windows 移植版位于 [`windows/`](windows/README.md):系统托盘应用 + `leftopen` 命令行,共用一个 C# 引擎(`LeftOpen.Core`)。理念与安全模型完全一致——面板打开时才扫描、归属推断带置信度与依据、只温和关闭绝不强杀(WM_CLOSE / 控制台 Ctrl+C / Ctrl+Break)。 + +```powershell +dotnet build windows\LeftOpen.sln +windows\src\LeftOpen.Tray\bin\Debug\net8.0-windows\LeftOpenApp.exe # 托盘应用 +windows\src\LeftOpen.Cli\bin\Debug\net8.0-windows\leftopen.exe # 命令行 +``` + +--- + ## 许可证 [MIT License](LICENSE) diff --git a/windows/.gitignore b/windows/.gitignore new file mode 100644 index 0000000..de00091 --- /dev/null +++ b/windows/.gitignore @@ -0,0 +1,15 @@ +# 构建产物 +bin/ +obj/ +dist/ +dist-*/ + +# 测试结果 +TestResults/ +*.trx + +# IDE +.vs/ +.idea/ +*.user +*.suo diff --git a/windows/LeftOpen.sln b/windows/LeftOpen.sln new file mode 100644 index 0000000..c5fc935 --- /dev/null +++ b/windows/LeftOpen.sln @@ -0,0 +1,50 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{544456D8-8129-4FAD-AD56-DB2E2C7430CC}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LeftOpen.Core", "src\LeftOpen.Core\LeftOpen.Core.csproj", "{89B0270A-D388-4543-8F02-313CFD214F71}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LeftOpen.Cli", "src\LeftOpen.Cli\LeftOpen.Cli.csproj", "{AA4897B1-4FED-4F6A-8CC6-0C153474F015}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LeftOpen.Tray", "src\LeftOpen.Tray\LeftOpen.Tray.csproj", "{424F7028-34BE-4810-B495-1920CD69AD40}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{4670D1DC-9A3F-4770-A065-9C5D9B7ACE4E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LeftOpen.Core.Tests", "tests\LeftOpen.Core.Tests\LeftOpen.Core.Tests.csproj", "{F3CBD148-FDE5-4123-9413-00426F82E4ED}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {89B0270A-D388-4543-8F02-313CFD214F71}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {89B0270A-D388-4543-8F02-313CFD214F71}.Debug|Any CPU.Build.0 = Debug|Any CPU + {89B0270A-D388-4543-8F02-313CFD214F71}.Release|Any CPU.ActiveCfg = Release|Any CPU + {89B0270A-D388-4543-8F02-313CFD214F71}.Release|Any CPU.Build.0 = Release|Any CPU + {AA4897B1-4FED-4F6A-8CC6-0C153474F015}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AA4897B1-4FED-4F6A-8CC6-0C153474F015}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AA4897B1-4FED-4F6A-8CC6-0C153474F015}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AA4897B1-4FED-4F6A-8CC6-0C153474F015}.Release|Any CPU.Build.0 = Release|Any CPU + {424F7028-34BE-4810-B495-1920CD69AD40}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {424F7028-34BE-4810-B495-1920CD69AD40}.Debug|Any CPU.Build.0 = Debug|Any CPU + {424F7028-34BE-4810-B495-1920CD69AD40}.Release|Any CPU.ActiveCfg = Release|Any CPU + {424F7028-34BE-4810-B495-1920CD69AD40}.Release|Any CPU.Build.0 = Release|Any CPU + {F3CBD148-FDE5-4123-9413-00426F82E4ED}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F3CBD148-FDE5-4123-9413-00426F82E4ED}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F3CBD148-FDE5-4123-9413-00426F82E4ED}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F3CBD148-FDE5-4123-9413-00426F82E4ED}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {89B0270A-D388-4543-8F02-313CFD214F71} = {544456D8-8129-4FAD-AD56-DB2E2C7430CC} + {AA4897B1-4FED-4F6A-8CC6-0C153474F015} = {544456D8-8129-4FAD-AD56-DB2E2C7430CC} + {424F7028-34BE-4810-B495-1920CD69AD40} = {544456D8-8129-4FAD-AD56-DB2E2C7430CC} + {F3CBD148-FDE5-4123-9413-00426F82E4ED} = {4670D1DC-9A3F-4770-A065-9C5D9B7ACE4E} + EndGlobalSection +EndGlobal diff --git a/windows/README.md b/windows/README.md new file mode 100644 index 0000000..c49c787 --- /dev/null +++ b/windows/README.md @@ -0,0 +1,147 @@ +# LeftOpen (Windows) + +> 把那些虚掩着的门,轻轻关上。 + +LeftOpen 是一个 Windows 系统托盘应用 + 命令行工具,用来**看清 localhost 上还有哪些进程在监听,并把不再需要的温和地关掉**。 + +它是 [SonghaiFan/leftopen](https://github.com/SonghaiFan/leftopen)(macOS 菜单栏应用)的 Windows 移植版:同一套理念、同一套安全规则、同样的“按需扫描、不做驻留”的行为,只是把菜单栏换成了系统托盘,把 `lsof`/`ps` 换成了 Windows 原生 API。 + +## 它解决什么问题 + +同时开几个编码代理、跑一堆 dev server 时,一天下来总有 `node`/`python` 还挂在 5173、8000、3000 上。Windows 自带的 `netstat` 只告诉你“端口被占用”,不告诉你**是哪个进程、属于哪个项目**。 + +LeftOpen 做三件事: + +1. **端口归属**:从进程的工作目录向上找 `.git` / `package.json` / `pyproject.toml` / `Cargo.toml` / `go.mod`,把监听者归类为**我的项目 / 应用 / 系统服务 / 未知**,并给出判定依据(confidence + reason)。 +2. **一眼看清**:托盘图标是一扇门(有遗留进程时门是开的),点开面板就是分组列表——端口、进程、运行时长、是否局域网可见。 +3. **温和关闭**:关闭前展示完整信息并要求二次确认;只发送“温和”信号,**绝不强杀**;关不掉就如实告诉你。 + +## 安装与构建 + +需要 [.NET 8 SDK](https://dotnet.microsoft.com/download)(`winget install Microsoft.DotNet.SDK.8`)。 + +```powershell +# 开发构建 +dotnet build LeftOpen.sln + +# 运行 +src\LeftOpen.Tray\bin\Debug\net8.0-windows\LeftOpenApp.exe # 托盘应用 +src\LeftOpen.Cli\bin\Debug\net8.0-windows\leftopen.exe # 命令行 + +# 发布(默认框架依赖单文件,约 700 KB;需要 .NET 8 运行时) +powershell -File Scripts\publish.ps1 + +# 发布自包含版本(目标机器无需安装 .NET,托盘约 158 MB——WinForms 不支持裁剪) +powershell -File Scripts\publish.ps1 -SelfContained +``` + +## 托盘应用 + +- 启动后只驻留一个托盘图标(门):**不扫描、不轮询、无守护进程**。 +- 点击图标 → 弹出面板 → **此刻才扫描一次**;再次点击或点击别处自动收起。 +- 面板分组:`我的项目` / `其他进程` / `不可关闭(系统或应用,默认折叠)`。 +- 每行显示:端口(多端口显示 `+N`)、进程图标、归属标签、命令与 PID、运行时长、`LAN` 徽标(局域网可见时)。 +- 悬停出现红色 `✕`,右键菜单:浏览器打开 / 复制端口 / 复制 PID / 关闭进程。 +- 点击行进入详情:完整证据链(进程、用户、可执行路径、工作目录、项目标记、判定依据)。 +- 关闭是**两步**:先看确认页(进程信息、其他端口警告、温和关闭说明),再点“关闭”。 +- 托盘右键菜单:刷新 / 开机自启动(默认关)/ 退出。 +- 再次启动 `LeftOpenApp.exe` 会唤起已运行实例的面板(可做快捷方式)。 + +## 命令行 + +```text +leftopen # 列出所有监听活动 +leftopen # 解释某个端口属于谁 +leftopen open # 在浏览器中打开 +leftopen close # 温和关闭端口上的进程 +leftopen --json # 机器可读输出 +``` + +选项:`--pid `(一个端口被多个进程持有时指定)、`--dry-run`(只预览不发信号)、`--yes`(跳过交互确认)、`--no-color`。 + +```text +$ leftopen 5173 +PORT 5173 · 1 listener +Owner: fdi_project_info_web +Type: project +Confidence: high +Process: node.exe +PID: 5252 +User: MOMENTA\yan.gao1 +Uptime: 2h 14m +Scope: LOCAL +Addresses: 127.0.0.1 +Executable: C:\nvm4w\nodejs\node.exe +CWD: ~\workspace\fdi_project_info_web\ +Marker: ~\workspace\fdi_project_info_web\.git +Reason: CWD is within a project root containing git at ...\.git. +``` + +## 工作原理:macOS → Windows 映射 + +| 原版(macOS) | 本移植版(Windows) | +|---|---| +| `lsof -iTCP -sTCP:LISTEN` | `GetExtendedTcpTable`(iphlpapi,netstat 同源的原生 API,自带 PID) | +| `ps -axo pid,ppid,comm` | 一次 WMI 查询拿到 PID/父 PID/可执行路径/命令行/启动时间 | +| `lsof -d cwd` 读工作目录 | 读目标进程 PEB 的 `RTL_USER_PROCESS_PARAMETERS.CurrentDirectory`(`NtQueryInformationProcess` + `ReadProcessMemory`) | +| `.app` 包识别应用 | 可执行文件位于 `Program Files` / `WindowsApps` / `%LOCALAPPDATA%\Programs` | +| 系统目录 `/usr/bin` 等 | `C:\Windows` 树 | +| `127.0.0.1` vs `0.0.0.0` | 同样区分 `LOCAL`(仅本机)/ `LAN`(局域网可见) | +| 只发 SIGTERM | 温和关闭阶梯:WM_CLOSE → 控制台 Ctrl+C → Ctrl+Break | +| 菜单栏 `MenuBarExtra` | 系统托盘 `NotifyIcon` + 弹出面板 | + +`LeftOpen.Core` 是共享引擎,托盘应用与 CLI 用的是**完全相同**的扫描、推断与关闭规则。 + +### 为什么关闭阶梯里要加 Ctrl+Break + +Windows 没有 SIGTERM,最接近的“温柔”做法是模拟用户在那个终端里按键: + +1. `WM_CLOSE` —— 有自己窗口的进程(GUI 程序)。 +2. `AttachConsole` + `CTRL_C_EVENT` —— 等同于在该进程的终端里按 Ctrl+C,dev server(node/vite/next)会走正常的清理逻辑。 +3. `CTRL_BREAK_EVENT` —— 由 PowerShell `Start-Process` 等方式启动的进程会被放进**新进程组**,新进程组默认忽略 Ctrl+C;Ctrl+Break 不受这个忽略模式影响,能可靠送达。 + +发送前,发送方会注册一个返回 `true` 的控制台事件处理器,确保这些事件不会把 LeftOpen 自己一起带走。 + +## 安全规则 + +关闭是**三段式**的,任何一步不满足就拒绝执行、绝不发信号: + +1. **准备**:拒绝系统进程(PID ≤ 4)、LeftOpen 自身、非当前用户(含 SYSTEM)的进程、可执行文件位于 `C:\Windows` 的进程、以及“装在 Program Files 里的应用本体”(例如 Spotify);共享运行时(node/python/dotnet…)不算应用本体,可以关闭。 +2. **复核**(防 PID 复用):发信号前重新扫描,核对目标 PID 仍在该端口监听、没有出现新的持有者、用户 SID / 可执行路径 / **启动时间**都与预览时一致——任何一项变化都判定为“PID 被换人”,放弃并明确报告“未发送任何信号”。 +3. **执行**:发送温和信号后轮询最多 5 秒确认端口释放,结构化返回 `targetStoppedListening` / `portFree` / `remainingPids` / `signalsDelivered`。 + +**从不强杀**(没有 `taskkill /F`,没有 `Process.Kill`)。进程不响应就如实报告;连温和信号都送不到(既无可达窗口也无控制台)时,也会明确说明“未送达,进程未受影响”。 + +## 已知限制 + +- 管理员 / 受保护进程的工作目录读不到(PEB 不可读),这些进程归类为“未知”,面板会在底部列出限制说明。 +- 32 位进程的 PEB 读取未做 WOW64 特化处理,失败时同样优雅降级。 +- 使用 `http.sys` 的程序(如 .NET `HttpListener`)在 TCP 表中归属 PID 4(SYSTEM),因此会被识别为系统进程、不可关闭——这是 Windows 的行为,不是误判。 +- `LAN` 只表示绑定地址不是回环地址,不代表防火墙后真的可达(与原版一致)。 +- 面板暂不支持深色主题跟随与搜索历史。 + +## 项目结构 + +本目录是原仓库 [SonghaiFan/leftopen](https://github.com/SonghaiFan/leftopen) 的 `windows-port` 分支上的 Windows 移植部分;macOS 的 Swift 源码与 TypeScript CLI 原型仍在仓库根的 `Sources/`、`src/`,未做改动。 + +``` +windows/ +├── LeftOpen.sln +├── src/LeftOpen.Core/ # 引擎:TCP 表、进程事实、PEB 工作目录、项目识别、归属推断、关闭服务 +├── src/LeftOpen.Tray/ # 托盘应用(WinForms):门图标、面板、两步确认 +├── src/LeftOpen.Cli/ # 命令行(leftopen.exe) +├── tests/LeftOpen.Core.Tests/ # 56 个单元测试(项目标记、排除规则、推断优先级、关闭规则) +└── Scripts/publish.ps1 +``` + +## 测试 + +```powershell +dotnet test +``` + +覆盖:项目标记向上查找与排除规则(`node_modules`、缓存、AppData、系统树、home 根、隐藏目录)、归属推断优先级与置信度、局域网判定、父进程链(环检测与深度上限)、关闭保护规则、PID 复用复核、端口被抢占的报告、信号未送达的如实报告,以及端口字节序解析的回归测试。 + +## 致谢与许可 + +设计与交互来自 [SonghaiFan/leftopen](https://github.com/SonghaiFan/leftopen)(MIT)。本移植版沿用同一理念与安全模型。 diff --git a/windows/Scripts/publish.ps1 b/windows/Scripts/publish.ps1 new file mode 100644 index 0000000..1e522ab --- /dev/null +++ b/windows/Scripts/publish.ps1 @@ -0,0 +1,38 @@ +# Builds release artifacts for LeftOpen on Windows. +# Usage: powershell -File Scripts\publish.ps1 [-OutputDir dist] [-SelfContained] +# +# Default: framework-dependent single-file executables (~1.5 MB each) — small and +# fast to start, but require the .NET 8 runtime (SDK or Desktop Runtime). +# -SelfContained: standalone executables that run on any Windows 10+ box without +# a runtime, at the cost of size (WinForms cannot be trimmed). + +param( + [string]$OutputDir = "dist", + [switch]$SelfContained +) + +$ErrorActionPreference = "Stop" +$repoRoot = Split-Path -Parent $PSScriptRoot +Set-Location $repoRoot + +$common = @("-c", "Release", "-r", "win-x64", "-p:PublishSingleFile=true") +if ($SelfContained) { + $common += "--self-contained", "true", "-p:IncludeNativeLibrariesForSelfExtract=true" +} +else { + $common += "--self-contained", "false" +} + +Write-Host "==> Publishing CLI (leftopen.exe)..." -ForegroundColor Cyan +dotnet publish "src\LeftOpen.Cli\LeftOpen.Cli.csproj" @common -o $OutputDir +if ($LASTEXITCODE -ne 0) { throw "CLI publish failed." } + +Write-Host "==> Publishing tray app (LeftOpenApp.exe)..." -ForegroundColor Cyan +dotnet publish "src\LeftOpen.Tray\LeftOpen.Tray.csproj" @common -o $OutputDir +if ($LASTEXITCODE -ne 0) { throw "Tray publish failed." } + +Write-Host "" +Write-Host "Done. Artifacts in ${OutputDir}:" -ForegroundColor Green +Get-ChildItem $OutputDir -Filter *.exe | ForEach-Object { + "{0,-20} {1,10:N0} KB" -f $_.Name, ($_.Length / 1KB) +} diff --git a/windows/src/LeftOpen.Cli/LeftOpen.Cli.csproj b/windows/src/LeftOpen.Cli/LeftOpen.Cli.csproj new file mode 100644 index 0000000..12773e5 --- /dev/null +++ b/windows/src/LeftOpen.Cli/LeftOpen.Cli.csproj @@ -0,0 +1,17 @@ + + + + + + + + Exe + leftopen + LeftOpen.Cli + net8.0-windows + enable + enable + 0.1.0 + + + diff --git a/windows/src/LeftOpen.Cli/Program.cs b/windows/src/LeftOpen.Cli/Program.cs new file mode 100644 index 0000000..cc53f57 --- /dev/null +++ b/windows/src/LeftOpen.Cli/Program.cs @@ -0,0 +1,479 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Security.Principal; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using LeftOpen.Core; +using Activity = LeftOpen.Core.Activity; + +namespace LeftOpen.Cli; + +internal static class Program +{ + private const int EnableVirtualTerminalProcessing = 0x0004; + + private static bool _colorEnabled; + + private static int Main(string[] arguments) + { + try + { + Console.OutputEncoding = Encoding.UTF8; + _colorEnabled = !Console.IsOutputRedirected && !arguments.Contains("--no-color") && + Environment.GetEnvironmentVariable("NO_COLOR") == null; + EnableAnsi(); + + return Run(arguments); + } + catch (CloseRefusedException ex) + { + Console.Error.WriteLine($"LeftOpen refused to close this port: {ex.Message}"); + return 1; + } + catch (Exception ex) + { + Console.Error.WriteLine($"LeftOpen could not scan this PC: {ex.Message}"); + return 1; + } + } + + private static int Run(string[] arguments) + { + var args = arguments.Where(arg => arg != "--no-color").ToArray(); + if (args.Contains("-h") || args.Contains("--help")) + { + Help(); + return 0; + } + + if (args.Contains("-v") || args.Contains("--version")) + { + Console.WriteLine(typeof(Program).Assembly.GetName().Version?.ToString(3) ?? "0.1.0"); + return 0; + } + + if (args.Length > 0 && args[0] == "close") + { + return RunClose(args[1..]).GetAwaiter().GetResult(); + } + + if (args.Length > 0 && args[0] == "open") + { + return RunOpen(args[1..]); + } + + return RunList(args); + } + + private static int RunList(string[] args) + { + var json = args.Contains("--json"); + var positional = args.Where(arg => arg != "--json").ToArray(); + if (positional.Length > 1 || (positional.Length == 1 && !IsPort(positional[0]))) + { + Console.Error.WriteLine("Usage: leftopen [port] [--json]"); + return 2; + } + + var port = positional.Length == 1 ? ParsePort(positional[0]) : null; + var result = Scanner.ScanActivities(); + + if (json) + { + var selected = port == null + ? result.Activities + : result.Activities.Where(a => a.Facts.Listener.Port == port).ToList(); + Console.WriteLine(JsonSerializer.Serialize( + new { activities = selected, limitations = result.Limitations }, + JsonOptions)); + return 0; + } + + if (port != null) + { + PrintDetail(port.Value, result.Activities); + } + else + { + PrintOverview(result.Activities); + } + + if (result.Limitations.Count > 0) + { + Console.WriteLine(Yellow($"Limited evidence: {string.Join(" ", result.Limitations)}")); + } + + return 0; + } + + private static int RunOpen(string[] args) + { + if (args.Length != 1 || !IsPort(args[0])) + { + Console.Error.WriteLine("Usage: leftopen open "); + return 2; + } + + var port = ParsePort(args[0])!.Value; + var url = $"http://localhost:{port}/"; + try + { + Process.Start(new ProcessStartInfo { FileName = url, UseShellExecute = true }); + Console.WriteLine(Green($"Opened {url} in your browser.")); + return 0; + } + catch (Exception ex) + { + Console.Error.WriteLine($"LeftOpen could not open {url}: {ex.Message}"); + return 1; + } + } + + private static async Task RunClose(string[] args) + { + var dryRun = args.Contains("--dry-run"); + var yes = args.Contains("--yes"); + int? selectedPid = null; + var positional = new List(); + + for (var i = 0; i < args.Length; i++) + { + if (args[i] == "--pid") + { + if (i + 1 >= args.Length || !int.TryParse(args[i + 1], out var pidValue)) + { + Console.Error.WriteLine("Usage: leftopen close [--pid ] [--dry-run] [--yes]"); + return 2; + } + + selectedPid = pidValue; + i++; + } + else if (args[i] is "--dry-run" or "--yes") + { + // handled above + } + else + { + positional.Add(args[i]); + } + } + + if (positional.Count != 1 || !IsPort(positional[0])) + { + Console.Error.WriteLine("Usage: leftopen close [--pid ] [--dry-run] [--yes]"); + return 2; + } + + var port = ParsePort(positional[0])!.Value; + var currentSid = WindowsIdentity.GetCurrent().User?.Value; + var service = new CloseService(); + + var plan = await service.PrepareCloseAsync(new CloseOptions(port, selectedPid, currentSid, Environment.ProcessId)); + + Console.WriteLine(); + Console.WriteLine($"{Bold($"CLOSE PORT {port}")} · PID {plan.Pid}"); + Console.WriteLine($"Owner: {plan.Activity.Inference.Label} ({plan.Activity.Inference.Confidence.ToString().ToLowerInvariant()} confidence)"); + Console.WriteLine($"Process: {plan.Activity.Facts.Process.Command}"); + Console.WriteLine($"Executable: {Ownership.CompactPath(plan.ExecutablePath)}"); + Console.WriteLine($"CWD: {Ownership.CompactPath(plan.Activity.Facts.Process.Cwd)}"); + Console.WriteLine($"Started: {plan.StartTime:yyyy-MM-dd HH:mm:ss} (uptime {DescribeDuration(DateTime.UtcNow - plan.StartTime.ToUniversalTime())})"); + if (plan.OtherPorts.Count > 0) + { + Console.WriteLine(Yellow($"Warning: the same PID also listens on {string.Join(", ", plan.OtherPorts)}; they may close too.")); + } + if (plan.PeerPids.Count > 0) + { + Console.WriteLine(Yellow($"Other PIDs also listen on this port: {string.Join(", ", plan.PeerPids)}. Only PID {plan.Pid} will be signalled.")); + } + + if (dryRun) + { + Console.WriteLine(Green("Dry run: no signal sent.")); + return 0; + } + + if (!yes) + { + if (Console.IsInputRedirected) + { + Console.Error.WriteLine("An interactive terminal is required; use --yes only when you intend to close this PID."); + return 1; + } + + Console.Write($"Close PID {plan.Pid} listening on port {port}? [y/N] "); + var answer = Console.ReadLine()?.Trim().ToLowerInvariant(); + if (answer is not ("y" or "yes")) + { + Console.WriteLine("Cancelled; no signal sent."); + return 0; + } + } + + Console.WriteLine(Dim("Sending a gentle close (WM_CLOSE / Ctrl+C / Ctrl+Break) and checking the port...")); + var result = await service.ExecuteCloseAsync(plan); + if (result.PortFree) + { + Console.WriteLine(Green($"Port {port} is now free.")); + return 0; + } + + if (result.TargetStoppedListening) + { + Console.WriteLine(Yellow($"PID {plan.Pid} stopped listening, but port {port} is now held by {string.Join(", ", result.RemainingPids)}.")); + return 1; + } + + if (!result.SignalsDelivered) + { + Console.WriteLine(Yellow( + $"No gentle close could be delivered to PID {plan.Pid} (it has no reachable window or console), so it was left running. " + + "No force-kill was attempted.")); + return 1; + } + + Console.WriteLine(Yellow($"A gentle close was sent, but PID {plan.Pid} still listens on port {port}. No force-kill was attempted.")); + return 1; + } + + private static void Help() + { + Console.WriteLine($""" + {Bold("LeftOpen")} — See what your tools left running on localhost. + + Usage: + leftopen Show all listening activity + leftopen Explain who owns a port + leftopen open Open a port in your browser + leftopen close Gracefully close the process listening on a port + leftopen --json Print machine-readable output + + Options: + --no-color Disable colours + --pid Select a PID when multiple processes share a port + --dry-run Preview a close without signalling anything + --yes Confirm a close without an interactive prompt + -h, --help Show this help + -v, --version Show the version + """); + } + + private static void PrintOverview(IReadOnlyList activities) + { + var portCount = activities.Select(a => a.Facts.Listener.Port).Distinct().Count(); + var processCount = activities.Select(a => a.Facts.Process.Pid).Distinct().Count(); + var projectCount = activities + .Where(a => a.Facts.ProjectMarker != null) + .Select(a => a.Facts.ProjectMarker!.Root) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Count(); + var lanCount = activities + .Where(a => a.Facts.Listener.Scope == Scope.Lan) + .Select(a => a.Facts.Listener.Port) + .Distinct() + .Count(); + + Console.WriteLine(); + Console.WriteLine(Bold("LEFT OPEN")); + Console.WriteLine( + $"{Cyan(portCount.ToString())} listening ports · {processCount} processes · " + + $"{projectCount} projects · {Yellow(lanCount.ToString())} LAN-visible"); + + var sections = new (Category Category, string Title)[] + { + (Category.Project, "MY PROJECTS"), + (Category.Application, "APPLICATIONS"), + (Category.SystemService, "SYSTEM SERVICES"), + (Category.Unknown, "UNKNOWN"), + }; + + foreach (var (category, title) in sections) + { + PrintSection(title, activities.Where(a => a.Inference.Category == category).ToList()); + } + + Console.WriteLine(Dim("\nLOCAL = this PC only · LAN = may be reachable from your local network\n")); + } + + private static void PrintSection(string title, IReadOnlyList activities) + { + if (activities.Count == 0) + { + return; + } + + Console.WriteLine(); + Console.WriteLine($"{Bold(title)} {Dim($"({activities.Count})")}"); + Console.WriteLine(Dim($"{Pad("PORT", 8)}{Pad("PID", 9)}{Pad("OWNER", 29)}{Pad("PROCESS", 25)}SCOPE")); + + foreach (var activity in activities) + { + var facts = activity.Facts; + Console.WriteLine( + $"{Pad(facts.Listener.Port, 8)}{Pad(facts.Process.Pid, 9)}{Pad(Trim(activity.Inference.Label, 27), 29)}" + + $"{Pad(Trim(facts.Process.Command, 23), 25)}{ScopeLabel(activity)}"); + if (facts.ProjectMarker is { } marker) + { + Console.WriteLine(Dim($" ↳ {Ownership.CompactPath(marker.Root)}")); + } + } + } + + private static void PrintDetail(int port, IReadOnlyList activities) + { + var matches = activities.Where(a => a.Facts.Listener.Port == port).ToList(); + if (matches.Count == 0) + { + Console.WriteLine($"\n{Green("FREE")} Nothing is listening on port {port}.\n"); + return; + } + + Console.WriteLine($"\n{Bold($"PORT {port}")} · {matches.Count} listener{(matches.Count == 1 ? "" : "s")}"); + for (var index = 0; index < matches.Count; index++) + { + var activity = matches[index]; + var facts = activity.Facts; + if (index > 0) + { + Console.WriteLine(Dim(new string('─', 56))); + } + + var started = facts.Process.StartTime; + var uptime = started != null + ? DescribeDuration(DateTime.UtcNow - started.Value.ToUniversalTime()) + : null; + + Console.WriteLine($"Owner: {activity.Inference.Label}"); + Console.WriteLine($"Type: {activity.Inference.Category.ToString().ToLowerInvariant()}"); + Console.WriteLine($"Confidence: {activity.Inference.Confidence.ToString().ToLowerInvariant()}"); + Console.WriteLine($"Process: {facts.Process.Command}"); + Console.WriteLine($"PID: {facts.Process.Pid}"); + Console.WriteLine($"PPID: {facts.Process.ParentPid?.ToString() ?? "unknown"}"); + Console.WriteLine($"User: {facts.Process.OwnerName ?? facts.Process.OwnerSid ?? "unknown"}"); + Console.WriteLine($"Uptime: {uptime ?? "unknown"}"); + Console.WriteLine($"Scope: {ScopeLabel(activity)}"); + Console.WriteLine($"Addresses: {string.Join(", ", facts.Listener.Addresses)}"); + Console.WriteLine($"Executable: {(facts.Process.ExecutablePath != null ? Ownership.CompactPath(facts.Process.ExecutablePath) : "unknown")}"); + Console.WriteLine($"CWD: {Ownership.CompactPath(facts.Process.Cwd)}"); + if (facts.ProjectMarker is { } marker) + { + Console.WriteLine($"Marker: {Ownership.CompactPath(marker.MarkerPath)}"); + } + if (facts.InstalledApp is { } app) + { + Console.WriteLine($"App path: {Ownership.CompactPath(app.Path)}"); + } + if (facts.ParentChain.Count > 0) + { + Console.WriteLine($"Parents: {string.Join(" → ", facts.ParentChain.Select(parent => $"{parent.Command} ({parent.Pid})"))}"); + } + Console.WriteLine($"Reason: {activity.Inference.Reason}"); + } + + Console.WriteLine(); + } + + internal static string DescribeDuration(TimeSpan span) + { + if (span.TotalMinutes < 1) + { + return $"{(int)span.TotalSeconds}s"; + } + + if (span.TotalHours < 1) + { + return $"{(int)span.TotalMinutes}m"; + } + + if (span.TotalDays < 1) + { + return $"{(int)span.TotalHours}h {(int)span.TotalMinutes % 60}m"; + } + + return $"{(int)span.TotalDays}d {(int)span.TotalHours % 24}h"; + } + + private static string ScopeLabel(Activity activity) => + activity.Facts.Listener.Scope == Scope.Local ? Green("LOCAL") : Yellow("LAN"); + + private static bool IsPort(string value) => int.TryParse(value, out var port) && port is >= 1 and <= 65535; + + private static int? ParsePort(string value) => IsPort(value) ? int.Parse(value) : null; + + private static string Pad(object value, int width) + { + var text = value.ToString() ?? ""; + // Windows dynamic ports (e.g. 54321) regularly overflow the column; keep + // at least one space after the cell so columns never run together. + return text.Length >= width ? text + " " : text + new string(' ', width - text.Length); + } + + private static string Trim(string value, int width) => + value.Length <= width ? value : value[..(width - 1)] + "…"; + + private static string Paint(int code, string text) => _colorEnabled ? $"\u001b[{code}m{text}\u001b[0m" : text; + + private static string Bold(string text) => Paint(1, text); + + private static string Dim(string text) => Paint(2, text); + + private static string Green(string text) => Paint(32, text); + + private static string Yellow(string text) => Paint(33, text); + + private static string Cyan(string text) => Paint(36, text); + + private static void EnableAnsi() + { + if (!_colorEnabled || Console.IsOutputRedirected) + { + return; + } + + var handle = GetStdHandle(StdOutputHandle); + if (GetConsoleMode(handle, out var mode)) + { + SetConsoleMode(handle, mode | EnableVirtualTerminalProcessing); + } + } + + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = { new JsonStringEnumConverter(KebabCaseNamingPolicy.Instance) }, + }; + + private sealed class KebabCaseNamingPolicy : JsonNamingPolicy + { + public static readonly KebabCaseNamingPolicy Instance = new(); + + public override string ConvertName(string name) + { + var result = new StringBuilder(name.Length + 4); + foreach (var c in name) + { + if (char.IsUpper(c) && result.Length > 0) + { + result.Append('-'); + } + + result.Append(char.ToLowerInvariant(c)); + } + + return result.ToString(); + } + } + + private const int StdOutputHandle = -11; + + [DllImport("kernel32.dll")] + private static extern IntPtr GetStdHandle(int nStdHandle); + + [DllImport("kernel32.dll")] + private static extern bool GetConsoleMode(IntPtr handle, out uint mode); + + [DllImport("kernel32.dll")] + private static extern bool SetConsoleMode(IntPtr handle, uint mode); +} diff --git a/windows/src/LeftOpen.Core/CloseService.cs b/windows/src/LeftOpen.Core/CloseService.cs new file mode 100644 index 0000000..a537493 --- /dev/null +++ b/windows/src/LeftOpen.Core/CloseService.cs @@ -0,0 +1,263 @@ +using System.Diagnostics; + +namespace LeftOpen.Core; + +public sealed record CloseOptions(int Port, int? Pid, string? CurrentUserSid, int CurrentPid); + +public sealed record ClosePlan( + int Port, + int Pid, + string OwnerSid, + string ExecutablePath, + DateTime StartTime, + Activity Activity, + IReadOnlyList OtherPorts, + IReadOnlyList PeerPids); + +public sealed record CloseResult( + bool TargetStoppedListening, + bool PortFree, + IReadOnlyList RemainingPids, + bool SignalsDelivered = true); + +public sealed class CloseRefusedException(string message) : Exception(message); + +/// +/// Leftopen's three-stage close, ported to Windows: prepare a plan from a snapshot, +/// re-verify identity (PID still on the port, no new peers, same owner SID, same +/// executable, same start time — guarding against PID reuse) immediately before +/// signalling, then send the gentle close and poll for the port to be released. +/// Refusals throw and never signal anything; +/// a stubborn process is reported honestly rather than force-killed. +/// +public sealed class CloseService +{ + private readonly Func _scan; + private readonly Func> _listeners; + private readonly Func _startTime; + private readonly Func _signal; + private readonly Func _wait; + + public CloseService( + Func? scan = null, + Func>? listeners = null, + Func? startTime = null, + Func? signal = null, + Func? wait = null) + { + _scan = scan ?? Scanner.ScanActivities; + _listeners = listeners ?? Scanner.ScanListeners; + _startTime = startTime ?? ProcessTable.QueryStartTime; + _signal = signal ?? ProcessTerminator.TrySendGentleClose; + _wait = wait ?? (ms => Task.Delay(ms)); + } + + /// + /// Non-throwing variant of the protection rules, so the panel can decide which + /// rows are closable and why, using the exact same policy as SelectCloseTarget. + /// + public static bool DescribeClosability(Activity activity, string? currentUserSid, int currentPid, out string? reason) + { + var target = activity.Facts.Process; + reason = null; + + if (target.Pid <= 4 || target.Pid == currentPid) + { + reason = "PID is a system process or LeftOpen itself."; + return false; + } + + if (target.OwnerSid == null) + { + reason = "Owner could not be verified (elevated or protected process)."; + return false; + } + + if (!string.Equals(target.OwnerSid, currentUserSid, StringComparison.Ordinal)) + { + reason = "Belongs to another user or SYSTEM."; + return false; + } + + if (string.IsNullOrEmpty(target.ExecutablePath)) + { + reason = "Executable path could not be verified."; + return false; + } + + if (Ownership.IsSystemExecutable(target.ExecutablePath)) + { + reason = "Runs from an operating-system executable location."; + return false; + } + + if (activity.Facts.ProjectMarker == null && + activity.Facts.InstalledApp is { DirectProcess: true } app && + !Ownership.IsRuntimeHost(target.ExecutablePath)) + { + reason = $"Is the installed application \"{app.Name}\"; closing it could disrupt the app."; + return false; + } + + return true; + } + + public (Activity Activity, IReadOnlyList OtherPorts, IReadOnlyList PeerPids) SelectCloseTarget( + IReadOnlyList activities, + CloseOptions options) + { + var matches = activities.Where(activity => activity.Facts.Listener.Port == options.Port).ToList(); + if (matches.Count == 0) + { + throw new CloseRefusedException($"Nothing is listening on port {options.Port}."); + } + + var pids = matches.Select(activity => activity.Facts.Process.Pid).Distinct().ToList(); + if (options.Pid == null && pids.Count > 1) + { + throw new CloseRefusedException($"Port {options.Port} has multiple owning PIDs ({string.Join(", ", pids)}); specify --pid."); + } + + var pid = options.Pid ?? pids[0]; + var activity = matches.FirstOrDefault(candidate => candidate.Facts.Process.Pid == pid); + if (activity == null) + { + throw new CloseRefusedException($"PID {pid} is not listening on port {options.Port}."); + } + + var target = activity.Facts.Process; + if (pid <= 4 || pid == options.CurrentPid) + { + throw new CloseRefusedException($"PID {pid} is protected."); + } + + if (target.OwnerSid == null) + { + throw new CloseRefusedException($"PID {pid} has no verified owner SID; refusing to close it."); + } + + if (options.CurrentUserSid == null) + { + throw new CloseRefusedException("LeftOpen cannot verify the current user's SID on this system."); + } + + if (!string.Equals(target.OwnerSid, options.CurrentUserSid, StringComparison.Ordinal)) + { + throw new CloseRefusedException($"PID {pid} belongs to another user or SYSTEM; refusing to close it."); + } + + if (string.IsNullOrEmpty(target.ExecutablePath)) + { + throw new CloseRefusedException($"PID {pid} has no verified executable path; refusing to close it."); + } + + if (Ownership.IsSystemExecutable(target.ExecutablePath)) + { + throw new CloseRefusedException($"PID {pid} runs from an operating-system executable location; refusing to close it."); + } + + if (activity.Facts.ProjectMarker == null && + activity.Facts.InstalledApp is { DirectProcess: true } app && + !Ownership.IsRuntimeHost(target.ExecutablePath)) + { + throw new CloseRefusedException( + $"PID {pid} is the installed application \"{app.Name}\" ({app.Path}); closing it could disrupt the app."); + } + + var otherPorts = activities + .Where(candidate => candidate.Facts.Process.Pid == pid && candidate.Facts.Listener.Port != options.Port) + .Select(candidate => candidate.Facts.Listener.Port) + .Distinct() + .OrderBy(port => port) + .ToList(); + + return (activity, otherPorts, pids.Where(candidate => candidate != pid).ToList()); + } + + public async Task PrepareCloseAsync(CloseOptions options) + { + var snapshot = await Task.Run(_scan); + var (activity, otherPorts, peerPids) = SelectCloseTarget(snapshot.Activities, options); + var target = activity.Facts.Process; + + var startTime = _startTime(target.Pid); + if (startTime == null) + { + throw new CloseRefusedException($"PID {target.Pid} has no verified start time; refusing to close it."); + } + + return new ClosePlan( + options.Port, + target.Pid, + target.OwnerSid!, + target.ExecutablePath!, + startTime.Value, + activity, + otherPorts, + peerPids); + } + + public void VerifyCloseTarget(ClosePlan plan, IReadOnlyList activities, DateTime? startTime) + { + var matches = activities.Where(activity => activity.Facts.Listener.Port == plan.Port).ToList(); + var matching = matches.FirstOrDefault(activity => activity.Facts.Process.Pid == plan.Pid); + if (matching == null) + { + throw new CloseRefusedException($"PID {plan.Pid} no longer listens on port {plan.Port}; nothing was signalled."); + } + + var freshPeers = matches + .Select(activity => activity.Facts.Process.Pid) + .Where(pid => pid != plan.Pid) + .Distinct() + .ToList(); + if (freshPeers.Any(pid => !plan.PeerPids.Contains(pid))) + { + throw new CloseRefusedException($"Port {plan.Port} acquired a new owning PID; nothing was signalled."); + } + + var target = matching.Facts.Process; + if (startTime == null || + startTime.Value != plan.StartTime || + !string.Equals(target.OwnerSid, plan.OwnerSid, StringComparison.Ordinal) || + !string.Equals(target.ExecutablePath, plan.ExecutablePath, StringComparison.OrdinalIgnoreCase)) + { + throw new CloseRefusedException($"PID {plan.Pid} changed identity since the preview; nothing was signalled."); + } + + if (Ownership.IsSystemExecutable(target.ExecutablePath) || + (matching.Facts.ProjectMarker == null && + matching.Facts.InstalledApp is { DirectProcess: true } freshApp && + !Ownership.IsRuntimeHost(target.ExecutablePath))) + { + throw new CloseRefusedException($"PID {plan.Pid} is now a protected process; nothing was signalled."); + } + } + + public async Task ExecuteCloseAsync(ClosePlan plan) + { + var fresh = await Task.Run(_scan); + var startTime = _startTime(plan.Pid); + VerifyCloseTarget(plan, fresh.Activities, startTime); + + var delivered = _signal(plan.Pid); + + var latest = new List(); + for (var attempt = 0; attempt < 10; attempt++) + { + await _wait(500); + latest = _listeners().Where(listener => listener.Port == plan.Port).ToList(); + if (latest.All(listener => listener.Pid != plan.Pid)) + { + break; + } + } + + var remainingPids = latest.Select(listener => listener.Pid).Distinct().ToList(); + return new CloseResult( + TargetStoppedListening: !remainingPids.Contains(plan.Pid), + PortFree: remainingPids.Count == 0, + RemainingPids: remainingPids, + SignalsDelivered: delivered); + } +} diff --git a/windows/src/LeftOpen.Core/LeftOpen.Core.csproj b/windows/src/LeftOpen.Core/LeftOpen.Core.csproj new file mode 100644 index 0000000..b03b73e --- /dev/null +++ b/windows/src/LeftOpen.Core/LeftOpen.Core.csproj @@ -0,0 +1,13 @@ + + + + net8.0-windows + enable + enable + + + + + + + diff --git a/windows/src/LeftOpen.Core/Models.cs b/windows/src/LeftOpen.Core/Models.cs new file mode 100644 index 0000000..f0d9905 --- /dev/null +++ b/windows/src/LeftOpen.Core/Models.cs @@ -0,0 +1,94 @@ +namespace LeftOpen.Core; + +public enum Category +{ + Project, + Application, + SystemService, + Unknown, +} + +public enum Confidence +{ + High, + Medium, + None, +} + +public enum Scope +{ + Local, + Lan, +} + +/// A single process listening on a single TCP port, with all bound addresses. +public sealed record Listener( + int Pid, + string Command, + string? OwnerSid, + string? OwnerName, + int Port, + IReadOnlyList Addresses); + +/// Process-table evidence for one PID, independent of any listener. +public sealed record ProcessFact( + int Pid, + int? ParentPid, + string Command, + string? ExecutablePath, + string? CommandLine, + DateTime? StartTime) +{ + public static ProcessFact Unknown(int pid) => new(pid, null, "unknown", null, null, null); +} + +/// A detected project root that a process's working directory lives inside. +public sealed record ProjectMarkerFact( + string Name, + string Root, + string Source, + string MarkerPath); + +/// An installed Windows application (Program Files / WindowsApps / per-user Programs). +public sealed record InstalledAppFact( + string Name, + string Path, + int SourcePid, + bool DirectProcess); + +public sealed record ListenerFact( + int Port, + IReadOnlyList Addresses, + Scope Scope); + +public sealed record ActivityProcessFact( + int Pid, + int? ParentPid, + string Command, + string? ExecutablePath, + string? CommandLine, + DateTime? StartTime, + string? OwnerSid, + string? OwnerName, + string? Cwd); + +public sealed record ActivityFacts( + ListenerFact Listener, + ActivityProcessFact Process, + IReadOnlyList ParentChain, + ProjectMarkerFact? ProjectMarker, + InstalledAppFact? InstalledApp); + +public sealed record OwnerInference( + string Label, + Category Category, + Confidence Confidence, + string Reason); + +public sealed record Activity( + ActivityFacts Facts, + OwnerInference Inference); + +public sealed record ScanResult( + IReadOnlyList Activities, + IReadOnlyList Limitations); diff --git a/windows/src/LeftOpen.Core/OwnerInference.cs b/windows/src/LeftOpen.Core/OwnerInference.cs new file mode 100644 index 0000000..b44e963 --- /dev/null +++ b/windows/src/LeftOpen.Core/OwnerInference.cs @@ -0,0 +1,190 @@ +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; + +namespace LeftOpen.Core; + +/// +/// Turns collected facts into an owner inference — project > installed application > +/// system service > unknown — with a confidence and a human-readable reason, mirroring +/// leftopen's inferOwner. +/// +public static class Ownership +{ + /// + /// Shared language runtimes that live in Program Files but are not applications + /// themselves (Windows puts node/python/etc. there, unlike macOS homebrew paths). + /// Closing them does not "disrupt an app", so they stay eligible for gentle close. + /// + private static readonly HashSet RuntimeHostNames = new(StringComparer.OrdinalIgnoreCase) + { + "node", "deno", "bun", "npm", + "python", "pythonw", "python3", "py", + "dotnet", "java", "javaw", "ruby", "php", "perl", + "go", "air", "cargo", "watchexec", "gradle", + }; + + public static bool IsRuntimeHost(string? executablePath) => + !string.IsNullOrEmpty(executablePath) && + RuntimeHostNames.Contains(Path.GetFileNameWithoutExtension(executablePath)); + + public static string[] DefaultAppRoots() + { + var roots = new List(); + Add(roots, Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles)); + Add(roots, Environment.GetEnvironmentVariable("ProgramFiles(x86)")); + Add(roots, Environment.GetEnvironmentVariable("ProgramW6432")); + var local = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + if (!string.IsNullOrEmpty(local)) + { + roots.Add(Path.Combine(local, "Programs")); + roots.Add(Path.Combine(local, "Microsoft", "WindowsApps")); + } + + return roots.ToArray(); + } + + public static string DefaultSystemRoot() => Environment.GetFolderPath(Environment.SpecialFolder.Windows); + + /// Detects an installed application from an executable path (equivalent of a macOS .app bundle). + public static InstalledAppFact? InstalledAppFromPath( + string? executablePath, + int sourcePid, + bool directProcess, + IReadOnlyList? appRoots = null) + { + if (string.IsNullOrEmpty(executablePath) || !Path.IsPathRooted(executablePath)) + { + return null; + } + + var roots = appRoots ?? DefaultAppRoots(); + var match = roots.FirstOrDefault(root => ProjectDetector.IsWithin(executablePath, root)); + if (match == null) + { + return null; + } + + return new InstalledAppFact(AppDisplayName(executablePath), match, sourcePid, directProcess); + } + + private static string AppDisplayName(string executablePath) + { + try + { + var info = FileVersionInfo.GetVersionInfo(executablePath); + var description = info.FileDescription?.Trim(); + if (!string.IsNullOrEmpty(description)) + { + return description; + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or COMException) + { + } + + return Path.GetFileNameWithoutExtension(executablePath); + } + + public static bool IsSystemExecutable(string? executablePath, string? systemRoot = null) + { + if (string.IsNullOrEmpty(executablePath)) + { + return false; + } + + var root = systemRoot ?? DefaultSystemRoot(); + return !string.IsNullOrEmpty(root) && ProjectDetector.IsWithin(executablePath, root); + } + + public static Scope ListenerScope(IReadOnlyList addresses) + { + var loopback = addresses.All(address => + { + var normalized = address.Trim('[', ']').ToLowerInvariant(); + return normalized is "127.0.0.1" or "::1" or "localhost"; + }); + return loopback ? Scope.Local : Scope.Lan; + } + + public static IReadOnlyList ParentChainFor(ProcessFact process, IReadOnlyDictionary table) + { + var chain = new List(); + var seen = new HashSet { process.Pid }; + var parentPid = process.ParentPid; + + while (parentPid is > 0 && !seen.Contains(parentPid.Value) && chain.Count < 16) + { + if (!table.TryGetValue(parentPid.Value, out var parent)) + { + break; + } + + seen.Add(parentPid.Value); + chain.Add(parent); + parentPid = parent.ParentPid; + } + + return chain; + } + + public static OwnerInference Infer(ActivityFacts facts) + { + if (facts.ProjectMarker is { } marker) + { + return new( + marker.Name, + Category.Project, + Confidence.High, + $"CWD is within a project root containing {marker.Source} at {marker.MarkerPath}."); + } + + if (facts.InstalledApp is { } app) + { + return new( + app.Name, + Category.Application, + app.DirectProcess ? Confidence.High : Confidence.Medium, + app.DirectProcess + ? $"The executable is inside {app.Path}." + : $"Direct parent PID {app.SourcePid} runs inside {app.Path}."); + } + + if (IsSystemExecutable(facts.Process.ExecutablePath)) + { + return new( + Path.GetFileName(facts.Process.ExecutablePath!), + Category.SystemService, + Confidence.High, + $"The executable path {facts.Process.ExecutablePath} is in an operating-system-managed executable location."); + } + + return new( + "Unknown", + Category.Unknown, + Confidence.None, + "No accepted project marker, installed application, or operating-system executable path established an owner."); + } + + public static string CompactPath(string? inputPath) + { + if (string.IsNullOrEmpty(inputPath)) + { + return "—"; + } + + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var full = Path.GetFullPath(inputPath); + return full.StartsWith(home + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) + ? "~" + full[home.Length..] + : full; + } + + private static void Add(List roots, string? path) + { + if (!string.IsNullOrEmpty(path)) + { + roots.Add(path); + } + } +} diff --git a/windows/src/LeftOpen.Core/ProcessCwdReader.cs b/windows/src/LeftOpen.Core/ProcessCwdReader.cs new file mode 100644 index 0000000..1d503fb --- /dev/null +++ b/windows/src/LeftOpen.Core/ProcessCwdReader.cs @@ -0,0 +1,174 @@ +using System.Runtime.InteropServices; +using System.Text; + +namespace LeftOpen.Core; + +/// +/// Reads a process's current working directory straight out of its PEB +/// (RTL_USER_PROCESS_PARAMETERS.CurrentDirectory) — the Windows stand-in for +/// leftopen's lsof -d cwd. Works for same-user, non-elevated targets; +/// returns null on any failure (elevated/protected/protected/system processes, +/// exited pids, WOW64 edge cases), which the caller degrades gracefully. +/// +public static class ProcessCwdReader +{ + private const uint ProcessQueryInformation = 0x0400; + private const uint ProcessVmRead = 0x0010; + private const int ProcessBasicInformation = 0; + private const int ProcessWow64Information = 26; + + // Offsets: ProcessParameters pointer within PEB, CurrentDirectory within params, + // and the Buffer pointer within the UNICODE_STRING, for each bitness. + private const int PebParamsOffsetX64 = 0x20; + private const int PebParamsOffsetX86 = 0x10; + private const int ParamsCurrentDirectoryOffsetX64 = 0x38; + private const int ParamsCurrentDirectoryOffsetX86 = 0x24; + private const int UnicodeStringBufferOffsetX64 = 0x8; + private const int UnicodeStringBufferOffsetX86 = 0x4; + + public static string? TryReadCwd(int pid) + { + var process = OpenProcess(ProcessQueryInformation | ProcessVmRead, false, (uint)pid); + if (process == IntPtr.Zero) + { + return null; + } + + try + { + // A 64-bit leftopen can read either bitness; a 32-bit leftopen (not shipped) + // could only read 32-bit PEBs, which NtQueryInformationProcess returns anyway. + var is64BitTarget = !TryGetWow64Peb(process, out var wow64Peb) || wow64Peb == 0; + + if (!TryGetPeb(process, out var peb) || peb == 0) + { + return null; + } + + var paramsOffset = is64BitTarget ? PebParamsOffsetX64 : PebParamsOffsetX86; + if (!TryReadPointer(process, peb + paramsOffset, is64BitTarget, out var processParameters) || processParameters == 0) + { + return null; + } + + var cwdOffset = is64BitTarget ? ParamsCurrentDirectoryOffsetX64 : ParamsCurrentDirectoryOffsetX86; + var dirStruct = processParameters + (nint)cwdOffset; + + var bufferOffset = is64BitTarget ? UnicodeStringBufferOffsetX64 : UnicodeStringBufferOffsetX86; + if (!TryReadPointer(process, dirStruct + bufferOffset, is64BitTarget, out var buffer) || buffer == 0) + { + return null; + } + + if (!TryReadUInt16(process, dirStruct, out var length) || length == 0) + { + return string.Empty; + } + + var raw = ReadBytes(process, buffer, length); + return raw == null ? null : Encoding.Unicode.GetString(raw); + } + catch + { + return null; + } + } + + private static bool TryGetPeb(IntPtr process, out nint peb) + { + var pbi = new long[6]; // PROCESS_BASIC_INFORMATION, 48 bytes on x64 + if (NtQueryInformationProcess(process, ProcessBasicInformation, pbi, pbi.Length * sizeof(long), out _) != 0) + { + peb = 0; + return false; + } + + peb = unchecked((nint)pbi[1]); // PebBaseAddress is the second pointer + return peb != 0; + } + + private static bool TryGetWow64Peb(IntPtr process, out nint wow64Peb) + { + var value = 0L; + if (NtQueryInformationProcess(process, ProcessWow64Information, ref value, sizeof(long), out _) != 0) + { + wow64Peb = 0; + return false; + } + + wow64Peb = unchecked((nint)value); + return true; + } + + private static bool TryReadPointer(IntPtr process, nint address, bool is64Bit, out nint value) + { + if (is64Bit) + { + var buffer = new byte[8]; + if (!ReadProcessMemory(process, address, buffer, buffer.Length, out _)) + { + value = 0; + return false; + } + + value = unchecked((nint)BitConverter.ToInt64(buffer, 0)); + return true; + } + + var buffer32 = new byte[4]; + if (!ReadProcessMemory(process, address, buffer32, buffer32.Length, out _)) + { + value = 0; + return false; + } + + value = unchecked((nint)BitConverter.ToUInt32(buffer32, 0)); + return true; + } + + private static bool TryReadUInt16(IntPtr process, nint address, out ushort value) + { + var buffer = new byte[2]; + if (!ReadProcessMemory(process, address, buffer, buffer.Length, out _)) + { + value = 0; + return false; + } + + value = BitConverter.ToUInt16(buffer, 0); + return true; + } + + private static byte[]? ReadBytes(IntPtr process, nint address, int length) + { + var buffer = new byte[length]; + return ReadProcessMemory(process, address, buffer, length, out _) ? buffer : null; + } + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr OpenProcess(uint access, bool inheritHandle, uint pid); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool ReadProcessMemory( + IntPtr process, + nint baseAddress, + byte[] buffer, + int size, + out nint bytesRead); + + [DllImport("ntdll.dll")] + private static extern int NtQueryInformationProcess( + IntPtr processHandle, + int processInformationClass, + long[] processInformation, + int processInformationLength, + out int returnLength); + + [DllImport("ntdll.dll")] + private static extern int NtQueryInformationProcess( + IntPtr processHandle, + int processInformationClass, + ref long processInformation, + int processInformationLength, + out int returnLength); +} diff --git a/windows/src/LeftOpen.Core/ProcessIdentity.cs b/windows/src/LeftOpen.Core/ProcessIdentity.cs new file mode 100644 index 0000000..bf1cabd --- /dev/null +++ b/windows/src/LeftOpen.Core/ProcessIdentity.cs @@ -0,0 +1,115 @@ +using System.Runtime.InteropServices; +using System.Security.Principal; + +namespace LeftOpen.Core; + +/// +/// Resolves the user SID that owns a process, via its access token — the Windows +/// equivalent of leftopen's verified uid. Returns null when the process is elevated, +/// protected, or already gone (the caller treats that as "unverified, refuse to close"). +/// +public static class ProcessIdentity +{ + private const uint ProcessQueryLimitedInformation = 0x1000; + private const uint TokenQuery = 0x0008; + private const int TokenUser = 1; + + public static string? GetOwnerSid(int pid) + { + var process = OpenProcess(ProcessQueryLimitedInformation, false, (uint)pid); + if (process == IntPtr.Zero) + { + return null; + } + + try + { + if (!OpenProcessToken(process, TokenQuery, out var token)) + { + return null; + } + + try + { + return GetTokenUserSid(token); + } + finally + { + CloseHandle(token); + } + } + finally + { + CloseHandle(process); + } + } + + public static string? ResolveOwnerName(string? sid) + { + if (string.IsNullOrEmpty(sid)) + { + return null; + } + + try + { + return new SecurityIdentifier(sid).Translate(typeof(NTAccount)).Value; + } + catch + { + return null; + } + } + + private static string? GetTokenUserSid(IntPtr token) + { + var length = 0; + GetTokenInformation(token, TokenUser, IntPtr.Zero, 0, ref length); + if (length <= 0) + { + return null; + } + + var buffer = Marshal.AllocHGlobal(length); + try + { + if (!GetTokenInformation(token, TokenUser, buffer, length, ref length)) + { + return null; + } + + // TOKEN_USER { SID_AND_ATTRIBUTES { PVOID Sid; ULONG Attributes; } } + var sid = Marshal.ReadIntPtr(buffer); + if (sid == IntPtr.Zero) + { + return null; + } + + return ConvertSidToStringSid(sid, out var sidString) ? sidString : null; + } + finally + { + Marshal.FreeHGlobal(buffer); + } + } + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr OpenProcess(uint access, bool inheritHandle, uint pid); + + [DllImport("advapi32.dll", SetLastError = true)] + private static extern bool OpenProcessToken(IntPtr processHandle, uint access, out IntPtr tokenHandle); + + [DllImport("advapi32.dll", SetLastError = true)] + private static extern bool GetTokenInformation( + IntPtr tokenHandle, + int tokenInformationClass, + IntPtr tokenInformation, + int tokenInformationLength, + ref int returnLength); + + [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool ConvertSidToStringSid(IntPtr sid, out string stringSid); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool CloseHandle(IntPtr handle); +} diff --git a/windows/src/LeftOpen.Core/ProcessTable.cs b/windows/src/LeftOpen.Core/ProcessTable.cs new file mode 100644 index 0000000..6fb9a43 --- /dev/null +++ b/windows/src/LeftOpen.Core/ProcessTable.cs @@ -0,0 +1,83 @@ +using System.Diagnostics; +using System.Management; +using System.Runtime.InteropServices; + +namespace LeftOpen.Core; + +/// +/// Collects the full process table in one WMI query (pid, parent pid, executable path, +/// command line, start time) — the Windows stand-in for leftopen's ps pass. +/// +public static class ProcessTable +{ + public static IReadOnlyDictionary Snapshot(out string? limitation) + { + try + { + var table = new Dictionary(); + using var searcher = new ManagementObjectSearcher( + "SELECT ProcessId, ParentProcessId, Name, ExecutablePath, CommandLine, CreationDate FROM Win32_Process"); + foreach (var mo in searcher.Get().Cast()) + { + var pid = Convert.ToInt32(mo["ProcessId"]); + if (pid <= 0) + { + continue; + } + + var parentPid = Convert.ToInt32(mo["ParentProcessId"]); + var name = mo["Name"]?.ToString() ?? "unknown"; + var executablePath = mo["ExecutablePath"]?.ToString(); + var commandLine = mo["CommandLine"]?.ToString(); + var startTime = ParseCreationDate(mo["CreationDate"]?.ToString()); + + table[pid] = new ProcessFact( + pid, + parentPid > 0 ? parentPid : null, + name, + executablePath, + commandLine, + startTime); + } + + limitation = null; + return table; + } + catch (Exception ex) when (ex is ManagementException or COMException or UnauthorizedAccessException) + { + limitation = "The process table was unavailable, so parent-process evidence could not be collected."; + return new Dictionary(); + } + } + + /// Reads the authoritative start time for one PID straight from the OS (WMI snapshot may be stale). + public static DateTime? QueryStartTime(int pid) + { + try + { + using var process = Process.GetProcessById(pid); + return process.StartTime; + } + catch + { + return null; + } + } + + private static DateTime? ParseCreationDate(string? dmtf) + { + if (string.IsNullOrEmpty(dmtf)) + { + return null; + } + + try + { + return ManagementDateTimeConverter.ToDateTime(dmtf); + } + catch + { + return null; + } + } +} diff --git a/windows/src/LeftOpen.Core/ProcessTerminator.cs b/windows/src/LeftOpen.Core/ProcessTerminator.cs new file mode 100644 index 0000000..049276f --- /dev/null +++ b/windows/src/LeftOpen.Core/ProcessTerminator.cs @@ -0,0 +1,114 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; + +namespace LeftOpen.Core; + +/// +/// The Windows stand-in for leftopen's SIGTERM-only rule. Windows has no per-process +/// signal, so the gentle ladder is: (1) WM_CLOSE to the process's own window, then +/// (2) console Ctrl+C followed by Ctrl+Break, delivered as if the user pressed the +/// keys in the terminal hosting the process. Ctrl+Break matters because processes +/// started in a new process group (e.g. PowerShell Start-Process) ignore Ctrl+C, +/// while Ctrl+Break always gets through. Force-kill (taskkill /F, Process.Kill) is +/// never used; a process that ignores everything is reported honestly instead. +/// +public static class ProcessTerminator +{ + private const uint CtrlCEvent = 0; + private const uint CtrlBreakEvent = 1; + private const uint AttachParentProcess = unchecked(uint.MaxValue); + + /// Sends every gentle close signal we have. True when something was actually delivered. + public static bool TrySendGentleClose(int pid) + { + var delivered = false; + + try + { + using var process = Process.GetProcessById(pid); + if (!process.HasExited && process.MainWindowHandle != 0) + { + process.CloseMainWindow(); + delivered = true; + } + } + catch (ArgumentException) + { + // Process already exited — nothing to signal, but also nothing left to do. + return true; + } + catch (Exception ex) when (ex is InvalidOperationException or System.ComponentModel.Win32Exception) + { + // Exited between enumeration and access, or details unavailable — + // the console path may still work. + } + + if (TrySendConsoleCtrl(pid)) + { + delivered = true; + } + + return delivered; + } + + private static bool TrySendConsoleCtrl(int pid) + { + var hadConsole = GetConsoleWindow() != IntPtr.Zero; + if (hadConsole && !FreeConsole()) + { + return false; + } + + if (!AttachConsole((uint)pid)) + { + if (hadConsole) + { + AttachConsole(AttachParentProcess); + } + + return false; + } + + try + { + // A real handler that returns TRUE suppresses the default action for + // BOTH Ctrl+C and Ctrl+Break (the NULL-handler "ignore mode" only + // covers Ctrl+C), so the events we generate for that console do not + // terminate leftopen itself. + SetConsoleCtrlHandler(KeepAliveHandler, true); + GenerateConsoleCtrlEvent(CtrlCEvent, 0); + GenerateConsoleCtrlEvent(CtrlBreakEvent, 0); + Thread.Sleep(100); + } + finally + { + FreeConsole(); + SetConsoleCtrlHandler(KeepAliveHandler, false); + if (hadConsole) + { + AttachConsole(AttachParentProcess); + } + } + + return true; + } + + private delegate bool ConsoleCtrlDelegate(uint ctrlType); + + private static readonly ConsoleCtrlDelegate KeepAliveHandler = _ => true; + + [DllImport("kernel32.dll")] + private static extern IntPtr GetConsoleWindow(); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool FreeConsole(); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool AttachConsole(uint processId); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool SetConsoleCtrlHandler(ConsoleCtrlDelegate? handlerRoutine, bool add); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GenerateConsoleCtrlEvent(uint ctrlEvent, uint processGroup); +} diff --git a/windows/src/LeftOpen.Core/ProjectDetector.cs b/windows/src/LeftOpen.Core/ProjectDetector.cs new file mode 100644 index 0000000..984e57d --- /dev/null +++ b/windows/src/LeftOpen.Core/ProjectDetector.cs @@ -0,0 +1,215 @@ +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace LeftOpen.Core; + +/// +/// Detects the project a working directory belongs to by walking up until a marker +/// (.git / package.json / pyproject.toml / Cargo.toml / go.mod) is found in a +/// directory that passes the rejection rules — a faithful port of leftopen's +/// findProject + projectPathRejectionReason, adapted to Windows tree layouts. +/// +public static partial class ProjectDetector +{ + private static readonly (string Marker, string Source)[] Markers = + [ + (".git", "git"), + ("package.json", "package.json"), + ("pyproject.toml", "pyproject.toml"), + ("Cargo.toml", "Cargo.toml"), + ("go.mod", "go.mod"), + ]; + + public sealed record RejectionContext( + string Home, + IReadOnlyList OsManagedRoots, + IReadOnlyList UserManagedRoots); + + public static RejectionContext DefaultContext() + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var osManaged = new List(); + var userManaged = new List(); + + AddIfPresent(osManaged, Environment.GetFolderPath(Environment.SpecialFolder.Windows)); + AddIfPresent(osManaged, Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles)); + AddIfPresent(osManaged, Environment.GetEnvironmentVariable("ProgramFiles(x86)")); + AddIfPresent(osManaged, Environment.GetEnvironmentVariable("ProgramW6432")); + AddIfPresent(osManaged, Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData)); + + // Per-user install and cache trees: npm globals, per-user app installs, etc. + AddIfPresent(userManaged, Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)); + AddIfPresent(userManaged, Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData)); + + return new RejectionContext(home, osManaged, userManaged); + } + + public static ProjectMarkerFact? FindProject(string? cwd, RejectionContext? context = null) + { + context ??= DefaultContext(); + + if (string.IsNullOrEmpty(cwd)) + { + return null; + } + + // PEB-derived cwd strings carry a trailing separator; drop it so name + // fallbacks and marker paths stay clean. + var directory = Path.GetFullPath(cwd) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + while (true) + { + foreach (var (marker, source) in Markers) + { + var markerPath = Path.Combine(directory, marker); + if (!File.Exists(markerPath) && !Directory.Exists(markerPath)) + { + continue; + } + + if (ProjectPathRejectionReason(directory, cwd, context) != null) + { + continue; + } + + var name = source switch + { + "package.json" => PackageName(directory) ?? Path.GetFileName(directory), + "pyproject.toml" => PyprojectName(directory) ?? Path.GetFileName(directory), + _ => Path.GetFileName(directory), + }; + + if (string.IsNullOrEmpty(name)) + { + name = directory; + } + + return new ProjectMarkerFact(name, directory, source, markerPath); + } + + var parent = Path.GetDirectoryName(directory.TrimEnd(Path.DirectorySeparatorChar)); + if (string.IsNullOrEmpty(parent) || string.Equals(parent, directory, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + directory = parent; + } + } + + /// Returns why a candidate marker root must be ignored, or null when it is acceptable. + public static string? ProjectPathRejectionReason(string root, string cwd, RejectionContext context) + { + var resolvedRoot = Path.GetFullPath(root).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var resolvedCwd = Path.GetFullPath(cwd); + var segments = SplitSegments(resolvedRoot); + + if (context.OsManagedRoots.Any(managed => IsWithin(resolvedRoot, managed))) + { + return "the marker is inside an installed or operating-system-managed tree"; + } + + if (context.UserManagedRoots.Any(managed => IsWithin(resolvedRoot, managed))) + { + return "the marker is inside a per-user application-data tree used for installs and caches"; + } + + var home = Path.GetFullPath(context.Home).TrimEnd(Path.DirectorySeparatorChar); + if (IsWithin(resolvedRoot, home)) + { + if (string.Equals(resolvedRoot, home, StringComparison.OrdinalIgnoreCase)) + { + return "the marker root is the user home rather than a specific project directory"; + } + + var relative = Path.GetRelativePath(home, resolvedRoot); + var firstSegment = SplitSegments(relative).FirstOrDefault(); + if (firstSegment != null && firstSegment.StartsWith('.')) + { + return "the marker is inside a hidden per-user tool-data tree"; + } + } + + if (segments.Any(segment => segment.Equals("node_modules", StringComparison.OrdinalIgnoreCase))) + { + return "the marker is inside a dependency tree"; + } + + if (segments.Any(segment => + segment.Equals("cache", StringComparison.OrdinalIgnoreCase) || + segment.Equals("caches", StringComparison.OrdinalIgnoreCase) || + segment.Equals(".cache", StringComparison.OrdinalIgnoreCase))) + { + return "the marker is inside a cache tree"; + } + + if (!IsWithin(resolvedCwd, resolvedRoot)) + { + return "the working directory is not inside the marker root"; + } + + return null; + } + + public static bool IsWithin(string candidate, string parent) + { + var relative = Path.GetRelativePath( + parent.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), + candidate.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); + return relative == "." || (!relative.StartsWith("..") && !Path.IsPathRooted(relative)); + } + + internal static string? PackageName(string directory) + { + try + { + using var document = JsonDocument.Parse(File.ReadAllText(Path.Combine(directory, "package.json"))); + if (document.RootElement.ValueKind == JsonValueKind.Object && + document.RootElement.TryGetProperty("name", out var name) && + name.ValueKind == JsonValueKind.String) + { + var value = name.GetString()?.Trim(); + return string.IsNullOrEmpty(value) ? null : value; + } + } + catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException) + { + } + + return null; + } + + internal static string? PyprojectName(string directory) + { + try + { + var contents = File.ReadAllText(Path.Combine(directory, "pyproject.toml")); + return PyprojectProjectNameRegex().Match(contents) is { Success: true } match + ? match.Groups["name"].Value.Trim() + : null; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return null; + } + } + + private static void AddIfPresent(List roots, string? path) + { + if (!string.IsNullOrEmpty(path)) + { + roots.Add(path); + } + } + + private static List SplitSegments(string path) + { + // Accept both separators and keep drive letters out of the segment list. + return path.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries) + .Where(segment => !segment.EndsWith(':')) + .ToList(); + } + + [GeneratedRegex(@"\[project\][^\[]*?^\s*name\s*=\s*[""'](?[^""']+)[""']", RegexOptions.Multiline | RegexOptions.ExplicitCapture)] + private static partial Regex PyprojectProjectNameRegex(); +} diff --git a/windows/src/LeftOpen.Core/Scanner.cs b/windows/src/LeftOpen.Core/Scanner.cs new file mode 100644 index 0000000..f6b4753 --- /dev/null +++ b/windows/src/LeftOpen.Core/Scanner.cs @@ -0,0 +1,157 @@ +namespace LeftOpen.Core; + +/// +/// Orchestrates one on-demand scan: TCP listeners → process facts (WMI) → working +/// directories (PEB) → project markers, then assembles explainable activities. +/// Nothing here polls or caches — each call is a fresh snapshot, matching leftopen's +/// "scan only when the panel opens" philosophy. +/// +public static class Scanner +{ + public static IReadOnlyList ScanListeners() + { + var rows = TcpTable.GetListeners(); + var commandByPid = new Dictionary(); + var sidByPid = new Dictionary(); + var byKey = new Dictionary<(int Pid, int Port), Listener>(); + + foreach (var row in rows.OrderBy(row => row.Port).ThenBy(row => row.Pid)) + { + if (!commandByPid.TryGetValue(row.Pid, out var command)) + { + command = ResolveCommand(row.Pid); + commandByPid[row.Pid] = command; + } + + if (!sidByPid.TryGetValue(row.Pid, out var owner)) + { + owner = ResolveOwner(row.Pid); + sidByPid[row.Pid] = owner; + } + + var key = (row.Pid, row.Port); + if (byKey.TryGetValue(key, out var existing)) + { + if (!existing.Addresses.Contains(row.Address)) + { + byKey[key] = existing with { Addresses = [.. existing.Addresses, row.Address] }; + } + + continue; + } + + byKey[key] = new Listener( + row.Pid, + command, + owner.Sid, + owner.Name, + row.Port, + [row.Address]); + } + + return byKey.Values + .OrderBy(listener => listener.Port) + .ThenBy(listener => listener.Pid) + .ToList(); + } + + public static ScanResult ScanActivities() + { + var listeners = ScanListeners(); + var limitations = new List(); + + var table = ProcessTable.Snapshot(out var tableLimitation); + if (tableLimitation != null) + { + limitations.Add(tableLimitation); + } + + var cwdByPid = new Dictionary(); + var unreadablePids = new List(); + foreach (var pid in listeners.Select(listener => listener.Pid).Distinct()) + { + var cwd = ProcessCwdReader.TryReadCwd(pid); + if (cwd == null) + { + unreadablePids.Add(pid); + } + + cwdByPid[pid] = cwd; + } + + if (unreadablePids.Count > 0) + { + limitations.Add( + $"Working directories could not be read for {unreadablePids.Count} PID(s) ({string.Join(", ", unreadablePids)}); " + + "project ownership for them is unavailable."); + } + + var projectByCwd = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var cwd in cwdByPid.Values.Where(value => !string.IsNullOrEmpty(value)).Distinct(StringComparer.OrdinalIgnoreCase)) + { + projectByCwd[cwd!] = ProjectDetector.FindProject(cwd); + } + + var activities = new List(); + foreach (var listener in listeners) + { + table.TryGetValue(listener.Pid, out var tableFact); + var process = new ProcessFact( + listener.Pid, + tableFact?.ParentPid, + tableFact?.Command ?? listener.Command, + tableFact?.ExecutablePath, + tableFact?.CommandLine, + tableFact?.StartTime); + + var cwd = cwdByPid.GetValueOrDefault(listener.Pid); + var parentChain = Ownership.ParentChainFor(process, table); + var projectMarker = cwd != null ? projectByCwd.GetValueOrDefault(cwd) : null; + + var parent = parentChain.FirstOrDefault(); + var installedApp = Ownership.InstalledAppFromPath(process.ExecutablePath, process.Pid, true) + ?? Ownership.InstalledAppFromPath(parent?.ExecutablePath, parent?.Pid ?? process.Pid, false); + + var activityProcess = new ActivityProcessFact( + process.Pid, + process.ParentPid, + process.Command, + process.ExecutablePath, + process.CommandLine, + process.StartTime, + listener.OwnerSid, + listener.OwnerName, + cwd); + + var facts = new ActivityFacts( + new ListenerFact(listener.Port, listener.Addresses, Ownership.ListenerScope(listener.Addresses)), + activityProcess, + parentChain, + projectMarker, + installedApp); + + activities.Add(new Activity(facts, Ownership.Infer(facts))); + } + + return new ScanResult(activities, limitations); + } + + private static string ResolveCommand(int pid) + { + try + { + using var process = System.Diagnostics.Process.GetProcessById(pid); + return string.IsNullOrEmpty(process.ProcessName) ? "unknown" : process.ProcessName; + } + catch + { + return "unknown"; + } + } + + private static (string? Sid, string? Name) ResolveOwner(int pid) + { + var sid = ProcessIdentity.GetOwnerSid(pid); + return (sid, ProcessIdentity.ResolveOwnerName(sid)); + } +} diff --git a/windows/src/LeftOpen.Core/TcpTable.cs b/windows/src/LeftOpen.Core/TcpTable.cs new file mode 100644 index 0000000..3645a28 --- /dev/null +++ b/windows/src/LeftOpen.Core/TcpTable.cs @@ -0,0 +1,153 @@ +using System.Net; +using System.Runtime.InteropServices; + +namespace LeftOpen.Core; + +/// A raw listening-socket row from the Windows TCP table: one PID, one port, one bound address. +public sealed record TcpListenerRow(int Pid, int Port, string Address); + +/// +/// Enumerates TCP listeners with owning PIDs via the same native API netstat uses +/// (), avoiding text parsing and sub-process overhead. +/// +public static class TcpTable +{ + private const int AfInet = 2; + private const int AfInet6 = 23; + private const int TcpStateListen = 2; + + public static IReadOnlyList GetListeners() + { + var rows = new List(); + AppendRows(rows, AfInet, isIpv6: false); + AppendRows(rows, AfInet6, isIpv6: true); + return rows; + } + + private static void AppendRows(List rows, int addressFamily, bool isIpv6) + { + var raw = QueryTcpTable(addressFamily); + if (raw.Length == 0) + { + return; + } + + var count = (int)BitConverter.ToUInt32(raw, 0); + var offset = sizeof(uint); // dwNumEntries + var rowSize = isIpv6 ? Marshal.SizeOf() : Marshal.SizeOf(); + + for (var i = 0; i < count; i++) + { + if (isIpv6) + { + var row = BytesToStruct(raw, offset + i * rowSize); + if (row.State != TcpStateListen) + { + continue; + } + + var address = new IPAddress(row.LocalAddr!, (long)row.LocalScopeId).ToString(); + rows.Add(new TcpListenerRow((int)row.OwningPid, PortFromNetworkOrder(row.LocalPort), address)); + } + else + { + var row = BytesToStruct(raw, offset + i * rowSize); + if (row.State != TcpStateListen) + { + continue; + } + + var address = new IPAddress(row.LocalAddr).ToString(); + rows.Add(new TcpListenerRow((int)row.OwningPid, PortFromNetworkOrder(row.LocalPort), address)); + } + } + } + + internal static int PortFromNetworkOrder(uint portField) + { + // dwLocalPort's low 16 bits hold the port in network byte order; swap the + // two bytes and keep the result in 16 bits (an unmasked << 8 lets the + // original low byte leak into bits 16-23, producing impossible ports). + var networkPort = (ushort)(portField & 0xFFFF); + return ((networkPort & 0xFF) << 8) | (networkPort >> 8); + } + + private static byte[] QueryTcpTable(int addressFamily) + { + var size = 0; + var result = GetExtendedTcpTable(IntPtr.Zero, ref size, false, addressFamily, TcpTableClassOwnerPidAll, 0); + if (result != ErrorInsufficientBuffer || size <= 0) + { + return Array.Empty(); + } + + var buffer = Marshal.AllocHGlobal(size); + try + { + result = GetExtendedTcpTable(buffer, ref size, false, addressFamily, TcpTableClassOwnerPidAll, 0); + if (result != 0) + { + return Array.Empty(); + } + + var raw = new byte[size]; + Marshal.Copy(buffer, raw, 0, size); + return raw; + } + finally + { + Marshal.FreeHGlobal(buffer); + } + } + + private static T BytesToStruct(byte[] bytes, int offset) where T : struct + { + var handle = GCHandle.Alloc(bytes, GCHandleType.Pinned); + try + { + return Marshal.PtrToStructure(handle.AddrOfPinnedObject() + offset); + } + finally + { + handle.Free(); + } + } + + private const uint ErrorInsufficientBuffer = 122; + private const int TcpTableClassOwnerPidAll = 5; + + [DllImport("iphlpapi.dll")] + private static extern uint GetExtendedTcpTable( + IntPtr pTcpTable, + ref int dwSize, + bool bOrder, + int ulAf, + int tableClass, + uint reserved); + + [StructLayout(LayoutKind.Sequential)] + private struct MibTcpRowOwnerPid + { + public uint State; + public uint LocalAddr; + public uint LocalPort; + public uint RemoteAddr; + public uint RemotePort; + public uint OwningPid; + } + + [StructLayout(LayoutKind.Sequential)] + private struct MibTcp6RowOwnerPid + { + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 16)] + public byte[]? LocalAddr; + public uint LocalScopeId; + public uint LocalPort; + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 16)] + public byte[]? RemoteAddr; + public uint RemoteScopeId; + public uint RemotePort; + public uint State; + public uint OwningPid; + } +} diff --git a/windows/src/LeftOpen.Tray/AppIcons.cs b/windows/src/LeftOpen.Tray/AppIcons.cs new file mode 100644 index 0000000..0e4d752 --- /dev/null +++ b/windows/src/LeftOpen.Tray/AppIcons.cs @@ -0,0 +1,61 @@ +using System.Diagnostics; + +namespace LeftOpen.Tray; + +/// +/// Per-row icon resolution, mirroring leftopen's chain: the target executable's own +/// icon (installed apps) → the project's favicon.ico → a generic door glyph. +/// +internal static class AppIcons +{ + public static Image Resolve(string? executablePath, string? projectRoot, int size) + { + try + { + if (!string.IsNullOrEmpty(executablePath) && File.Exists(executablePath)) + { + using var icon = Icon.ExtractAssociatedIcon(executablePath); + if (icon != null) + { + return new Bitmap(icon.ToBitmap(), size, size); + } + } + } + catch + { + // Fall through to the project favicon. + } + + try + { + if (!string.IsNullOrEmpty(projectRoot)) + { + var favicon = Path.Combine(projectRoot, "favicon.ico"); + if (File.Exists(favicon)) + { + using var icon = new Icon(favicon, size, size); + return new Bitmap(icon.ToBitmap(), size, size); + } + } + } + catch + { + // Fall through to the fallback glyph. + } + + return FallbackGlyph(size); + } + + private static Image FallbackGlyph(int size) + { + var bitmap = new Bitmap(size, size); + using var g = Graphics.FromImage(bitmap); + g.Clear(Color.Transparent); + using var brush = new SolidBrush(Color.FromArgb(90, 90, 96)); + using var font = new Font("Segoe UI", size * 0.55f, FontStyle.Bold, GraphicsUnit.Pixel); + var text = "?"; + var measured = g.MeasureString(text, font); + g.DrawString(text, font, brush, (size - measured.Width) / 2, (size - measured.Height) / 2); + return bitmap; + } +} diff --git a/windows/src/LeftOpen.Tray/DoorIcon.cs b/windows/src/LeftOpen.Tray/DoorIcon.cs new file mode 100644 index 0000000..bec2b3d --- /dev/null +++ b/windows/src/LeftOpen.Tray/DoorIcon.cs @@ -0,0 +1,100 @@ +using LeftOpen.Core; + +namespace LeftOpen.Tray; + +/// +/// The door glyph for the system tray: a closed door when localhost is quiet, +/// an open door when something was left running. Drawn with GDI+ in a colour +/// that adapts to the light/dark taskbar theme, like leftopen's DoorMark. +/// +internal static class DoorIcon +{ + public static Icon Closed(int size) => Create(size, open: false); + + public static Icon Open(int size) => Create(size, open: true); + + public static void Paint(Graphics g, Rectangle bounds, bool open, Color color) + { + g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias; + using var pen = new Pen(color, Math.Max(1.5f, bounds.Width / 12f)); + var thickness = Math.Max(3, bounds.Width / 7); + + // Door frame: rounded rectangle outline. + var frame = Rectangle.Inflate(bounds, -thickness, -thickness / 2); + frame.Height += thickness / 2; + using var framePath = RoundedRect(frame, bounds.Width / 8); + g.DrawPath(pen, framePath); + + if (open) + { + // The leaf swung outwards: a parallelogram from the frame's edge. + var leaf = new[] + { + new PointF(frame.Right - thickness - 1, frame.Top + thickness), + new PointF(frame.Right + thickness * 1.1f, frame.Top + thickness * 2.2f), + new PointF(frame.Right + thickness * 1.1f, frame.Bottom - 1), + new PointF(frame.Right - thickness - 1, frame.Bottom - thickness / 2), + }; + using var leafPath = new System.Drawing.Drawing2D.GraphicsPath(); + leafPath.AddPolygon(leaf); + using var fill = new SolidBrush(Color.FromArgb(90, color)); + g.FillPath(fill, leafPath); + g.DrawPath(pen, leafPath); + + // Knob on the frame side. + g.FillEllipse(new SolidBrush(color), frame.Left + thickness, frame.Top + frame.Height / 2 - thickness, thickness * 1.2f, thickness * 1.2f); + } + else + { + // Closed leaf fills the frame, with a knob on the right. + var leaf = Rectangle.Inflate(frame, -thickness / 3, -thickness / 3); + using var leafPath = RoundedRect(leaf, bounds.Width / 10); + g.FillPath(new SolidBrush(Color.FromArgb(60, color)), leafPath); + g.DrawPath(pen, leafPath); + + var knobSize = Math.Max(3, bounds.Width / 9); + var knob = new Rectangle(leaf.Right - knobSize - thickness, leaf.Top + leaf.Height / 2 - knobSize / 2, knobSize, knobSize); + g.FillEllipse(new SolidBrush(color), knob); + } + } + + private static System.Drawing.Drawing2D.GraphicsPath RoundedRect(Rectangle bounds, int radius) + { + var path = new System.Drawing.Drawing2D.GraphicsPath(); + var d = radius * 2; + path.AddArc(bounds.Left, bounds.Top, d, d, 180, 90); + path.AddArc(bounds.Right - d, bounds.Top, d, d, 270, 90); + path.AddArc(bounds.Right - d, bounds.Bottom - d, d, d, 0, 90); + path.AddArc(bounds.Left, bounds.Bottom - d, d, d, 90, 90); + path.CloseFigure(); + return path; + } + + private static Icon Create(int size, bool open) + { + using var bitmap = new Bitmap(size, size); + using (var g = Graphics.FromImage(bitmap)) + { + g.Clear(Color.Transparent); + Paint(g, new Rectangle(0, 0, size, size), open, TaskbarGlyphColor()); + } + + return Icon.FromHandle(bitmap.GetHicon()); + } + + /// White glyph on the default dark taskbar, dark glyph on light themes. + public static Color TaskbarGlyphColor() + { + try + { + using var key = Microsoft.Win32.Registry.CurrentUser.OpenSubKey( + @"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize"); + var light = key?.GetValue("SystemUsesLightTheme") as int? ?? 0; + return light == 1 ? Color.FromArgb(32, 32, 32) : Color.FromArgb(240, 240, 240); + } + catch + { + return Color.FromArgb(240, 240, 240); + } + } +} diff --git a/windows/src/LeftOpen.Tray/GroupRowControl.cs b/windows/src/LeftOpen.Tray/GroupRowControl.cs new file mode 100644 index 0000000..9eb4ae4 --- /dev/null +++ b/windows/src/LeftOpen.Tray/GroupRowControl.cs @@ -0,0 +1,246 @@ +using System.Diagnostics; +using LeftOpen.Core; + +namespace LeftOpen.Tray; + +/// +/// One row in the panel list: port badge + process icon + owner label + sub-line, +/// with the LAN badge on the right, a hover-revealed × button, and a right-click +/// menu (open in browser / copy / close), mirroring leftopen's GroupRowItem. +/// +internal sealed class GroupRowControl : Panel +{ + private readonly PanelForm.GroupRow _group; + private readonly Action _openDetail; + private readonly Action _requestClose; + private readonly Button _closeButton; + private readonly PictureBox _icon; + private bool _iconLoaded; + + public GroupRowControl( + PanelForm.GroupRow group, + Action openDetail, + Action requestClose) + { + _group = group; + _openDetail = openDetail; + _requestClose = requestClose; + + const int width = PanelForm.PanelWidth; + Height = 54; + Width = width - 26; + Margin = new Padding(14, 2, 14, 2); + BackColor = SystemColors.Window; + Cursor = Cursors.Hand; + + // Expose the row to accessibility tools (and screen readers) as a button. + AccessibleRole = AccessibleRole.PushButton; + AccessibleName = $"端口 {string.Join(", ", group.Ports)} · {group.Label} · PID {group.Pid}"; + AccessibleDescription = group.Closable ? "打开详情" : $"不可关闭:{group.RefusalReason}"; + + var port = new Label + { + Text = group.Ports[0].ToString(), + AutoSize = false, + Size = new Size(66, 20), + Location = new Point(10, 8), + Font = new Font("Consolas", 11f, FontStyle.Bold), + TextAlign = ContentAlignment.MiddleLeft, + }; + + if (group.Ports.Count > 1) + { + var more = new Label + { + Text = $"+{group.Ports.Count - 1}", + AutoSize = false, + Size = new Size(30, 16), + Location = new Point(76, 10), + Font = new Font("Segoe UI", 8f, FontStyle.Bold), + BackColor = SystemColors.ControlLight, + ForeColor = SystemColors.GrayText, + TextAlign = ContentAlignment.MiddleCenter, + }; + Controls.Add(more); + } + + _icon = new PictureBox + { + Size = new Size(24, 24), + Location = new Point(10, 24), + SizeMode = PictureBoxSizeMode.CenterImage, + }; + + var label = new Label + { + Text = TrimLabel(group.Label), + AutoSize = false, + Size = new Size(width - 210, 18), + Location = new Point(112, 7), + Font = new Font("Segoe UI", 9.5f, FontStyle.Bold), + ForeColor = SystemColors.ControlText, + }; + + var sub = new Label + { + Text = $"{TrimCommand(group.Command)} · PID {group.Pid}", + AutoSize = false, + Size = new Size(width - 272, 16), + Location = new Point(112, 27), + ForeColor = SystemColors.GrayText, + }; + + var uptime = new Label + { + Text = group.StartTime != null ? DescribeDuration(DateTime.Now - group.StartTime.Value) : "—", + AutoSize = false, + Size = new Size(56, 34), + Location = new Point(width - 216, 16), + ForeColor = SystemColors.GrayText, + TextAlign = ContentAlignment.MiddleRight, + }; + + if (group.AnyLan) + { + var lan = new Label + { + Text = "LAN", + AutoSize = false, + Size = new Size(34, 16), + Location = new Point(width - 158, 25), + BackColor = Color.FromArgb(255, 235, 205), + ForeColor = Color.FromArgb(150, 100, 20), + Font = new Font("Segoe UI", 7.5f, FontStyle.Bold), + TextAlign = ContentAlignment.MiddleCenter, + }; + Controls.Add(lan); + } + + _closeButton = new Button + { + Text = "✕", + AccessibleName = $"关闭 {group.Label} (PID {group.Pid})", + Font = new Font("Segoe UI", 10f, FontStyle.Bold), + FlatStyle = FlatStyle.Flat, + Size = new Size(32, 32), + Location = new Point(width - 114, 10), + ForeColor = Color.FromArgb(190, 60, 50), + Visible = false, + TabStop = false, + }; + _closeButton.FlatAppearance.BorderSize = 0; + _closeButton.FlatAppearance.MouseOverBackColor = Color.FromArgb(250, 228, 226); + if (group.Closable) + { + _closeButton.Click += (_, _) => _requestClose(_group); + Controls.Add(_closeButton); + } + + Controls.Add(port); + Controls.Add(_icon); + Controls.Add(label); + Controls.Add(sub); + Controls.Add(uptime); + + MouseEnter += (_, _) => { BackColor = SystemColors.ControlLightLight; if (_group.Closable) _closeButton.Visible = true; }; + MouseLeave += OnMouseLeaveRow; + foreach (Control child in Controls) + { + child.MouseEnter += (_, _) => { BackColor = SystemColors.ControlLightLight; if (_group.Closable) _closeButton.Visible = true; }; + } + + Click += (_, _) => _openDetail(_group); + label.Click += (_, _) => _openDetail(_group); + sub.Click += (_, _) => _openDetail(_group); + _icon.Click += (_, _) => _openDetail(_group); + MouseClick += OnRowMouseClick; + label.MouseClick += OnRowMouseClick; + + // Icon loading touches the filesystem; do it off the hot path so the + // panel renders instantly. + _ = Task.Run(() => + { + var image = AppIcons.Resolve(group.ExecutablePath, group.ProjectRoot, 24); + BeginInvoke(() => + { + if (!IsDisposed) + { + _icon.Image = image; + } + else + { + image.Dispose(); + } + }); + }); + } + + private void OnMouseLeaveRow(object? sender, EventArgs e) + { + if (ClientRectangle.Contains(PointToClient(MousePosition))) + { + return; + } + + BackColor = SystemColors.Window; + _closeButton.Visible = false; + } + + private void OnRowMouseClick(object? sender, MouseEventArgs e) + { + if (e.Button != MouseButtons.Right) + { + return; + } + + var menu = new ContextMenuStrip(); + menu.Items.Add($"在浏览器打开 :{ _group.Ports[0]}", null, (_, _) => OpenInBrowser(_group.Ports[0])); + menu.Items.Add($"复制端口 {_group.Ports[0]}", null, (_, _) => CopyToClipboard(_group.Ports[0].ToString())); + menu.Items.Add($"复制 PID {_group.Pid}", null, (_, _) => CopyToClipboard(_group.Pid.ToString())); + if (_group.Closable) + { + menu.Items.Add(new ToolStripSeparator()); + menu.Items.Add($"关闭进程 (PID {_group.Pid})…", null, (_, _) => _requestClose(_group)); + } + + menu.Show(this, e.Location); + } + + private static void OpenInBrowser(int port) + { + try + { + Process.Start(new ProcessStartInfo { FileName = $"http://localhost:{port}/", UseShellExecute = true }); + } + catch + { + // Browser launch failures are not worth interrupting the panel for. + } + } + + private static void CopyToClipboard(string text) => Clipboard.SetText(text); + + private static string TrimLabel(string label) => label.Length <= 34 ? label : label[..33] + "…"; + + private static string TrimCommand(string command) => command.Length <= 28 ? command : command[..27] + "…"; + + private static string DescribeDuration(TimeSpan span) + { + if (span.TotalMinutes < 1) + { + return $"{(int)span.TotalSeconds}秒"; + } + + if (span.TotalHours < 1) + { + return $"{(int)span.TotalMinutes}分钟"; + } + + if (span.TotalDays < 1) + { + return $"{(int)span.TotalHours}小时"; + } + + return $"{(int)span.TotalDays}天"; + } +} diff --git a/windows/src/LeftOpen.Tray/LeftOpen.Tray.csproj b/windows/src/LeftOpen.Tray/LeftOpen.Tray.csproj new file mode 100644 index 0000000..4c0e48e --- /dev/null +++ b/windows/src/LeftOpen.Tray/LeftOpen.Tray.csproj @@ -0,0 +1,18 @@ + + + + + + + + WinExe + LeftOpenApp + LeftOpen.Tray + net8.0-windows + enable + true + enable + 0.1.0 + + + diff --git a/windows/src/LeftOpen.Tray/PanelForm.cs b/windows/src/LeftOpen.Tray/PanelForm.cs new file mode 100644 index 0000000..620edfd --- /dev/null +++ b/windows/src/LeftOpen.Tray/PanelForm.cs @@ -0,0 +1,679 @@ +using System.Diagnostics; +using System.Security.Principal; +using LeftOpen.Core; +using Activity = LeftOpen.Core.Activity; + +namespace LeftOpen.Tray; + +/// +/// The popup panel anchored above the tray, mirroring leftopen's MenuBarExtra panel: +/// opens on tray click, scans once on open (never polls), grouped rows with real +/// icons, and a two-step confirm before any gentle close. +/// +internal sealed class PanelForm : Form +{ + internal const int PanelWidth = 392; + private const int PanelHeight = 480; + + private readonly Action _onScanFinished; + private readonly Label _statusLabel; + private readonly PictureBox _doorGlyph; + private readonly TextBox _searchBox; + private readonly Panel _content; + private readonly Label _footer; + private readonly Button _refreshButton; + private readonly System.Windows.Forms.Timer _deferredHide = new() { Interval = 250 }; + + private readonly CloseService _closeService = new(); + private ScanResult? _result; + private DateTime? _scannedAt; + private bool _scanning; + private List _groups = []; + private Control? _currentView; + private bool _notClosableExpanded; + private string _searchText = ""; + + public string? CurrentUserSid { get; } = WindowsIdentity.GetCurrent().User?.Value; + + public bool IsStale => _scannedAt == null || DateTime.Now - _scannedAt > TimeSpan.FromSeconds(20); + + public PanelForm(Action onScanFinished) + { + _onScanFinished = onScanFinished; + + FormBorderStyle = FormBorderStyle.None; + ShowInTaskbar = false; + StartPosition = FormStartPosition.Manual; + TopMost = true; + Size = new Size(PanelWidth, PanelHeight); + MinimumSize = new Size(PanelWidth, 380); + MaximumSize = new Size(PanelWidth, 720); + BackColor = SystemColors.Window; + AutoScaleMode = AutoScaleMode.Dpi; + Font = new Font("Segoe UI", 9f); + + _doorGlyph = new PictureBox + { + Size = new Size(26, 26), + SizeMode = PictureBoxSizeMode.Zoom, + Location = new Point(14, 12), + }; + + _statusLabel = new Label + { + AutoSize = false, + Location = new Point(48, 10), + Size = new Size(PanelWidth - 140, 30), + TextAlign = ContentAlignment.MiddleLeft, + ForeColor = SystemColors.ControlText, + Text = "LeftOpen", + }; + + _refreshButton = new Button + { + Text = "↻", + Font = new Font("Segoe UI", 11f, FontStyle.Bold), + FlatStyle = FlatStyle.Flat, + Size = new Size(36, 30), + Location = new Point(PanelWidth - 52, 10), + TabStop = false, + }; + _refreshButton.FlatAppearance.BorderSize = 0; + _refreshButton.FlatAppearance.MouseOverBackColor = SystemColors.ControlLight; + _refreshButton.Click += (_, _) => Scan(); + + _searchBox = new TextBox + { + Location = new Point(14, 46), + Size = new Size(PanelWidth - 30, 24), + PlaceholderText = "筛选端口、进程、项目…", + }; + _searchBox.TextChanged += (_, _) => + { + _searchText = _searchBox.Text.Trim(); + if (_result != null && _currentView is not null && _currentView.Name == "listView") + { + ShowListView(); + } + }; + + _content = new Panel + { + Location = new Point(0, 78), + Size = new Size(PanelWidth, PanelHeight - 110), + AutoScroll = true, + BackColor = SystemColors.Window, + }; + + _footer = new Label + { + Dock = DockStyle.Bottom, + Height = 28, + TextAlign = ContentAlignment.MiddleRight, + ForeColor = SystemColors.GrayText, + Padding = new Padding(0, 6, 14, 0), + Text = "尚未扫描", + }; + + Controls.Add(_content); + Controls.Add(_footer); + Controls.Add(_searchBox); + Controls.Add(_refreshButton); + Controls.Add(_statusLabel); + Controls.Add(_doorGlyph); + + Resize += (_, _) => + { + _content.Size = new Size(Width, Height - 110); + _refreshButton.Location = new Point(Width - 52, 10); + _statusLabel.Width = Width - 140; + _searchBox.Width = Width - 30; + }; + + Deactivate += OnDeactivated; + _deferredHide.Tick += (_, _) => + { + _deferredHide.Stop(); + if (!IsActiveView()) + { + Hide(); + } + }; + + ShowView(new Label + { + Name = "idle", + Text = "点击 ↻ 扫描 localhost。", + Dock = DockStyle.Fill, + TextAlign = ContentAlignment.TopCenter, + Padding = new Padding(0, 16, 0, 0), + ForeColor = SystemColors.GrayText, + }); + } + + /// Opens the panel near the tray icon; scans on open, like MenuBarExtra. + public void OpenFromTray(bool rescan) + { + var area = Screen.FromPoint(Cursor.Position).WorkingArea; + Location = new Point(area.Right - Width - 12, area.Bottom - Height - 12); + + Show(); + Activate(); + if (rescan) + { + Scan(); + } + } + + private void OnDeactivated(object? sender, EventArgs e) + { + // During confirm/progress views we must not vanish while a sub-dialog has focus. + if (_currentView is { Name: "confirmView" or "progressView" }) + { + return; + } + + _deferredHide.Stop(); + _deferredHide.Start(); + } + + private bool IsActiveView() => _currentView is { Name: "confirmView" or "progressView" } || ContainsFocus || Focused; + + public void Scan() + { + if (_scanning) + { + return; + } + + _scanning = true; + _refreshButton.Enabled = false; + ShowView(new Label + { + Name = "scanningView", + Text = "正在扫描此电脑…", + Dock = DockStyle.Fill, + TextAlign = ContentAlignment.TopCenter, + Padding = new Padding(0, 16, 0, 0), + ForeColor = SystemColors.GrayText, + }); + + Task.Run(() => + { + ScanResult result; + try + { + result = Scanner.ScanActivities(); + } + catch (Exception ex) + { + result = new ScanResult([], [$"扫描失败:{ex.Message}"]); + } + + BeginInvoke(() => + { + _scanning = false; + _refreshButton.Enabled = true; + _result = result; + _scannedAt = DateTime.Now; + _footer.Text = $"扫描于 {DateTime.Now:HH:mm:ss}"; + _groups = BuildGroups(result); + UpdateHeader(); + _onScanFinished(result); + ShowListView(); + }); + }); + } + + private void UpdateHeader() + { + var result = _result; + if (result == null) + { + return; + } + + var closable = _groups.Count(g => g.Closable); + var ports = result.Activities.Select(a => a.Facts.Listener.Port).Distinct().Count(); + var lan = result.Activities.Where(a => a.Facts.Listener.Scope == Scope.Lan) + .Select(a => a.Facts.Listener.Port).Distinct().Count(); + _statusLabel.Text = $"{closable} 可关闭 · {ports} 监听 · {lan} LAN"; + _doorGlyph.Image = RenderDoorGlyph(closable > 0); + } + + private Image RenderDoorGlyph(bool open) + { + var bitmap = new Bitmap(26, 26); + using var g = Graphics.FromImage(bitmap); + g.Clear(Color.Transparent); + DoorIcon.Paint(g, new Rectangle(0, 0, 26, 26), open, SystemColors.ControlText); + return bitmap; + } + + // ---- grouping ----------------------------------------------------------- + + internal sealed record GroupRow( + int Pid, + string Label, + Category Category, + string Command, + IReadOnlyList Activities, + IReadOnlyList Ports, + bool AnyLan, + bool Closable, + string? RefusalReason, + DateTime? StartTime, + string? ExecutablePath, + string? ProjectRoot); + + private static List BuildGroups(ScanResult result) + { + var currentSid = WindowsIdentity.GetCurrent().User?.Value; + var currentPid = Environment.ProcessId; + + return result.Activities + .GroupBy(a => a.Facts.Process.Pid) + .Select(pids => + { + var activities = pids.OrderBy(a => a.Facts.Listener.Port).ToList(); + var first = activities[0]; + var process = first.Facts.Process; + var closable = CloseService.DescribeClosability(first, currentSid, currentPid, out var refusal); + return new GroupRow( + process.Pid, + first.Inference.Label, + first.Inference.Category, + process.Command, + activities, + activities.Select(a => a.Facts.Listener.Port).Distinct().ToList(), + activities.Any(a => a.Facts.Listener.Scope == Scope.Lan), + closable, + refusal, + process.StartTime, + process.ExecutablePath, + first.Facts.ProjectMarker?.Root); + }) + .OrderBy(g => g.Category == Category.Project ? 0 : 1) + .ThenBy(g => g.Ports[0]) + .ToList(); + } + + // ---- views -------------------------------------------------------------- + + private void ShowView(Control view) + { + if (_currentView != null) + { + _content.Controls.Remove(_currentView); + _currentView.Dispose(); + } + + _currentView = view; + _content.Controls.Add(view); + view.Dock = DockStyle.Fill; + _content.AutoScrollPosition = Point.Empty; + } + + private void ShowListView() + { + var view = new Panel { Name = "listView", AutoScroll = false, BackColor = SystemColors.Window }; + var flow = new FlowLayoutPanel + { + Dock = DockStyle.Fill, + FlowDirection = FlowDirection.TopDown, + WrapContents = false, + AutoScroll = true, + }; + + var text = _searchText; + var filtered = _groups.Where(g => Matches(g, text)).ToList(); + if (filtered.Count == 0 && _groups.Count > 0) + { + flow.Controls.Add(new Label + { + Text = "没有匹配的监听进程。", + AutoSize = true, + Margin = new Padding(16, 12, 16, 0), + ForeColor = SystemColors.GrayText, + }); + } + + foreach (var section in Sections(filtered)) + { + if (section.Groups.Count == 0) + { + continue; + } + + flow.Controls.Add(new Label + { + Text = section.Title, + AutoSize = true, + Margin = new Padding(16, 14, 16, 4), + ForeColor = SystemColors.GrayText, + Font = new Font("Segoe UI", 8.5f, FontStyle.Bold), + }); + + if (section.Collapsed) + { + var toggle = new LinkLabel + { + Text = _notClosableExpanded ? "收起 ▴" : $"展开 {section.Groups.Count} 项 ▾", + AutoSize = true, + Margin = new Padding(24, 2, 16, 4), + }; + toggle.LinkClicked += (_, _) => + { + _notClosableExpanded = !_notClosableExpanded; + ShowListView(); + }; + if (!_notClosableExpanded) + { + flow.Controls.Add(toggle); + continue; + } + } + + foreach (var group in section.Groups) + { + flow.Controls.Add(new GroupRowControl(group, ShowDetail, CloseWithConfirm)); + } + } + + if (_result is { Limitations.Count: > 0 }) + { + flow.Controls.Add(new Label + { + Text = "⚠ " + string.Join(" ", _result.Limitations), + AutoSize = true, + MaximumSize = new Size(PanelWidth - 40, 0), + Margin = new Padding(16, 12, 16, 0), + ForeColor = Color.FromArgb(150, 110, 20), + }); + } + + view.Controls.Add(flow); + ShowView(view); + } + + private IEnumerable<(string Title, List Groups, bool Collapsed)> Sections(List groups) + { + yield return ("我的项目", groups.Where(g => g.Category == Category.Project && g.Closable).ToList(), false); + yield return ("其他进程", groups.Where(g => g.Category != Category.Project && g.Closable).ToList(), false); + yield return ("不可关闭(系统或应用)", groups.Where(g => !g.Closable).ToList(), true); + } + + private static bool Matches(GroupRow g, string text) + { + if (text.Length == 0) + { + return true; + } + + return g.Ports.Any(p => p.ToString().Contains(text, StringComparison.OrdinalIgnoreCase)) || + g.Pid.ToString().Contains(text, StringComparison.OrdinalIgnoreCase) || + g.Label.Contains(text, StringComparison.OrdinalIgnoreCase) || + g.Command.Contains(text, StringComparison.OrdinalIgnoreCase) || + (g.ProjectRoot?.Contains(text, StringComparison.OrdinalIgnoreCase) ?? false); + } + + private void ShowDetail(GroupRow group) + { + var view = new Panel { Name = "detailView", AutoScroll = true }; + var flow = new FlowLayoutPanel + { + Dock = DockStyle.Fill, + FlowDirection = FlowDirection.TopDown, + WrapContents = false, + AutoScroll = true, + }; + + var back = new LinkLabel { Text = "‹ 返回", AutoSize = true, Margin = new Padding(16, 12, 16, 6) }; + back.LinkClicked += (_, _) => ShowListView(); + flow.Controls.Add(back); + + var facts = group.Activities[0].Facts; + var inference = group.Activities[0].Inference; + var uptime = group.StartTime != null ? DescribeDuration(DateTime.Now - group.StartTime.Value) : "未知"; + + flow.Controls.Add(new Label + { + Text = group.Label, + AutoSize = true, + Margin = new Padding(16, 2, 16, 2), + Font = new Font("Segoe UI", 13f, FontStyle.Bold), + }); + flow.Controls.Add(new Label + { + Text = $"{CategoryTitle(inference.Category)} · {ConfidenceText(inference.Confidence)} · 运行 {uptime}", + AutoSize = true, + Margin = new Padding(16, 0, 16, 10), + ForeColor = SystemColors.GrayText, + }); + + foreach (var activity in group.Activities) + { + var f = activity.Facts; + flow.Controls.Add(new Label + { + Text = $"端口 {f.Listener.Port} {(f.Listener.Scope == Scope.Local ? "仅本机" : "局域网可见")} {string.Join(", ", f.Listener.Addresses)}", + AutoSize = true, + Margin = new Padding(16, 2, 16, 2), + Font = new Font("Consolas", 10f, FontStyle.Bold), + }); + } + + flow.Controls.Add(new Label + { + Text = InferenceBlock(facts, inference.Reason), + AutoSize = true, + MaximumSize = new Size(PanelWidth - 40, 0), + Margin = new Padding(16, 10, 16, 10), + ForeColor = SystemColors.ControlText, + }); + + if (group.Closable) + { + var close = new Button + { + Text = $"关闭进程 (PID {group.Pid})…", + AutoSize = true, + FlatStyle = FlatStyle.Flat, + Margin = new Padding(16, 4, 16, 8), + }; + close.FlatAppearance.BorderColor = Color.FromArgb(190, 60, 50); + close.ForeColor = Color.FromArgb(190, 60, 50); + close.Click += (_, _) => CloseWithConfirm(group); + flow.Controls.Add(close); + } + else if (group.RefusalReason != null) + { + flow.Controls.Add(new Label + { + Text = "⨯ 无法关闭:" + group.RefusalReason, + AutoSize = true, + MaximumSize = new Size(PanelWidth - 40, 0), + Margin = new Padding(16, 4, 16, 8), + ForeColor = SystemColors.GrayText, + }); + } + + view.Controls.Add(flow); + ShowView(view); + } + + private static string InferenceBlock(ActivityFacts facts, string reason) => + $""" + 进程 {facts.Process.Command} (PID {facts.Process.Pid}, PPID {facts.Process.ParentPid?.ToString() ?? "未知"}) + 用户 {facts.Process.OwnerName ?? facts.Process.OwnerSid ?? "未知"} + 可执行 {Ownership.CompactPath(facts.Process.ExecutablePath)} + 工作目录 {Ownership.CompactPath(facts.Process.Cwd)} + 项目标记 {(facts.ProjectMarker != null ? $"{facts.ProjectMarker.Source} — {Ownership.CompactPath(facts.ProjectMarker.MarkerPath)}" : "无")} + 依据 {reason} + """; + + private void CloseWithConfirm(GroupRow group) + { + var view = new Panel { Name = "confirmView" }; + var flow = new FlowLayoutPanel + { + Dock = DockStyle.Fill, + FlowDirection = FlowDirection.TopDown, + WrapContents = false, + AutoScroll = true, + }; + + var facts = group.Activities[0].Facts; + var uptime = group.StartTime != null ? DescribeDuration(DateTime.Now - group.StartTime.Value) : "未知"; + + flow.Controls.Add(new Label + { + Text = $"关闭 PID {group.Pid}?", + AutoSize = true, + Margin = new Padding(16, 14, 16, 4), + Font = new Font("Segoe UI", 13f, FontStyle.Bold), + }); + flow.Controls.Add(new Label + { + Text = $"{group.Label} · {facts.Process.Command} · 运行 {uptime}", + AutoSize = true, + Margin = new Padding(16, 0, 16, 10), + ForeColor = SystemColors.GrayText, + }); + flow.Controls.Add(new Label + { + Text = $"可执行文件\n{Ownership.CompactPath(facts.Process.ExecutablePath)}\n\n端口\n{string.Join("、", group.Ports)}", + AutoSize = true, + MaximumSize = new Size(PanelWidth - 40, 0), + Margin = new Padding(16, 0, 16, 10), + }); + + if (group.Ports.Count > 1) + { + flow.Controls.Add(new Label + { + Text = $"⚠ 该进程还监听其他端口({string.Join("、", group.Ports.Skip(1))}),关闭后它们可能一起释放。", + AutoSize = true, + MaximumSize = new Size(PanelWidth - 40, 0), + Margin = new Padding(16, 0, 16, 4), + ForeColor = Color.FromArgb(150, 110, 20), + }); + } + + flow.Controls.Add(new Label + { + Text = "将发送温和关闭(等同在该进程的终端里按 Ctrl+C),绝不使用强杀;若它不响应,会如实告诉你。", + AutoSize = true, + MaximumSize = new Size(PanelWidth - 40, 0), + Margin = new Padding(16, 4, 16, 12), + ForeColor = SystemColors.GrayText, + }); + + var cancel = new Button { Text = "取消", AutoSize = true, FlatStyle = FlatStyle.Flat, Margin = new Padding(16, 0, 8, 0) }; + cancel.Click += (_, _) => ShowListView(); + + var confirm = new Button + { + Text = "关闭", + AutoSize = true, + FlatStyle = FlatStyle.Flat, + ForeColor = Color.White, + BackColor = Color.FromArgb(200, 60, 50), + Margin = new Padding(8, 0, 16, 0), + }; + confirm.Click += async (_, _) => await ExecuteClose(group); + + var buttons = new FlowLayoutPanel { AutoSize = true, Margin = new Padding(12, 0, 0, 0) }; + buttons.Controls.Add(cancel); + buttons.Controls.Add(confirm); + flow.Controls.Add(buttons); + + view.Controls.Add(flow); + ShowView(view); + } + + private async Task ExecuteClose(GroupRow group) + { + var view = new Panel { Name = "progressView" }; + var label = new Label + { + Text = "正在发送温和关闭并检查端口…", + Dock = DockStyle.Fill, + TextAlign = ContentAlignment.TopCenter, + Padding = new Padding(0, 20, 0, 0), + ForeColor = SystemColors.GrayText, + }; + view.Controls.Add(label); + ShowView(view); + + try + { + var plan = await _closeService.PrepareCloseAsync( + new CloseOptions(group.Ports[0], group.Pid, CurrentUserSid, Environment.ProcessId)); + var result = await _closeService.ExecuteCloseAsync(plan); + + if (result.PortFree) + { + label.Text = "端口已释放 ✓"; + } + else if (result.TargetStoppedListening) + { + label.Text = $"PID {group.Pid} 已停止监听,但端口被 {string.Join(", ", result.RemainingPids)} 接管。"; + } + else if (!result.SignalsDelivered) + { + label.Text = $"无法向 PID {group.Pid} 送达温和关闭(无可达的窗口或控制台),进程未受影响,未强杀。"; + } + else + { + label.Text = $"已发送温和关闭,但 PID {group.Pid} 仍在监听。未强杀。"; + } + } + catch (CloseRefusedException ex) + { + label.Text = "已拒绝关闭:" + ex.Message; + } + catch (Exception ex) + { + label.Text = "关闭失败:" + ex.Message; + } + + await Task.Delay(1200); + Scan(); + } + + private static string CategoryTitle(Category category) => category switch + { + Category.Project => "我的项目", + Category.Application => "应用", + Category.SystemService => "系统服务", + _ => "未知", + }; + + private static string ConfidenceText(Confidence confidence) => confidence switch + { + Confidence.High => "高置信", + Confidence.Medium => "中置信", + _ => "无置信", + }; + + private static string DescribeDuration(TimeSpan span) + { + if (span.TotalMinutes < 1) + { + return $"{(int)span.TotalSeconds}秒"; + } + + if (span.TotalHours < 1) + { + return $"{(int)span.TotalMinutes}分钟"; + } + + if (span.TotalDays < 1) + { + return $"{(int)span.TotalHours}小时{(int)span.TotalMinutes % 60}分"; + } + + return $"{(int)span.TotalDays}天{(int)span.TotalHours % 24}小时"; + } +} diff --git a/windows/src/LeftOpen.Tray/Program.cs b/windows/src/LeftOpen.Tray/Program.cs new file mode 100644 index 0000000..8e16949 --- /dev/null +++ b/windows/src/LeftOpen.Tray/Program.cs @@ -0,0 +1,196 @@ +using LeftOpen.Core; + +namespace LeftOpen.Tray; + +static class Program +{ + /// Named event a second launch uses to ask the running instance to show its panel. + internal const string OpenPanelEventName = @"Local\LeftOpenApp.OpenPanel"; + + /// + /// LeftOpen tray app: a door in the notification area. No window, no dock, + /// no background scanning — the scan runs only while the panel is open, + /// mirroring leftopen's MenuBarExtra philosophy. + /// + [STAThread] + static void Main() + { + using var singleInstance = new Mutex(true, @"Local\LeftOpenApp.SingleInstance", out var isFirst); + if (!isFirst) + { + // Already running: ask that instance to open its panel (so a shortcut or + // a second launch brings the panel up instead of doing nothing). + if (EventWaitHandle.TryOpenExisting(OpenPanelEventName, out var openEvent)) + { + openEvent.Set(); + openEvent.Dispose(); + } + + return; + } + + using var panelEvent = new EventWaitHandle(false, EventResetMode.AutoReset, OpenPanelEventName); + + ApplicationConfiguration.Initialize(); + Thread.CurrentThread.Name = "ui"; + + var context = new TrayContext(); + var listener = new Thread(() => + { + while (true) + { + if (!panelEvent.WaitOne()) + { + return; + } + + context.OpenPanel(); + } + }) + { + IsBackground = true, + Name = "open-panel-listener", + }; + listener.Start(); + + Application.Run(context); + GC.KeepAlive(singleInstance); + } +} + +/// Owns the NotifyIcon for the whole app lifetime; the panel is created on demand. +internal sealed class TrayContext : ApplicationContext +{ + private readonly NotifyIcon _notifyIcon; + private readonly PanelForm _panel; + private bool _disposed; + + public TrayContext() + { + _panel = new PanelForm(OnScanFinished); + _ = _panel.Handle; // realize the window handle so BeginInvoke works from other threads + + var menu = new ContextMenuStrip(); + menu.Items.Add("刷新(&R)", null, (_, _) => _panel.OpenFromTray(rescan: true)); + menu.Items.Add(new ToolStripSeparator()); + menu.Items.Add("开机自启动(&S)", null, (_, _) => ToggleStartup()); + menu.Items.Add(new ToolStripSeparator()); + menu.Items.Add("退出(&Q)", null, (_, _) => ExitThread()); + + _notifyIcon = new NotifyIcon + { + Icon = DoorIcon.Closed(32), + Text = "LeftOpen — 查看遗留进程", + ContextMenuStrip = menu, + Visible = true, + }; + _notifyIcon.MouseClick += OnTrayMouseClick; + + Application.ApplicationExit += OnApplicationExit; + } + + private void OnTrayMouseClick(object? sender, MouseEventArgs e) + { + if (e.Button == MouseButtons.Left) + { + OpenPanel(); + } + } + + /// Shows the panel, scanning only when the previous scan is stale. Safe from any thread. + public void OpenPanel() + { + if (_panel.InvokeRequired) + { + _panel.BeginInvoke(OpenPanel); + return; + } + + _panel.OpenFromTray(rescan: _panel.IsStale); + } + + private void OnScanFinished(ScanResult result) + { + if (_disposed) + { + return; + } + + var closable = result.Activities + .Select(a => a.Facts.Process.Pid) + .Distinct() + .Count(pid => result.Activities.Any(a => + a.Facts.Process.Pid == pid && + CloseService.DescribeClosability(a, _panel.CurrentUserSid, Environment.ProcessId, out _))); + + // Rebuild the tray icon for the current taskbar theme; dispose the old one. + var old = _notifyIcon.Icon; + _notifyIcon.Icon = closable > 0 ? DoorIcon.Open(32) : DoorIcon.Closed(32); + old?.Dispose(); + _notifyIcon.Text = closable > 0 + ? $"LeftOpen — {closable} 个可关闭的进程" + : "LeftOpen — localhost 干净"; + } + + private void ToggleStartup() + { + const string runKey = @"Software\Microsoft\Windows\CurrentVersion\Run"; + try + { + using var key = Microsoft.Win32.Registry.CurrentUser.OpenSubKey(runKey, writable: true); + if (key == null) + { + return; + } + + var value = (string?)key.GetValue("LeftOpen"); + if (string.IsNullOrEmpty(value)) + { + var exe = Environment.ProcessPath; + if (exe == null) + { + return; + } + + key.SetValue("LeftOpen", $"\"{exe}\""); + MessageBox.Show("已开启开机自启动。", "LeftOpen", MessageBoxButtons.OK, MessageBoxIcon.Information); + } + else + { + key.DeleteValue("LeftOpen"); + MessageBox.Show("已关闭开机自启动。", "LeftOpen", MessageBoxButtons.OK, MessageBoxIcon.Information); + } + } + catch (Exception ex) + { + MessageBox.Show($"切换自启动失败:{ex.Message}", "LeftOpen", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + + private void OnApplicationExit(object? sender, EventArgs e) => Dispose(); + + protected override void ExitThreadCore() + { + Dispose(); + base.ExitThreadCore(); + } + + protected override void Dispose(bool disposing) + { + if (_disposed) + { + return; + } + + _disposed = true; + if (disposing) + { + _panel.Dispose(); + _notifyIcon.Visible = false; + _notifyIcon.Icon?.Dispose(); + _notifyIcon.Dispose(); + } + + base.Dispose(disposing); + } +} diff --git a/windows/tests/LeftOpen.Core.Tests/CloseServiceTests.cs b/windows/tests/LeftOpen.Core.Tests/CloseServiceTests.cs new file mode 100644 index 0000000..ddec630 --- /dev/null +++ b/windows/tests/LeftOpen.Core.Tests/CloseServiceTests.cs @@ -0,0 +1,318 @@ +using LeftOpen.Core; + +namespace LeftOpen.Core.Tests; + +public class CloseServiceTests +{ + private const string CurrentSid = "S-1-5-21-current"; + private static readonly DateTime Started = new(2026, 1, 1, 12, 0, 0, DateTimeKind.Local); + + private static Activity Activity( + int pid, + int port, + string? exe = "C:\\dev\\myapp\\server.exe", + string? sid = CurrentSid, + ProjectMarkerFact? marker = null, + InstalledAppFact? app = null) + { + var process = new ActivityProcessFact(pid, 900, "server", exe, null, Started, sid, "DEV\\dev", marker != null ? "C:\\dev\\myapp" : null); + var facts = new ActivityFacts( + new ListenerFact(port, ["127.0.0.1"], Scope.Local), + process, + [], + marker, + app); + return new Activity(facts, new OwnerInference("x", Category.Unknown, Confidence.None, "test")); + } + + private static Listener ListenerFor(int pid, int port) => + new(pid, "server", CurrentSid, "DEV\\dev", port, ["127.0.0.1"]); + + /// Wires a CloseService with in-memory scan/listener state and counters. + private sealed class Harness + { + public List Snapshot { get; set; } = []; + + /// What the TCP listener poll reports (only called after each wait). + public Func> Listeners { get; set; } = () => []; + + public DateTime? StartTime { get; set; } = Started; + + public int Signalled { get; private set; } + + public bool SignalResult { get; set; } = true; + + public int Waits { get; private set; } + + public CloseService Service() => new( + scan: () => new ScanResult(Snapshot, []), + listeners: Listeners, + startTime: _ => StartTime, + signal: _ => + { + Signalled++; + return SignalResult; + }, + wait: _ => + { + Waits++; + return Task.CompletedTask; + }); + } + + private static CloseService RigidService() => new( + scan: () => new ScanResult([], []), + listeners: () => [], + startTime: _ => Started, + signal: _ => throw new InvalidOperationException("must not signal"), + wait: _ => Task.CompletedTask); + [Fact] + public void SelectCloseTarget_NoListener_Throws() + { + var ex = Assert.Throws( + () => RigidService().SelectCloseTarget([], new CloseOptions(3000, null, CurrentSid, 555))); + Assert.Contains("Nothing is listening", ex.Message); + } + + [Fact] + public void SelectCloseTarget_MultiplePidsRequireExplicitPid() + { + var activities = new[] { Activity(100, 3000), Activity(101, 3000) }; + var ex = Assert.Throws( + () => RigidService().SelectCloseTarget(activities, new CloseOptions(3000, null, CurrentSid, 555))); + Assert.Contains("multiple owning PIDs", ex.Message); + } + + [Fact] + public void SelectCloseTarget_ProtectedPidsAreRefused() + { + Assert.Throws(() => + RigidService().SelectCloseTarget([Activity(4, 3000)], new CloseOptions(3000, null, CurrentSid, 555))); + Assert.Throws(() => + RigidService().SelectCloseTarget([Activity(555, 3000)], new CloseOptions(3000, null, CurrentSid, 555))); + } + + [Theory] + [InlineData(null, "no verified owner SID")] + [InlineData("S-1-5-21-someone-else", "belongs to another user")] + public void SelectCloseTarget_OwnerVerification(string? sid, string expectedFragment) + { + var ex = Assert.Throws( + () => RigidService().SelectCloseTarget([Activity(100, 3000, sid: sid)], new CloseOptions(3000, null, CurrentSid, 555))); + Assert.Contains(expectedFragment, ex.Message); + } + + [Fact] + public void SelectCloseTarget_MissingExePathRefused() + { + var ex = Assert.Throws( + () => RigidService().SelectCloseTarget([Activity(100, 3000, exe: null)], new CloseOptions(3000, null, CurrentSid, 555))); + Assert.Contains("no verified executable path", ex.Message); + } + + [Fact] + public void SelectCloseTarget_SystemExecutableRefused() + { + var exe = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), "System32", "svchost.exe"); + var ex = Assert.Throws( + () => RigidService().SelectCloseTarget([Activity(100, 3000, exe: exe)], new CloseOptions(3000, null, CurrentSid, 555))); + Assert.Contains("operating-system", ex.Message); + } + + [Fact] + public void SelectCloseTarget_InstalledAppWithoutProjectRefused() + { + var app = new InstalledAppFact("Spotify", "C:\\Program Files\\Spotify", 100, true); + var ex = Assert.Throws( + () => RigidService().SelectCloseTarget( + [Activity(100, 3000, exe: "C:\\Program Files\\Spotify\\Spotify.exe", app: app)], + new CloseOptions(3000, null, CurrentSid, 555))); + Assert.Contains("installed application", ex.Message); + } + + [Fact] + public void SelectCloseTarget_RuntimeHostInProgramFilesIsAllowed() + { + var app = new InstalledAppFact("node", "C:\\Program Files\\nodejs", 100, true); + + var (activity, otherPorts, peerPids) = RigidService().SelectCloseTarget( + [Activity(100, 3000, exe: "C:\\Program Files\\nodejs\\node.exe", app: app)], + new CloseOptions(3000, null, CurrentSid, 555)); + + Assert.Equal(100, activity.Facts.Process.Pid); + Assert.Empty(otherPorts); + Assert.Empty(peerPids); + } + + [Fact] + public void SelectCloseTarget_NodeInsideProjectIsAllowed_EvenThoughNodeLivesInProgramFiles() + { + var app = new InstalledAppFact("node", "C:\\Program Files\\nodejs", 100, true); + var marker = new ProjectMarkerFact("myapp", "C:\\dev\\myapp", "git", "C:\\dev\\myapp\\.git"); + + var (activity, _, _) = RigidService().SelectCloseTarget( + [Activity(100, 3000, exe: "C:\\Program Files\\nodejs\\node.exe", marker: marker, app: app)], + new CloseOptions(3000, null, CurrentSid, 555)); + + Assert.Equal(100, activity.Facts.Process.Pid); + } + + [Fact] + public void SelectCloseTarget_ReportsOtherPortsAndPeers() + { + var activities = new[] { Activity(100, 3000), Activity(100, 3001), Activity(101, 3000) }; + + var (_, otherPorts, peerPids) = RigidService().SelectCloseTarget( + activities, new CloseOptions(3000, 100, CurrentSid, 555)); + + Assert.Equal([3001], otherPorts); + Assert.Equal([101], peerPids); + } + + [Fact] + public async Task PrepareCloseAsync_RequiresVerifiedStartTime() + { + var harness = new Harness + { + Snapshot = [Activity(100, 3000)], + StartTime = null, + }; + + var ex = await Assert.ThrowsAsync( + () => harness.Service().PrepareCloseAsync(new CloseOptions(3000, null, CurrentSid, 555))); + Assert.Contains("start time", ex.Message); + } + + [Fact] + public void VerifyCloseTarget_PidGoneFromPort_Refuses() + { + var plan = new ClosePlan(3000, 100, CurrentSid, "C:\\dev\\myapp\\server.exe", Started, + Activity(100, 3000), [], []); + + var ex = Assert.Throws( + () => RigidService().VerifyCloseTarget(plan, [], Started)); + Assert.Contains("no longer listens", ex.Message); + } + + [Fact] + public void VerifyCloseTarget_NewPeerPid_Refuses() + { + var plan = new ClosePlan(3000, 100, CurrentSid, "C:\\dev\\myapp\\server.exe", Started, + Activity(100, 3000), [], []); + + var activities = new[] { Activity(100, 3000), Activity(101, 3000) }; + var ex = Assert.Throws( + () => RigidService().VerifyCloseTarget(plan, activities, Started)); + Assert.Contains("new owning PID", ex.Message); + } + + [Fact] + public void VerifyCloseTarget_IdentityChange_Refuses() + { + var plan = new ClosePlan(3000, 100, CurrentSid, "C:\\dev\\myapp\\server.exe", Started, + Activity(100, 3000), [], []); + + var changedTime = Started.AddMinutes(3); + var ex = Assert.Throws( + () => RigidService().VerifyCloseTarget(plan, [Activity(100, 3000)], changedTime)); + Assert.Contains("changed identity", ex.Message); + + // Same PID, different owner SID (PID reuse): also refused. + var exSid = Assert.Throws( + () => RigidService().VerifyCloseTarget(plan, [Activity(100, 3000, sid: "S-1-5-21-other")], Started)); + Assert.Contains("changed identity", exSid.Message); + } + + [Fact] + public async Task ExecuteCloseAsync_HappyPath_SignalsOnceAndPortFrees() + { + var harness = new Harness + { + Snapshot = [Activity(100, 3000)], + Listeners = () => [], + }; + var service = harness.Service(); + var plan = await service.PrepareCloseAsync(new CloseOptions(3000, null, CurrentSid, 555)); + + var result = await service.ExecuteCloseAsync(plan); + + Assert.Equal(1, harness.Signalled); + Assert.True(result.TargetStoppedListening); + Assert.True(result.PortFree); + Assert.Empty(result.RemainingPids); + Assert.Equal(1, harness.Waits); + } + + [Fact] + public async Task ExecuteCloseAsync_StubbornProcess_ReportsHonestlyWithoutForce() + { + var harness = new Harness + { + Snapshot = [Activity(100, 3000)], + Listeners = () => [ListenerFor(100, 3000)], + }; + var service = harness.Service(); + var plan = await service.PrepareCloseAsync(new CloseOptions(3000, null, CurrentSid, 555)); + + var result = await service.ExecuteCloseAsync(plan); + + Assert.Equal(1, harness.Signalled); + Assert.False(result.TargetStoppedListening); + Assert.False(result.PortFree); + Assert.Equal([100], result.RemainingPids); + Assert.Equal(10, harness.Waits); // polled the full window, never escalated to force + } + + [Fact] + public async Task ExecuteCloseAsync_VerificationFailure_SignalsNothing() + { + var harness = new Harness { Snapshot = [Activity(100, 3000)] }; + var service = harness.Service(); + var plan = await service.PrepareCloseAsync(new CloseOptions(3000, null, CurrentSid, 555)); + + // The PID disappears between preview and execution. + harness.Snapshot = []; + + await Assert.ThrowsAsync(() => service.ExecuteCloseAsync(plan)); + Assert.Equal(0, harness.Signalled); + Assert.Equal(0, harness.Waits); + } + + [Fact] + public async Task ExecuteCloseAsync_NoDeliveryPath_ReportsHonestly() + { + var harness = new Harness + { + Snapshot = [Activity(100, 3000)], + Listeners = () => [ListenerFor(100, 3000)], + SignalResult = false, + }; + var service = harness.Service(); + var plan = await service.PrepareCloseAsync(new CloseOptions(3000, null, CurrentSid, 555)); + + var result = await service.ExecuteCloseAsync(plan); + + Assert.False(result.SignalsDelivered); + Assert.False(result.TargetStoppedListening); + Assert.Equal([100], result.RemainingPids); + } + + [Fact] + public async Task ExecuteCloseAsync_PortTakenOverByPeer_ReportsRemainingPid() + { + var harness = new Harness + { + Snapshot = [Activity(100, 3000)], + Listeners = () => [ListenerFor(101, 3000)], + }; + var service = harness.Service(); + var plan = await service.PrepareCloseAsync(new CloseOptions(3000, null, CurrentSid, 555)); + + // Target PID 100 exits, but PID 101 immediately claims the port. + var result = await service.ExecuteCloseAsync(plan); + + Assert.True(result.TargetStoppedListening); + Assert.False(result.PortFree); + Assert.Equal([101], result.RemainingPids); + } +} diff --git a/windows/tests/LeftOpen.Core.Tests/LeftOpen.Core.Tests.csproj b/windows/tests/LeftOpen.Core.Tests/LeftOpen.Core.Tests.csproj new file mode 100644 index 0000000..39137b3 --- /dev/null +++ b/windows/tests/LeftOpen.Core.Tests/LeftOpen.Core.Tests.csproj @@ -0,0 +1,27 @@ + + + + net8.0-windows + enable + enable + + false + true + + + + + + + + + + + + + + + + + + diff --git a/windows/tests/LeftOpen.Core.Tests/OwnershipTests.cs b/windows/tests/LeftOpen.Core.Tests/OwnershipTests.cs new file mode 100644 index 0000000..1b4fd87 --- /dev/null +++ b/windows/tests/LeftOpen.Core.Tests/OwnershipTests.cs @@ -0,0 +1,173 @@ +using LeftOpen.Core; + +namespace LeftOpen.Core.Tests; + +public class OwnershipTests +{ + private static readonly IReadOnlyList AppRoots = ["C:\\Program Files", "C:\\Users\\dev\\AppData\\Local\\Programs"]; + + private static ActivityFacts Facts( + int pid = 100, + int? parentPid = null, + string? exe = null, + ProjectMarkerFact? marker = null, + InstalledAppFact? app = null, + string[]? addresses = null, + IReadOnlyList? parents = null) + { + var process = new ActivityProcessFact(pid, parentPid, "cmd", exe, null, null, "S-1-5-21-x", "DEV\\dev", null); + return new ActivityFacts( + new ListenerFact(3000, addresses ?? ["127.0.0.1"], Scope.Local), + process, + parents ?? [], + marker, + app); + } + + [Fact] + public void Infer_ProjectWinsOverEverything() + { + var marker = new ProjectMarkerFact("myapp", "C:\\dev\\myapp", "git", "C:\\dev\\myapp\\.git"); + var app = new InstalledAppFact("node", "C:\\Program Files\\nodejs", 100, true); + var facts = Facts(marker: marker, app: app); + + var inference = Ownership.Infer(facts); + + Assert.Equal(Category.Project, inference.Category); + Assert.Equal(Confidence.High, inference.Confidence); + Assert.Equal("myapp", inference.Label); + Assert.Contains("git", inference.Reason); + } + + [Fact] + public void Infer_DirectAppIsHighConfidence() + { + var app = new InstalledAppFact("Spotify", "C:\\Program Files\\Spotify", 100, true); + var inference = Ownership.Infer(Facts(app: app)); + + Assert.Equal(Category.Application, inference.Category); + Assert.Equal(Confidence.High, inference.Confidence); + Assert.Equal("Spotify", inference.Label); + } + + [Fact] + public void Infer_AncestorAppIsMediumConfidence() + { + var app = new InstalledAppFact("Code", "C:\\Users\\dev\\AppData\\Local\\Programs\\Microsoft VS Code", 99, false); + var inference = Ownership.Infer(Facts(app: app)); + + Assert.Equal(Category.Application, inference.Category); + Assert.Equal(Confidence.Medium, inference.Confidence); + Assert.Contains("99", inference.Reason); + } + + [Fact] + public void Infer_SystemExecutable_IsSystemService() + { + var exe = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), "System32", "svchost.exe"); + var inference = Ownership.Infer(Facts(exe: exe)); + + Assert.Equal(Category.SystemService, inference.Category); + Assert.Equal(Confidence.High, inference.Confidence); + Assert.Equal("svchost.exe", inference.Label); + } + + [Fact] + public void Infer_NoEvidence_IsUnknown() + { + var inference = Ownership.Infer(Facts(exe: "C:\\tools\\some-server.exe")); + + Assert.Equal(Category.Unknown, inference.Category); + Assert.Equal(Confidence.None, inference.Confidence); + } + + [Theory] + [InlineData("127.0.0.1", Scope.Local)] + [InlineData("::1", Scope.Local)] + [InlineData("localhost", Scope.Local)] + [InlineData("0.0.0.0", Scope.Lan)] + [InlineData("192.168.1.5", Scope.Lan)] + public void ListenerScope_ClassifiesAddresses(string address, Scope expected) + { + Assert.Equal(expected, Ownership.ListenerScope([address])); + } + + [Fact] + public void ListenerScope_AllLoopbackIsLocal_AnyOtherIsLan() + { + Assert.Equal(Scope.Local, Ownership.ListenerScope(["127.0.0.1", "::1"])); + Assert.Equal(Scope.Lan, Ownership.ListenerScope(["127.0.0.1", "0.0.0.0"])); + } + + [Theory] + [InlineData("C:\\Program Files\\nodejs\\node.exe", true)] + [InlineData("C:\\Program Files\\Python312\\python.exe", true)] + [InlineData("C:\\Program Files\\Spotify\\Spotify.exe", false)] + [InlineData(null, false)] + public void IsRuntimeHost_IdentifiesSharedRuntimes(string? exe, bool expected) + { + Assert.Equal(expected, Ownership.IsRuntimeHost(exe)); + } + + [Fact] + public void InstalledAppFromPath_DetectsAppInsideProvidedRoots() + { + var app = Ownership.InstalledAppFromPath("C:\\Program Files\\Spotify\\Spotify.exe", 42, true, AppRoots); + + Assert.NotNull(app); + Assert.Equal("Spotify", app!.Name); + Assert.True(app.DirectProcess); + Assert.Equal(42, app.SourcePid); + } + + [Fact] + public void InstalledAppFromPath_IgnoresPathsOutsideAppRoots() + { + Assert.Null(Ownership.InstalledAppFromPath("C:\\dev\\myapp\\server.exe", 42, true, AppRoots)); + Assert.Null(Ownership.InstalledAppFromPath("relative\\path.exe", 42, true, AppRoots)); + Assert.Null(Ownership.InstalledAppFromPath(null, 42, true, AppRoots)); + } + + [Fact] + public void ParentChainFor_WalksUpAndStopsOnCycle() + { + // a(1) ← b(2) ← a(1): a cycle must terminate, not hang. + var table = new Dictionary + { + [1] = new ProcessFact(1, 2, "a", null, null, null), + [2] = new ProcessFact(2, 1, "b", null, null, null), + }; + + var chain = Ownership.ParentChainFor(table[1], table); + + Assert.True(chain.Count <= 2); + } + + [Fact] + public void ParentChainFor_RespectsDepthLimit() + { + var table = new Dictionary(); + for (var pid = 1; pid <= 30; pid++) + { + table[pid] = new ProcessFact(pid, pid + 1, $"p{pid}", null, null, null); + } + table[31] = new ProcessFact(31, null, "root", null, null, null); + + var chain = Ownership.ParentChainFor(table[1], table); + + Assert.Equal(16, chain.Count); + } + + [Fact] + public void ParentChainFor_MissingParentStopsChain() + { + var table = new Dictionary + { + [1] = new ProcessFact(1, 999, "a", null, null, null), // parent not in table + }; + + var chain = Ownership.ParentChainFor(table[1], table); + + Assert.Empty(chain); + } +} diff --git a/windows/tests/LeftOpen.Core.Tests/ProjectDetectorTests.cs b/windows/tests/LeftOpen.Core.Tests/ProjectDetectorTests.cs new file mode 100644 index 0000000..32300e4 --- /dev/null +++ b/windows/tests/LeftOpen.Core.Tests/ProjectDetectorTests.cs @@ -0,0 +1,157 @@ +using LeftOpen.Core; + +namespace LeftOpen.Core.Tests; + +public class ProjectDetectorTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "leftopen-tests-" + Guid.NewGuid().ToString("N")); + + private ProjectDetector.RejectionContext Context(string home) => new( + home, + [Path.Combine(_root, "Windows"), Path.Combine(_root, "Program Files")], + [Path.Combine(home, "AppData", "Local"), Path.Combine(home, "AppData", "Roaming")]); + + private string Dir(params string[] segments) + { + var path = Path.Combine(new[] { _root }.Concat(segments).ToArray()); + Directory.CreateDirectory(path); + return path; + } + + private static string File(string dir, string name, string contents = "") + { + var path = Path.Combine(dir, name); + System.IO.File.WriteAllText(path, contents); + return path; + } + + [Fact] + public void FindProject_WalksUpToGitMarker() + { + var project = Dir("code", "myapp"); + var deep = Dir("code", "myapp", "src"); + File(project, ".git"); + File(deep, "main.py"); + + var home = Dir("home"); + var marker = ProjectDetector.FindProject(deep, Context(home)); + + Assert.NotNull(marker); + Assert.Equal("myapp", marker!.Name); + Assert.Equal("git", marker.Source); + Assert.Equal(Path.Combine(project, ".git"), marker.MarkerPath); + } + + [Fact] + public void FindProject_PrefersPackageName_OverDirectoryName() + { + var project = Dir("code", "ugly-folder-name"); + File(project, "package.json", """{ "name": "pretty-app", "version": "1.0.0" }"""); + + var home = Dir("home"); + var marker = ProjectDetector.FindProject(project, Context(home)); + + Assert.NotNull(marker); + Assert.Equal("pretty-app", marker!.Name); + Assert.Equal("package.json", marker.Source); + } + + [Fact] + public void FindProject_PrefersPyprojectName() + { + var project = Dir("code", "pyapp"); + File(project, "pyproject.toml", """ + [tool.poetry] + name = "wrong-place" + + [project] + name = "real-name" + version = "0.1.0" + """); + + var home = Dir("home"); + var marker = ProjectDetector.FindProject(project, Context(home)); + + Assert.NotNull(marker); + Assert.Equal("real-name", marker!.Name); + Assert.Equal(Path.GetFullPath(project), Path.GetFullPath(marker.Root)); + } + + [Fact] + public void FindProject_RejectsNodeModules_AndAcceptsOuterMarker() + { + var project = Dir("code", "web"); + File(project, ".git"); + var nested = Dir("code", "web", "node_modules", "vite"); + File(nested, "package.json", """{ "name": "vite" }"""); + + var home = Dir("home"); + var marker = ProjectDetector.FindProject(nested, Context(home)); + + Assert.NotNull(marker); + Assert.Equal(Path.GetFullPath(project), Path.GetFullPath(marker!.Root)); + } + + [Theory] + [InlineData("AppData", "Local", "npm-cache", "pkg", true)] // user app-data tree + [InlineData("AppData", "Roaming", "npm", "x", true)] // npm globals + [InlineData(".cargo", "registry", "src", "x", true)] // hidden per-user tool tree + [InlineData("work", "cache", "item", "x", true)] // cache segment + [InlineData("Program Files", "SomeApp", "data", "x", false)] // os-managed tree + public void FindProject_RejectsManagedTrees(string a, string b, string c, string d, bool underHome) + { + var home = Dir("home"); + var cwd = underHome ? Dir("home", a, b, c, d) : Dir(a, b, c, d); + File(cwd, ".git"); + + var marker = ProjectDetector.FindProject(cwd, Context(home)); + + // The immediate .git is rejected; nothing above it qualifies, so no project. + Assert.Null(marker); + } + + [Fact] + public void FindProject_RejectsHomeRootMarker() + { + var home = Dir("home"); + File(home, ".git"); + var cwd = Dir("home", "scratch"); + // The walk-up from scratch sees home's .git but home itself must be rejected. + var marker = ProjectDetector.FindProject(cwd, Context(home)); + + Assert.Null(marker); + } + + [Fact] + public void FindProject_CwdOutsideRootIsRejected() + { + var project = Dir("code", "marked"); + File(project, ".git"); + var elsewhere = Dir("code", "unmarked"); + + var home = Dir("home"); + var marker = ProjectDetector.FindProject(elsewhere, Context(home)); + + Assert.Null(marker); + } + + [Fact] + public void FindProject_EmptyCwd_ReturnsNull() + { + var home = Dir("home"); + Assert.Null(ProjectDetector.FindProject(null, Context(home))); + Assert.Null(ProjectDetector.FindProject("", Context(home))); + } + + public void Dispose() + { + try + { + Directory.Delete(_root, recursive: true); + } + catch + { + // temp cleanup is best-effort + } + } +} diff --git a/windows/tests/LeftOpen.Core.Tests/TcpTableTests.cs b/windows/tests/LeftOpen.Core.Tests/TcpTableTests.cs new file mode 100644 index 0000000..abd26c8 --- /dev/null +++ b/windows/tests/LeftOpen.Core.Tests/TcpTableTests.cs @@ -0,0 +1,23 @@ +using LeftOpen.Core; +using System.Reflection; + +namespace LeftOpen.Core.Tests; + +public class TcpTableTests +{ + [Theory] + [InlineData(0x9193u, 37777)] // real-world case from the E2E test server + [InlineData(0x5000u, 80)] // 0x0050 network order + [InlineData(0xBB01u, 443)] // 0x01BB network order + [InlineData(0x2273u, 29474)] // regression: unmasked << 8 once produced 0x227322 + public void PortFromNetworkOrder_SwapsBytesWithin16Bits(uint field, int expectedPort) + { + var method = typeof(TcpTable).GetMethod("PortFromNetworkOrder", BindingFlags.NonPublic | BindingFlags.Static); + Assert.NotNull(method); + + var result = (int)method!.Invoke(null, [field])!; + + Assert.Equal(expectedPort, result); + Assert.True(result is >= 1 and <= 65535, $"port {result} is out of range"); + } +} From 6c901c0181d0254434c85fa65cbbcdb77f686d5b Mon Sep 17 00:00:00 2001 From: "yan.gao1" Date: Wed, 23 Sep 2026 11:30:38 +0800 Subject: [PATCH 2/5] Windows: original icon system, row layout, panel focus fixes - Tray icon mirrors the original MenuBarExtra label: DoorMark (ported 1:1 from MenuPanel.swift path data) plus the closable-port count, with closed/open/error states and taskbar theme adaptation; error glyph from Segoe MDL2 Assets (SF Symbols' Windows counterpart) - exe icon: multi-size .ico generated from the original repo artwork (windows/Scripts/make-icon.ps1) - Per-row icons follow the original ProcessIconView tier chain: app exe icon (skipped for shared runtimes) -> project-shipped icon (Tauri/Next.js/public conventions + index.html-declared favicon) -> live favicon from the running server -> runtime symbol with the original's colouring -> category symbol - Row layout: icon and owner on one line, port+command+pid subline, full project names without truncation, panel widened to 480px - Panel no longer hides when Windows refuses the focus change (flash-and-vanish bug); anchors to the taskbar's screen; tray click toggles; --snapshot renders the panel (and tray states) to PNG for deterministic verification, like the original's snapshot-panel - publish.ps1: fix repo-root resolution and project paths after the windows/ move; default output windows/dist --- windows/.gitignore | 4 + windows/Scripts/make-icon.ps1 | 85 +++++ windows/Scripts/publish.ps1 | 12 +- windows/src/LeftOpen.Tray/AppIcons.cs | 334 ++++++++++++++++-- windows/src/LeftOpen.Tray/Assets/leftopen.ico | Bin 0 -> 41563 bytes windows/src/LeftOpen.Tray/DoorIcon.cs | 100 ------ windows/src/LeftOpen.Tray/DoorMark.cs | 208 +++++++++++ windows/src/LeftOpen.Tray/GroupRowControl.cs | 91 ++--- .../src/LeftOpen.Tray/LeftOpen.Tray.csproj | 2 + windows/src/LeftOpen.Tray/PanelForm.cs | 98 ++++- windows/src/LeftOpen.Tray/Program.cs | 107 +++++- 11 files changed, 850 insertions(+), 191 deletions(-) create mode 100644 windows/Scripts/make-icon.ps1 create mode 100644 windows/src/LeftOpen.Tray/Assets/leftopen.ico delete mode 100644 windows/src/LeftOpen.Tray/DoorIcon.cs create mode 100644 windows/src/LeftOpen.Tray/DoorMark.cs diff --git a/windows/.gitignore b/windows/.gitignore index de00091..213dd8d 100644 --- a/windows/.gitignore +++ b/windows/.gitignore @@ -4,6 +4,10 @@ obj/ dist/ dist-*/ +# 面板快照与图标校验产物(--snapshot / 人工验证生成的图片) +panel-snapshot*.png +exe-icon-check.png + # 测试结果 TestResults/ *.trx diff --git a/windows/Scripts/make-icon.ps1 b/windows/Scripts/make-icon.ps1 new file mode 100644 index 0000000..a7f24b8 --- /dev/null +++ b/windows/Scripts/make-icon.ps1 @@ -0,0 +1,85 @@ +# Builds the Windows application icon (multi-size .ico) from the original repo artwork. +# Usage: powershell -File windows\Scripts\make-icon.ps1 +param( + [string]$Source = "assets\leftopen-iOS-Default-1024@1x.png", + [string]$Output = "windows\src\LeftOpen.Tray\Assets\leftopen.ico" +) + +$ErrorActionPreference = "Stop" +Add-Type -AssemblyName System.Drawing + +# The icon builder runs as inline C# to sidestep PowerShell 5.1 overload-resolution quirks. +Add-Type -ReferencedAssemblies System.Drawing -TypeDefinition @" +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Drawing.Imaging; +using System.IO; + +public static class IcoBuilder +{ + public static void Build(string sourcePath, string outputPath, int[] sizes) + { + using (Image source = Image.FromFile(sourcePath)) + { + byte[][] payloads = new byte[sizes.Length][]; + for (int i = 0; i < sizes.Length; i++) + { + using (Bitmap bmp = new Bitmap(sizes[i], sizes[i])) + using (Graphics g = Graphics.FromImage(bmp)) + { + g.InterpolationMode = InterpolationMode.HighQualityBicubic; + g.PixelOffsetMode = PixelOffsetMode.HighQuality; + g.SmoothingMode = SmoothingMode.AntiAlias; + g.DrawImage(source, 0, 0, sizes[i], sizes[i]); + using (MemoryStream ms = new MemoryStream()) + { + bmp.Save(ms, ImageFormat.Png); + payloads[i] = ms.ToArray(); + } + } + } + + // ICONDIR + ICONDIRENTRY[] + PNG payloads (PNG-in-ICO is supported since Vista). + using (FileStream fs = File.Create(outputPath)) + using (BinaryWriter bw = new BinaryWriter(fs)) + { + bw.Write((ushort)0); + bw.Write((ushort)1); + bw.Write((ushort)sizes.Length); + + int offset = 6 + 16 * sizes.Length; + for (int i = 0; i < sizes.Length; i++) + { + byte dimension = sizes[i] >= 256 ? (byte)0 : (byte)sizes[i]; + bw.Write(dimension); + bw.Write(dimension); + bw.Write((byte)0); + bw.Write((byte)0); + bw.Write((ushort)1); + bw.Write((ushort)32); + bw.Write((uint)payloads[i].Length); + bw.Write((uint)offset); + offset += payloads[i].Length; + } + + foreach (byte[] payload in payloads) + { + bw.Write(payload); + } + } + } + } +} +"@ + +$repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +$sourcePath = Join-Path $repoRoot $Source +if (!(Test-Path $sourcePath)) { throw "Icon source not found: $sourcePath" } + +$outputPath = Join-Path $repoRoot $Output +New-Item -ItemType Directory -Force -Path (Split-Path -Parent $outputPath) | Out-Null + +$sizes = @(16, 20, 24, 32, 40, 48, 64, 128, 256) +[IcoBuilder]::Build($sourcePath, $outputPath, $sizes) + +Write-Host "Wrote $Output ($((Get-Item $outputPath).Length) bytes, sizes: $($sizes -join ', '))" -ForegroundColor Green diff --git a/windows/Scripts/publish.ps1 b/windows/Scripts/publish.ps1 index 1e522ab..28bad62 100644 --- a/windows/Scripts/publish.ps1 +++ b/windows/Scripts/publish.ps1 @@ -1,5 +1,5 @@ # Builds release artifacts for LeftOpen on Windows. -# Usage: powershell -File Scripts\publish.ps1 [-OutputDir dist] [-SelfContained] +# Usage: powershell -File windows\Scripts\publish.ps1 [-OutputDir windows\dist] [-SelfContained] # # Default: framework-dependent single-file executables (~1.5 MB each) — small and # fast to start, but require the .NET 8 runtime (SDK or Desktop Runtime). @@ -7,12 +7,14 @@ # a runtime, at the cost of size (WinForms cannot be trimmed). param( - [string]$OutputDir = "dist", + # Relative paths resolve against the repository root, not the current directory. + [string]$OutputDir = "windows\dist", [switch]$SelfContained ) $ErrorActionPreference = "Stop" -$repoRoot = Split-Path -Parent $PSScriptRoot +# This script lives in \windows\Scripts, so the repo root is two levels up. +$repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) Set-Location $repoRoot $common = @("-c", "Release", "-r", "win-x64", "-p:PublishSingleFile=true") @@ -24,11 +26,11 @@ else { } Write-Host "==> Publishing CLI (leftopen.exe)..." -ForegroundColor Cyan -dotnet publish "src\LeftOpen.Cli\LeftOpen.Cli.csproj" @common -o $OutputDir +dotnet publish "windows\src\LeftOpen.Cli\LeftOpen.Cli.csproj" @common -o $OutputDir if ($LASTEXITCODE -ne 0) { throw "CLI publish failed." } Write-Host "==> Publishing tray app (LeftOpenApp.exe)..." -ForegroundColor Cyan -dotnet publish "src\LeftOpen.Tray\LeftOpen.Tray.csproj" @common -o $OutputDir +dotnet publish "windows\src\LeftOpen.Tray\LeftOpen.Tray.csproj" @common -o $OutputDir if ($LASTEXITCODE -ne 0) { throw "Tray publish failed." } Write-Host "" diff --git a/windows/src/LeftOpen.Tray/AppIcons.cs b/windows/src/LeftOpen.Tray/AppIcons.cs index 0e4d752..1d2fc2b 100644 --- a/windows/src/LeftOpen.Tray/AppIcons.cs +++ b/windows/src/LeftOpen.Tray/AppIcons.cs @@ -1,61 +1,343 @@ -using System.Diagnostics; +using System.Drawing.Drawing2D; +using System.Drawing.Imaging; +using System.Drawing.Text; +using System.Net.Http; +using System.Text.RegularExpressions; +using LeftOpen.Core; namespace LeftOpen.Tray; /// -/// Per-row icon resolution, mirroring leftopen's chain: the target executable's own -/// icon (installed apps) → the project's favicon.ico → a generic door glyph. +/// Per-row icon resolution, following the original's tier order (ProcessIconView.swift): +/// 1. the executable's own icon — but only when the executable IS the app, not a +/// shared runtime like node.exe (whose icon says nothing about the project); +/// 2. the icon the project ships itself (Tauri/Electron, Next.js, public/static/assets, +/// bare favicon.*, plus whatever its index.html declares); +/// 3. the favicon served by the running dev server itself; +/// 4. a symbol for the runtime (node/python/go/java/ruby…), coloured like the original; +/// 5. a category symbol (project / app / system / unknown). +/// Symbols come from Segoe MDL2 Assets — Windows' system symbol font, the counterpart +/// of the SF Symbols the original relies on. /// -internal static class AppIcons +internal static partial class AppIcons { - public static Image Resolve(string? executablePath, string? projectRoot, int size) + /// + /// The original's projectIconCandidates, minus formats Windows cannot decode + /// (.icns, .svg — the macOS build loads those through AppKit). + /// + private static readonly string[] ProjectIconCandidates = + [ + // Tauri / Electron app icons + "src-tauri/icons/icon.png", "app-icon.png", + "build/icon.png", "buildResources/icon.png", "resources/icon.png", + // Next.js app router + "app/apple-icon.png", "app/icon.png", "app/favicon.ico", + "src/app/apple-icon.png", "src/app/icon.png", "src/app/favicon.ico", + // Vite / CRA / Nuxt / Astro (`public/`), SvelteKit (`static/`) + "public/apple-touch-icon.png", "public/icon.png", "public/logo.png", + "public/favicon.png", "public/favicon.ico", + "static/apple-touch-icon.png", "static/favicon.png", "static/favicon.ico", + "assets/favicon.png", "assets/favicon.ico", + // Bare roots + "favicon.png", "favicon.ico", "icon.png", + ]; + + /// Runtime executables that must not lend their own icon to a project row. + private static readonly HashSet RuntimeHostNames = new(StringComparer.OrdinalIgnoreCase) + { + "node", "bun", "deno", "npm", "yarn", "pnpm", "ts-node", + "python", "pythonw", "python3", "py", "uvicorn", "gunicorn", "flask", "jupyter", + "dotnet", "java", "javaw", "gradle", "kotlin", + "go", "dlv", "cargo", "rustc", "ruby", "rails", "puma", + }; + + private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromMilliseconds(1500) }; + + public static Image Resolve( + string? executablePath, + string? projectRoot, + int port, + Category category, + string command, + int size) + { + // Tier 1 — the executable's own icon, when it represents an actual application. + if (!string.IsNullOrEmpty(executablePath) && + !RuntimeHostNames.Contains(Path.GetFileNameWithoutExtension(executablePath))) + { + if (TryExeIcon(executablePath, size) is { } exeIcon) + { + return exeIcon; + } + } + + // Tier 2 — an icon the project ships itself. + if (!string.IsNullOrEmpty(projectRoot) && TryProjectIcon(projectRoot, size) is { } projectIcon) + { + return projectIcon; + } + + // Tier 3 — the favicon the running server serves (or declares in its HTML). + if (port is > 0 and <= 65535 && TryLiveFavicon(port, size) is { } liveIcon) + { + return liveIcon; + } + + // Tier 4 — runtime symbol, coloured the way the original colours its SF Symbols. + if (TryRuntimeSymbol(command, executablePath, size) is { } runtimeIcon) + { + return runtimeIcon; + } + + // Tier 5 — category symbol. + return category switch + { + Category.Project => Glyph('\uE8B7', Color.FromArgb(0x2B, 0x6C, 0xD6), size), // folder + Category.Application => Glyph('\uE713', Color.FromArgb(0x6B, 0x6B, 0x6B), size), // gear + Category.SystemService => Glyph('\uE713', Color.FromArgb(0x6B, 0x6B, 0x6B), size), // gear + _ => Glyph('\uE756', Color.FromArgb(0x6B, 0x6B, 0x6B), size), // command prompt + }; + } + + private static Image? TryExeIcon(string executablePath, int size) { try { - if (!string.IsNullOrEmpty(executablePath) && File.Exists(executablePath)) + if (!File.Exists(executablePath)) { - using var icon = Icon.ExtractAssociatedIcon(executablePath); - if (icon != null) - { - return new Bitmap(icon.ToBitmap(), size, size); - } + return null; } + + using var icon = Icon.ExtractAssociatedIcon(executablePath); + return icon == null ? null : new Bitmap(icon.ToBitmap(), size, size); } catch { - // Fall through to the project favicon. + return null; } + } + + private static Image? TryProjectIcon(string projectRoot, int size) + { + foreach (var candidate in CandidatesWithHtmlIcons(projectRoot)) + { + var path = Path.Combine(projectRoot, candidate); + if (!File.Exists(path)) + { + continue; + } + try + { + using var image = Image.FromFile(path); + return new Bitmap(image, size, size); + } + catch + { + // Unreadable or unsupported image — try the next candidate. + } + } + + return null; + } + + private static IEnumerable CandidatesWithHtmlIcons(string projectRoot) + { + // A Vite/Tauri project declares its own favicon in index.html; honour that first, + // exactly like the original does. + var indexHtml = Path.Combine(projectRoot, "index.html"); + if (File.Exists(indexHtml)) + { + string html; + try + { + html = File.ReadAllText(indexHtml); + } + catch + { + html = string.Empty; + } + + foreach (var relative in DeclaredIconPaths(html)) + { + yield return relative; + yield return Path.Combine("public", relative); + } + } + + foreach (var candidate in ProjectIconCandidates) + { + yield return candidate; + } + } + + private static IEnumerable DeclaredIconPaths(string html) + { + foreach (Match match in IconLinkRegex().Matches(html)) + { + var href = match.Groups["href"].Value; + if (href.StartsWith("http", StringComparison.OrdinalIgnoreCase) || + href.StartsWith("//", StringComparison.Ordinal)) + { + continue; + } + + var relative = href.Split('?')[0].TrimStart('/'); + if (relative.Length > 0 && !relative.Contains("..")) + { + yield return relative; + } + } + } + + private static Image? TryLiveFavicon(int port, int size) + { try { - if (!string.IsNullOrEmpty(projectRoot)) + var bytes = FetchBytes($"http://127.0.0.1:{port}/favicon.ico"); + if (bytes == null) { - var favicon = Path.Combine(projectRoot, "favicon.ico"); - if (File.Exists(favicon)) + // No /favicon.ico: ask the served HTML what it declares. + var html = FetchText($"http://127.0.0.1:{port}/"); + if (html != null) { - using var icon = new Icon(favicon, size, size); - return new Bitmap(icon.ToBitmap(), size, size); + foreach (var relative in DeclaredIconPaths(html)) + { + bytes = FetchBytes($"http://127.0.0.1:{port}/{relative}"); + if (bytes != null) + { + break; + } + } } } + + if (bytes == null) + { + return null; + } + + using var stream = new MemoryStream(bytes); + using var image = Image.FromStream(stream); + return new Bitmap(image, size, size); } catch { - // Fall through to the fallback glyph. + return null; + } + } + + private static byte[]? FetchBytes(string url) + { + try + { + using var response = Http.GetAsync(url).GetAwaiter().GetResult(); + if (!response.IsSuccessStatusCode) + { + return null; + } + + var bytes = response.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult(); + return bytes.Length == 0 ? null : bytes; + } + catch + { + return null; + } + } + + private static string? FetchText(string url) + { + try + { + using var response = Http.GetAsync(url).GetAwaiter().GetResult(); + if (!response.IsSuccessStatusCode) + { + return null; + } + + var content = response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + return content.Length > 512 * 1024 ? content[..(512 * 1024)] : content; + } + catch + { + return null; + } + } + + /// Runtime symbols with the original's colours (node green, python blue, …). + private static Image? TryRuntimeSymbol(string command, string? executablePath, int size) + { + var target = $"{Path.GetFileNameWithoutExtension(command)} {Path.GetFileNameWithoutExtension(executablePath)}".ToLowerInvariant(); + + if (Contains(target, "node", "bun", "deno", "npm", "yarn", "pnpm", "ts-node")) + { + return Glyph('\uE943', Color.FromArgb(89, 166, 64), size); // code, green + } + + if (Contains(target, "python", "uvicorn", "gunicorn", "flask", "fastapi", "django", "jupyter")) + { + return Glyph('\uE943', Color.FromArgb(59, 120, 173), size); // code, blue } - return FallbackGlyph(size); + if (Contains(target, "cargo", "rustc")) + { + return Glyph('\uE713', Color.FromArgb(224, 89, 41), size); // gear, orange + } + + if (Contains(target, "go", "dlv")) + { + return Glyph('\uE943', Color.FromArgb(0, 168, 209), size); // code, cyan + } + + if (Contains(target, "java", "gradle", "kotlin")) + { + return Glyph('\uE943', Color.FromArgb(224, 64, 38), size); // code, red-brown + } + + if (Contains(target, "ruby", "rails", "puma")) + { + return Glyph('\uE943', Color.FromArgb(204, 38, 38), size); // code, red + } + + if (Contains(target, "postgres", "pg_ctl")) + { + return Glyph('\uE943', Color.FromArgb(51, 99, 148), size); + } + + if (Contains(target, "redis", "valkey", "keydb")) + { + return Glyph('\uE943', Color.FromArgb(217, 51, 46), size); + } + + if (Contains(target, "mysql", "mariadb")) + { + return Glyph('\uE943', Color.FromArgb(237, 148, 38), size); + } + + return null; + + static bool Contains(string haystack, params string[] needles) => + needles.Any(needle => haystack.Contains(needle, StringComparison.OrdinalIgnoreCase)); } - private static Image FallbackGlyph(int size) + /// Renders a Segoe MDL2 Assets glyph (Windows' system symbol font). + private static Image Glyph(char glyph, Color color, int size) { - var bitmap = new Bitmap(size, size); + var bitmap = new Bitmap(size, size, PixelFormat.Format32bppArgb); using var g = Graphics.FromImage(bitmap); - g.Clear(Color.Transparent); - using var brush = new SolidBrush(Color.FromArgb(90, 90, 96)); - using var font = new Font("Segoe UI", size * 0.55f, FontStyle.Bold, GraphicsUnit.Pixel); - var text = "?"; + g.TextRenderingHint = TextRenderingHint.AntiAliasGridFit; + using var font = new Font("Segoe MDL2 Assets", size * 0.72f, FontStyle.Regular, GraphicsUnit.Pixel); + using var brush = new SolidBrush(color); + var text = glyph.ToString(); var measured = g.MeasureString(text, font); - g.DrawString(text, font, brush, (size - measured.Width) / 2, (size - measured.Height) / 2); + g.DrawString(text, font, brush, (size - measured.Width) / 2f, (size - measured.Height) / 2f); return bitmap; } + + /// Matches <link rel="…icon…" href="…"> in either attribute order. + [GeneratedRegex( + """]*rel\s*=\s*["'][^"']*(?:icon|apple-touch-icon)[^"']*["'][^>]*?href\s*=\s*["'](?[^"']+)["']|]*href\s*=\s*["'](?[^"']+)["'][^>]*?rel\s*=\s*["'][^"']*(?:icon|apple-touch-icon)[^"']*["']""", + RegexOptions.IgnoreCase)] + private static partial Regex IconLinkRegex(); } diff --git a/windows/src/LeftOpen.Tray/Assets/leftopen.ico b/windows/src/LeftOpen.Tray/Assets/leftopen.ico new file mode 100644 index 0000000000000000000000000000000000000000..874ab08d9e3a6f9720fc65f90c4a7a70462df78e GIT binary patch literal 41563 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 literal 0 HcmV?d00001 diff --git a/windows/src/LeftOpen.Tray/DoorIcon.cs b/windows/src/LeftOpen.Tray/DoorIcon.cs deleted file mode 100644 index bec2b3d..0000000 --- a/windows/src/LeftOpen.Tray/DoorIcon.cs +++ /dev/null @@ -1,100 +0,0 @@ -using LeftOpen.Core; - -namespace LeftOpen.Tray; - -/// -/// The door glyph for the system tray: a closed door when localhost is quiet, -/// an open door when something was left running. Drawn with GDI+ in a colour -/// that adapts to the light/dark taskbar theme, like leftopen's DoorMark. -/// -internal static class DoorIcon -{ - public static Icon Closed(int size) => Create(size, open: false); - - public static Icon Open(int size) => Create(size, open: true); - - public static void Paint(Graphics g, Rectangle bounds, bool open, Color color) - { - g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias; - using var pen = new Pen(color, Math.Max(1.5f, bounds.Width / 12f)); - var thickness = Math.Max(3, bounds.Width / 7); - - // Door frame: rounded rectangle outline. - var frame = Rectangle.Inflate(bounds, -thickness, -thickness / 2); - frame.Height += thickness / 2; - using var framePath = RoundedRect(frame, bounds.Width / 8); - g.DrawPath(pen, framePath); - - if (open) - { - // The leaf swung outwards: a parallelogram from the frame's edge. - var leaf = new[] - { - new PointF(frame.Right - thickness - 1, frame.Top + thickness), - new PointF(frame.Right + thickness * 1.1f, frame.Top + thickness * 2.2f), - new PointF(frame.Right + thickness * 1.1f, frame.Bottom - 1), - new PointF(frame.Right - thickness - 1, frame.Bottom - thickness / 2), - }; - using var leafPath = new System.Drawing.Drawing2D.GraphicsPath(); - leafPath.AddPolygon(leaf); - using var fill = new SolidBrush(Color.FromArgb(90, color)); - g.FillPath(fill, leafPath); - g.DrawPath(pen, leafPath); - - // Knob on the frame side. - g.FillEllipse(new SolidBrush(color), frame.Left + thickness, frame.Top + frame.Height / 2 - thickness, thickness * 1.2f, thickness * 1.2f); - } - else - { - // Closed leaf fills the frame, with a knob on the right. - var leaf = Rectangle.Inflate(frame, -thickness / 3, -thickness / 3); - using var leafPath = RoundedRect(leaf, bounds.Width / 10); - g.FillPath(new SolidBrush(Color.FromArgb(60, color)), leafPath); - g.DrawPath(pen, leafPath); - - var knobSize = Math.Max(3, bounds.Width / 9); - var knob = new Rectangle(leaf.Right - knobSize - thickness, leaf.Top + leaf.Height / 2 - knobSize / 2, knobSize, knobSize); - g.FillEllipse(new SolidBrush(color), knob); - } - } - - private static System.Drawing.Drawing2D.GraphicsPath RoundedRect(Rectangle bounds, int radius) - { - var path = new System.Drawing.Drawing2D.GraphicsPath(); - var d = radius * 2; - path.AddArc(bounds.Left, bounds.Top, d, d, 180, 90); - path.AddArc(bounds.Right - d, bounds.Top, d, d, 270, 90); - path.AddArc(bounds.Right - d, bounds.Bottom - d, d, d, 0, 90); - path.AddArc(bounds.Left, bounds.Bottom - d, d, d, 90, 90); - path.CloseFigure(); - return path; - } - - private static Icon Create(int size, bool open) - { - using var bitmap = new Bitmap(size, size); - using (var g = Graphics.FromImage(bitmap)) - { - g.Clear(Color.Transparent); - Paint(g, new Rectangle(0, 0, size, size), open, TaskbarGlyphColor()); - } - - return Icon.FromHandle(bitmap.GetHicon()); - } - - /// White glyph on the default dark taskbar, dark glyph on light themes. - public static Color TaskbarGlyphColor() - { - try - { - using var key = Microsoft.Win32.Registry.CurrentUser.OpenSubKey( - @"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize"); - var light = key?.GetValue("SystemUsesLightTheme") as int? ?? 0; - return light == 1 ? Color.FromArgb(32, 32, 32) : Color.FromArgb(240, 240, 240); - } - catch - { - return Color.FromArgb(240, 240, 240); - } - } -} diff --git a/windows/src/LeftOpen.Tray/DoorMark.cs b/windows/src/LeftOpen.Tray/DoorMark.cs new file mode 100644 index 0000000..992f03c --- /dev/null +++ b/windows/src/LeftOpen.Tray/DoorMark.cs @@ -0,0 +1,208 @@ +using System.Drawing.Drawing2D; +using System.Drawing.Imaging; +using System.Runtime.InteropServices; + +namespace LeftOpen.Tray; + +/// +/// The original LeftOpen door mark, ported from the Swift DoorMark in MenuPanel.swift +/// (33 × 55 view box). Open = the leaf has swung out, leaving the dark interior +/// visible; closed = the leaf sits flush, so only the frame ring and knob show. +/// The frame colour adapts to the taskbar theme, and the leaf is left transparent so +/// the taskbar (or panel) shows through it, exactly like the original's +/// windowBackgroundColor leaf. +/// +internal static class DoorMark +{ + private const float ViewWidth = 33f; + private const float ViewHeight = 55f; + private const float KnobRadius = 1.644f; + private const float KnobY = 29.134f; + + public static Image Render(int size, bool open, Color frame) + { + var bitmap = new Bitmap(size, size, PixelFormat.Format32bppArgb); + using var g = Graphics.FromImage(bitmap); + g.Clear(Color.Transparent); + Paint(g, new RectangleF(0, 0, size, size), open, frame); + return bitmap; + } + + public static Icon RenderIcon(int size, bool open, Color frame) + { + using var bitmap = (Bitmap)Render(size, open, frame); + var handle = bitmap.GetHicon(); + try + { + using var borrowed = Icon.FromHandle(handle); + return (Icon)borrowed.Clone(); + } + finally + { + DestroyIcon(handle); + } + } + + /// Tray icon states, mirroring the original's MenuBarExtra label. + public enum TrayState + { + /// Nothing closable: just the closed door. + Closed, + + /// Doors left open: open door plus the closable count. + Open, + + /// Scan failed: warning triangle plus the last known port count. + Error, + } + + /// + /// The tray icon, mirroring the original menu bar: door mark plus the closable-port + /// count in monospaced digits (closed state shows no number; errors show a warning + /// glyph plus the last known count). SF Symbols have no Windows equivalent, so the + /// door comes from the repo's own DoorMark geometry and the warning glyph from + /// Segoe MDL2 Assets. + /// + public static Icon RenderTrayIcon(int size, TrayState state, int count, Color frame) + { + var bitmap = new Bitmap(size, size, PixelFormat.Format32bppArgb); + using (var g = Graphics.FromImage(bitmap)) + { + g.Clear(Color.Transparent); + g.SmoothingMode = SmoothingMode.AntiAlias; + + if (state == TrayState.Error) + { + DrawGlyph(g, '\uE7BA', frame, new RectangleF(1, 1, size * 0.55f, size - 2)); // MDL2 warning + } + else + { + // The door occupies the left portion at full height. + Paint(g, new RectangleF(0, 0, size * 0.58f, size), state == TrayState.Open, frame); + } + + if (count > 0 && (state == TrayState.Open || state == TrayState.Error)) + { + DrawMonospaceCount(g, count, frame, size); + } + } + + var handle = bitmap.GetHicon(); + try + { + using var borrowed = Icon.FromHandle(handle); + return (Icon)borrowed.Clone(); + } + finally + { + bitmap.Dispose(); + DestroyIcon(handle); + } + } + + private static void DrawMonospaceCount(Graphics g, int count, Color color, int size) + { + var text = count > 99 ? "99" : count.ToString(); + using var font = new Font("Consolas", Math.Max(7f, size * 0.62f), FontStyle.Bold, GraphicsUnit.Pixel); + using var brush = new SolidBrush(color); + var measured = g.MeasureString(text, font); + var x = size - measured.Width - 0.5f; + var y = (size - measured.Height) / 2f; + g.DrawString(text, font, brush, x, y); + } + + private static void DrawGlyph(Graphics g, char glyph, Color color, RectangleF bounds) + { + using var font = new Font("Segoe MDL2 Assets", bounds.Height * 0.85f, FontStyle.Regular, GraphicsUnit.Pixel); + using var brush = new SolidBrush(color); + var text = glyph.ToString(); + var measured = g.MeasureString(text, font); + g.DrawString(text, font, brush, bounds.X + ((bounds.Width - measured.Width) / 2f), bounds.Y + ((bounds.Height - measured.Height) / 2f)); + } + + public static void Paint(Graphics g, RectangleF bounds, bool open, Color frame) + { + g.SmoothingMode = SmoothingMode.AntiAlias; + + // The original keeps the 33:55 aspect ratio and fits it inside the available box. + var scale = Math.Min(bounds.Width / ViewWidth, bounds.Height / ViewHeight); + var offsetX = bounds.X + ((bounds.Width - (ViewWidth * scale)) / 2f); + var offsetY = bounds.Y + ((bounds.Height - (ViewHeight * scale)) / 2f); + + PointF At(float x, float y) => new(offsetX + (x * scale), offsetY + (y * scale)); + + using var frameBrush = new SolidBrush(frame); + + using var back = new GraphicsPath(); + back.AddLine(At(0.74f, 54.51f), At(0.74f, 16.161f)); + back.AddBezier(At(0.74f, 16.161f), At(0.74f, 7.683f), At(7.755f, 0.707f), At(16.28f, 0.707f)); + back.AddBezier(At(16.28f, 0.707f), At(24.805f, 0.707f), At(31.82f, 7.683f), At(31.82f, 16.161f)); + back.AddLine(At(31.82f, 16.161f), At(31.82f, 54.51f)); + back.CloseFigure(); + + using var leaf = new GraphicsPath(); + if (open) + { + leaf.AddLine(At(9.9f, 49.177f), At(9.9f, 18.124f)); + leaf.AddBezier(At(9.9f, 18.124f), At(9.9f, 10.845f), At(19.563f, 4.007f), At(27.417f, 5.505f)); + leaf.AddBezier(At(27.417f, 5.505f), At(31.965f, 9.711f), At(31.647f, 11.702f), At(31.647f, 18.981f)); + leaf.AddLine(At(31.647f, 18.981f), At(31.647f, 53.609f)); + leaf.CloseFigure(); + + // Only the interior the swung-open leaf does not cover stays visible. + using var visibleOpening = new Region(back); + visibleOpening.Exclude(leaf); + g.FillRegion(frameBrush, visibleOpening); + } + else + { + // Closed leaf: the original paints it in the background colour, so with a + // transparent leaf there is nothing to fill — the ring and knob remain. + leaf.AddLine(At(1.501f, 53.695f), At(31.264f, 53.695f)); + leaf.AddLine(At(31.264f, 53.695f), At(31.264f, 16.155f)); + leaf.AddBezier(At(31.264f, 16.155f), At(31.264f, 8.037f), At(24.545f, 1.358f), At(16.382f, 1.358f)); + leaf.AddBezier(At(16.382f, 1.358f), At(8.219f, 1.358f), At(1.501f, 8.037f), At(1.501f, 16.155f)); + leaf.CloseFigure(); + } + + // Frame outline: outer arch minus inner arch (even-odd), as in the original. + using var outline = new GraphicsPath { FillMode = FillMode.Alternate }; + outline.AddLine(At(0.391f, 16.155f), At(0.391f, 16.155f)); + outline.AddBezier(At(0.391f, 16.155f), At(0.391f, 7.43f), At(7.609f, 0.248f), At(16.383f, 0.248f)); + outline.AddBezier(At(16.383f, 0.248f), At(25.157f, 0.248f), At(32.375f, 7.43f), At(32.375f, 16.155f)); + outline.AddLine(At(32.375f, 16.155f), At(32.375f, 54.805f)); + outline.AddLine(At(32.375f, 54.805f), At(0.391f, 54.805f)); + outline.CloseFigure(); + + outline.AddLine(At(1.501f, 53.695f), At(31.264f, 53.695f)); + outline.AddLine(At(31.264f, 53.695f), At(31.264f, 16.155f)); + outline.AddBezier(At(31.264f, 16.155f), At(31.264f, 8.037f), At(24.545f, 1.358f), At(16.382f, 1.358f)); + outline.AddBezier(At(16.382f, 1.358f), At(8.219f, 1.358f), At(1.501f, 8.037f), At(1.501f, 16.155f)); + outline.CloseFigure(); + g.FillPath(frameBrush, outline); + + var knobX = open ? 13.552f : 24.0f; + var knobSize = KnobRadius * 2 * scale; + var knobTopLeft = At(knobX - KnobRadius, KnobY - KnobRadius); + g.FillEllipse(frameBrush, knobTopLeft.X, knobTopLeft.Y, knobSize, knobSize); + } + + /// White glyph on the default dark taskbar, near-black on light themes. + public static Color ThemeFrameColor() + { + try + { + using var key = Microsoft.Win32.Registry.CurrentUser.OpenSubKey( + @"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize"); + var light = key?.GetValue("SystemUsesLightTheme") as int? ?? 0; + return light == 1 ? Color.FromArgb(0x21, 0x18, 0x11) : Color.FromArgb(0xF2, 0xF2, 0xF2); + } + catch + { + return Color.FromArgb(0xF2, 0xF2, 0xF2); + } + } + + [DllImport("user32.dll", SetLastError = true)] + private static extern bool DestroyIcon(IntPtr handle); +} diff --git a/windows/src/LeftOpen.Tray/GroupRowControl.cs b/windows/src/LeftOpen.Tray/GroupRowControl.cs index 9eb4ae4..ccd8dd4 100644 --- a/windows/src/LeftOpen.Tray/GroupRowControl.cs +++ b/windows/src/LeftOpen.Tray/GroupRowControl.cs @@ -26,9 +26,11 @@ public GroupRowControl( _openDetail = openDetail; _requestClose = requestClose; - const int width = PanelForm.PanelWidth; + // Right-hand columns anchor to the row's own width, which is the panel width + // minus the flow panel's vertical scrollbar and the row margins. + const int rowWidth = PanelForm.PanelWidth - 48; Height = 54; - Width = width - 26; + Width = rowWidth; Margin = new Padding(14, 2, 14, 2); BackColor = SystemColors.Window; Cursor = Cursors.Hand; @@ -38,36 +40,14 @@ public GroupRowControl( AccessibleName = $"端口 {string.Join(", ", group.Ports)} · {group.Label} · PID {group.Pid}"; AccessibleDescription = group.Closable ? "打开详情" : $"不可关闭:{group.RefusalReason}"; - var port = new Label - { - Text = group.Ports[0].ToString(), - AutoSize = false, - Size = new Size(66, 20), - Location = new Point(10, 8), - Font = new Font("Consolas", 11f, FontStyle.Bold), - TextAlign = ContentAlignment.MiddleLeft, - }; - - if (group.Ports.Count > 1) - { - var more = new Label - { - Text = $"+{group.Ports.Count - 1}", - AutoSize = false, - Size = new Size(30, 16), - Location = new Point(76, 10), - Font = new Font("Segoe UI", 8f, FontStyle.Bold), - BackColor = SystemColors.ControlLight, - ForeColor = SystemColors.GrayText, - TextAlign = ContentAlignment.MiddleCenter, - }; - Controls.Add(more); - } - + // Layout — main line: icon | owner | LAN | ✕ + // sub line: port +N · command · pid | uptime + // The icon leads the row so it sits on the same line as the process name, + // and the owner label gets the width it needs for long project names. _icon = new PictureBox { Size = new Size(24, 24), - Location = new Point(10, 24), + Location = new Point(10, 4), SizeMode = PictureBoxSizeMode.CenterImage, }; @@ -75,18 +55,29 @@ public GroupRowControl( { Text = TrimLabel(group.Label), AutoSize = false, - Size = new Size(width - 210, 18), - Location = new Point(112, 7), + Size = new Size(rowWidth - 136, 18), + Location = new Point(44, 6), Font = new Font("Segoe UI", 9.5f, FontStyle.Bold), ForeColor = SystemColors.ControlText, }; + var port = new Label + { + Text = group.Ports[0].ToString(), + AutoSize = false, + Size = new Size(66, 18), + Location = new Point(44, 25), + Font = new Font("Consolas", 10.5f, FontStyle.Bold), + ForeColor = SystemColors.ControlText, + TextAlign = ContentAlignment.MiddleLeft, + }; + var sub = new Label { - Text = $"{TrimCommand(group.Command)} · PID {group.Pid}", + Text = $"· {TrimCommand(group.Command)} · {group.Pid}", AutoSize = false, - Size = new Size(width - 272, 16), - Location = new Point(112, 27), + Size = new Size(rowWidth - 216, 16), + Location = new Point(116, 27), ForeColor = SystemColors.GrayText, }; @@ -94,12 +85,28 @@ public GroupRowControl( { Text = group.StartTime != null ? DescribeDuration(DateTime.Now - group.StartTime.Value) : "—", AutoSize = false, - Size = new Size(56, 34), - Location = new Point(width - 216, 16), + Size = new Size(88, 16), + Location = new Point(rowWidth - 96, 27), ForeColor = SystemColors.GrayText, TextAlign = ContentAlignment.MiddleRight, }; + if (group.Ports.Count > 1) + { + var more = new Label + { + Text = $"+{group.Ports.Count - 1}", + AutoSize = false, + Size = new Size(28, 15), + Location = new Point(112, 26), + Font = new Font("Segoe UI", 8f, FontStyle.Bold), + BackColor = SystemColors.ControlLight, + ForeColor = SystemColors.GrayText, + TextAlign = ContentAlignment.MiddleCenter, + }; + Controls.Add(more); + } + if (group.AnyLan) { var lan = new Label @@ -107,7 +114,7 @@ public GroupRowControl( Text = "LAN", AutoSize = false, Size = new Size(34, 16), - Location = new Point(width - 158, 25), + Location = new Point(rowWidth - 84, 8), BackColor = Color.FromArgb(255, 235, 205), ForeColor = Color.FromArgb(150, 100, 20), Font = new Font("Segoe UI", 7.5f, FontStyle.Bold), @@ -123,7 +130,7 @@ public GroupRowControl( Font = new Font("Segoe UI", 10f, FontStyle.Bold), FlatStyle = FlatStyle.Flat, Size = new Size(32, 32), - Location = new Point(width - 114, 10), + Location = new Point(rowWidth - 44, 4), ForeColor = Color.FromArgb(190, 60, 50), Visible = false, TabStop = false, @@ -156,11 +163,11 @@ public GroupRowControl( MouseClick += OnRowMouseClick; label.MouseClick += OnRowMouseClick; - // Icon loading touches the filesystem; do it off the hot path so the - // panel renders instantly. + // Icon loading touches the filesystem and possibly the running server; do it + // off the hot path so the panel renders instantly. _ = Task.Run(() => { - var image = AppIcons.Resolve(group.ExecutablePath, group.ProjectRoot, 24); + var image = AppIcons.Resolve(group.ExecutablePath, group.ProjectRoot, group.Ports[0], group.Category, group.Command, 24); BeginInvoke(() => { if (!IsDisposed) @@ -220,7 +227,7 @@ private static void OpenInBrowser(int port) private static void CopyToClipboard(string text) => Clipboard.SetText(text); - private static string TrimLabel(string label) => label.Length <= 34 ? label : label[..33] + "…"; + private static string TrimLabel(string label) => label.Length <= 40 ? label : label[..39] + "…"; private static string TrimCommand(string command) => command.Length <= 28 ? command : command[..27] + "…"; diff --git a/windows/src/LeftOpen.Tray/LeftOpen.Tray.csproj b/windows/src/LeftOpen.Tray/LeftOpen.Tray.csproj index 4c0e48e..5c3626b 100644 --- a/windows/src/LeftOpen.Tray/LeftOpen.Tray.csproj +++ b/windows/src/LeftOpen.Tray/LeftOpen.Tray.csproj @@ -13,6 +13,8 @@ true enable 0.1.0 + + Assets\leftopen.ico diff --git a/windows/src/LeftOpen.Tray/PanelForm.cs b/windows/src/LeftOpen.Tray/PanelForm.cs index 620edfd..6dbab64 100644 --- a/windows/src/LeftOpen.Tray/PanelForm.cs +++ b/windows/src/LeftOpen.Tray/PanelForm.cs @@ -12,7 +12,7 @@ namespace LeftOpen.Tray; /// internal sealed class PanelForm : Form { - internal const int PanelWidth = 392; + internal const int PanelWidth = 480; private const int PanelHeight = 480; private readonly Action _onScanFinished; @@ -32,6 +32,8 @@ internal sealed class PanelForm : Form private Control? _currentView; private bool _notClosableExpanded; private string _searchText = ""; + private DateTime _shownAt; + private bool _everActivated; public string? CurrentUserSid { get; } = WindowsIdentity.GetCurrent().User?.Value; @@ -154,17 +156,77 @@ public PanelForm(Action onScanFinished) /// Opens the panel near the tray icon; scans on open, like MenuBarExtra. public void OpenFromTray(bool rescan) { - var area = Screen.FromPoint(Cursor.Position).WorkingArea; + var area = TrayWorkingArea(); Location = new Point(area.Right - Width - 12, area.Bottom - Height - 12); + _shownAt = DateTime.Now; + _everActivated = false; + Show(); Activate(); + + // Activate() can be refused by the Windows foreground lock when the trigger + // came from a background process; ask explicitly so the panel actually takes + // focus and its own focus-lost rule does not hide it again immediately. + SetForegroundWindow(Handle); + if (rescan) { Scan(); } } + /// + /// The working area of the display that owns the taskbar — the panel must appear + /// next to the tray icon, not next to wherever the mouse happens to be. + /// + private static Rectangle TrayWorkingArea() + { + var taskbar = FindWindow("Shell_TrayWnd", null); + if (taskbar != IntPtr.Zero && GetWindowRect(taskbar, out var rect)) + { + return Screen.FromRectangle(Rectangle.FromLTRB(rect.Left, rect.Top, rect.Right, rect.Bottom)).WorkingArea; + } + + return Screen.PrimaryScreen?.WorkingArea ?? Screen.FromPoint(Cursor.Position).WorkingArea; + } + + /// Hides the panel (used when the tray icon is clicked while it is open). + public void HidePanel() => Hide(); + + /// + /// Renders the panel (with a real scan) to a PNG — the Windows counterpart of the + /// original repo's snapshot-panel script, used to check the layout deterministically. + /// + public async Task SnapshotAsync(string path) + { + // Off-screen so the snapshot never disturbs whatever the user is doing. + Location = new Point(-6000, -6000); + Show(); + Scan(); + + var deadline = DateTime.UtcNow.AddSeconds(20); + while (_scanning && DateTime.UtcNow < deadline) + { + await Task.Delay(100); + } + + // Let the row icons finish loading asynchronously (project icons, live + // favicon probes) before rendering. + await Task.Delay(4000); + + using var bitmap = new Bitmap(Width, Height); + DrawToBitmap(bitmap, new Rectangle(0, 0, Width, Height)); + bitmap.Save(path, System.Drawing.Imaging.ImageFormat.Png); + Hide(); + } + + protected override void OnActivated(EventArgs e) + { + _everActivated = true; + base.OnActivated(e); + } + private void OnDeactivated(object? sender, EventArgs e) { // During confirm/progress views we must not vanish while a sub-dialog has focus. @@ -173,6 +235,14 @@ private void OnDeactivated(object? sender, EventArgs e) return; } + // A panel that never received focus has nothing to hide from — hiding here is + // what made it flash and vanish when Windows refused the focus change. Ignore + // the focus churn that happens right after showing, too. + if (!_everActivated || DateTime.Now - _shownAt < TimeSpan.FromMilliseconds(400)) + { + return; + } + _deferredHide.Stop(); _deferredHide.Start(); } @@ -246,7 +316,7 @@ private Image RenderDoorGlyph(bool open) var bitmap = new Bitmap(26, 26); using var g = Graphics.FromImage(bitmap); g.Clear(Color.Transparent); - DoorIcon.Paint(g, new Rectangle(0, 0, 26, 26), open, SystemColors.ControlText); + DoorMark.Paint(g, new RectangleF(0, 0, 26, 26), open, SystemColors.ControlText); return bitmap; } @@ -386,8 +456,8 @@ private void ShowListView() { Text = "⚠ " + string.Join(" ", _result.Limitations), AutoSize = true, - MaximumSize = new Size(PanelWidth - 40, 0), - Margin = new Padding(16, 12, 16, 0), + MaximumSize = new Size(PanelWidth - 90, 0), + Margin = new Padding(14, 12, 14, 0), ForeColor = Color.FromArgb(150, 110, 20), }); } @@ -676,4 +746,22 @@ private static string DescribeDuration(TimeSpan span) return $"{(int)span.TotalDays}天{(int)span.TotalHours % 24}小时"; } + + [System.Runtime.InteropServices.DllImport("user32.dll")] + private static extern bool SetForegroundWindow(IntPtr hWnd); + + [System.Runtime.InteropServices.DllImport("user32.dll", CharSet = System.Runtime.InteropServices.CharSet.Unicode)] + private static extern IntPtr FindWindow(string className, string? windowName); + + [System.Runtime.InteropServices.DllImport("user32.dll")] + private static extern bool GetWindowRect(IntPtr hWnd, out Rect rect); + + [System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)] + private struct Rect + { + public int Left; + public int Top; + public int Right; + public int Bottom; + } } diff --git a/windows/src/LeftOpen.Tray/Program.cs b/windows/src/LeftOpen.Tray/Program.cs index 8e16949..e4f5a99 100644 --- a/windows/src/LeftOpen.Tray/Program.cs +++ b/windows/src/LeftOpen.Tray/Program.cs @@ -13,13 +13,34 @@ static class Program /// mirroring leftopen's MenuBarExtra philosophy. /// [STAThread] - static void Main() + static void Main(string[] args) { + // Snapshot mode (mirrors the original repo's snapshot-panel script): render + // the panel with a real scan to a PNG and exit. Skips the single-instance + // handshake so it works even while the tray app is running. + if (args.Length == 2 && args[0] == "--snapshot") + { + ApplicationConfiguration.Initialize(); + var snapshotPanel = new PanelForm(_ => { }); + var path = Path.GetFullPath(args[1]); + + // The awaits and BeginInvoke callbacks inside SnapshotAsync need a + // running message loop, so pump it via Application.Run and close the + // form (ending the loop) when the render is done. + _ = SnapshotAndQuit(snapshotPanel, path); + Application.Run(snapshotPanel); + return; + } + using var singleInstance = new Mutex(true, @"Local\LeftOpenApp.SingleInstance", out var isFirst); if (!isFirst) { // Already running: ask that instance to open its panel (so a shortcut or - // a second launch brings the panel up instead of doing nothing). + // a second launch brings the panel up instead of doing nothing). Hand + // over the foreground right first — this process was started by the + // user, so it may call SetForegroundWindow while the running instance + // (signalled from a background thread) may not. + AllowSetForegroundWindow(AsfwAny); if (EventWaitHandle.TryOpenExisting(OpenPanelEventName, out var openEvent)) { openEvent.Set(); @@ -56,6 +77,44 @@ static void Main() Application.Run(context); GC.KeepAlive(singleInstance); } + + private const uint AsfwAny = 0xFFFFFFFF; + + private static async Task SnapshotAndQuit(PanelForm panel, string path) + { + try + { + await panel.SnapshotAsync(path); + + // Also dump the tray icon states next to the panel snapshot so the + // notification-area rendering can be inspected without hunting for the + // icon in the tray overflow. + var trayPath = Path.Combine( + Path.GetDirectoryName(path) ?? ".", + Path.GetFileNameWithoutExtension(path) + "-tray.png"); + using var strip = new Bitmap(144, 48); + using (var g = Graphics.FromImage(strip)) + { + g.Clear(Color.FromArgb(0x1F, 0x1F, 0x1F)); // dark taskbar + var frame = DoorMark.ThemeFrameColor(); + using var closed = DoorMark.RenderTrayIcon(48, DoorMark.TrayState.Closed, 0, frame); + using var open = DoorMark.RenderTrayIcon(48, DoorMark.TrayState.Open, 3, frame); + using var error = DoorMark.RenderTrayIcon(48, DoorMark.TrayState.Error, 12, frame); + g.DrawIcon(closed, new Rectangle(0, 0, 48, 48)); + g.DrawIcon(open, new Rectangle(48, 0, 48, 48)); + g.DrawIcon(error, new Rectangle(96, 0, 48, 48)); + } + + strip.Save(trayPath, System.Drawing.Imaging.ImageFormat.Png); + } + finally + { + panel.Close(); + } + } + + [System.Runtime.InteropServices.DllImport("user32.dll")] + private static extern bool AllowSetForegroundWindow(uint processId); } /// Owns the NotifyIcon for the whole app lifetime; the panel is created on demand. @@ -79,7 +138,7 @@ public TrayContext() _notifyIcon = new NotifyIcon { - Icon = DoorIcon.Closed(32), + Icon = DoorMark.RenderTrayIcon(TrayIconSize(), DoorMark.TrayState.Closed, 0, DoorMark.ThemeFrameColor()), Text = "LeftOpen — 查看遗留进程", ContextMenuStrip = menu, Visible = true, @@ -93,10 +152,22 @@ private void OnTrayMouseClick(object? sender, MouseEventArgs e) { if (e.Button == MouseButtons.Left) { - OpenPanel(); + TogglePanel(); } } + /// Tray click toggles the panel, the way a menu bar icon does. + public void TogglePanel() + { + if (_panel.Visible) + { + _panel.HidePanel(); + return; + } + + OpenPanel(); + } + /// Shows the panel, scanning only when the previous scan is stale. Safe from any thread. public void OpenPanel() { @@ -116,22 +187,32 @@ private void OnScanFinished(ScanResult result) return; } - var closable = result.Activities - .Select(a => a.Facts.Process.Pid) + // Closable ports, the way the original counts them for its menu bar label. + var closablePorts = result.Activities + .Where(a => CloseService.DescribeClosability(a, _panel.CurrentUserSid, Environment.ProcessId, out _)) + .Select(a => a.Facts.Listener.Port) .Distinct() - .Count(pid => result.Activities.Any(a => - a.Facts.Process.Pid == pid && - CloseService.DescribeClosability(a, _panel.CurrentUserSid, Environment.ProcessId, out _))); + .Count(); + var failed = result.Activities.Count == 0 && result.Limitations.Any(l => l.StartsWith("扫描失败")); + var state = failed + ? DoorMark.TrayState.Error + : closablePorts > 0 ? DoorMark.TrayState.Open : DoorMark.TrayState.Closed; // Rebuild the tray icon for the current taskbar theme; dispose the old one. var old = _notifyIcon.Icon; - _notifyIcon.Icon = closable > 0 ? DoorIcon.Open(32) : DoorIcon.Closed(32); + _notifyIcon.Icon = DoorMark.RenderTrayIcon(TrayIconSize(), state, closablePorts, DoorMark.ThemeFrameColor()); old?.Dispose(); - _notifyIcon.Text = closable > 0 - ? $"LeftOpen — {closable} 个可关闭的进程" - : "LeftOpen — localhost 干净"; + + _notifyIcon.Text = state switch + { + DoorMark.TrayState.Error => "LeftOpen — 扫描失败", + DoorMark.TrayState.Open => $"LeftOpen — {closablePorts} 个可关闭的端口", + _ => "LeftOpen — localhost 干净", + }; } + private static int TrayIconSize() => Math.Max(SystemInformation.SmallIconSize.Width, 16); + private void ToggleStartup() { const string runKey = @"Software\Microsoft\Windows\CurrentVersion\Run"; From fa2c40a2fb0e378689effc5c955758e321f74f91 Mon Sep 17 00:00:00 2001 From: Songhai <95398822+SonghaiFan@users.noreply.github.com> Date: Thu, 8 Oct 2026 17:20:31 +1100 Subject: [PATCH 3/5] fix(windows): refuse unsafe console broadcasts and add isolation regression probe --- windows/README.md | 26 ++- windows/src/LeftOpen.Cli/Program.cs | 4 +- .../src/LeftOpen.Core/ProcessTerminator.cs | 113 +++--------- windows/src/LeftOpen.Tray/PanelForm.cs | 4 +- .../LeftOpen.ConsoleSafety.Probe.csproj | 12 ++ .../LeftOpen.ConsoleSafety.Probe/Program.cs | 166 ++++++++++++++++++ .../LeftOpen.Core.Tests/CloseServiceTests.cs | 22 +++ 7 files changed, 250 insertions(+), 97 deletions(-) create mode 100644 windows/tests/LeftOpen.ConsoleSafety.Probe/LeftOpen.ConsoleSafety.Probe.csproj create mode 100644 windows/tests/LeftOpen.ConsoleSafety.Probe/Program.cs diff --git a/windows/README.md b/windows/README.md index c49c787..3f1628e 100644 --- a/windows/README.md +++ b/windows/README.md @@ -87,20 +87,16 @@ Reason: CWD is within a project root containing git at ...\.git. | `.app` 包识别应用 | 可执行文件位于 `Program Files` / `WindowsApps` / `%LOCALAPPDATA%\Programs` | | 系统目录 `/usr/bin` 等 | `C:\Windows` 树 | | `127.0.0.1` vs `0.0.0.0` | 同样区分 `LOCAL`(仅本机)/ `LAN`(局域网可见) | -| 只发 SIGTERM | 温和关闭阶梯:WM_CLOSE → 控制台 Ctrl+C → Ctrl+Break | +| 只发 SIGTERM | 仅向目标 PID 自己的窗口发送 WM_CLOSE;拒绝控制台广播 | | 菜单栏 `MenuBarExtra` | 系统托盘 `NotifyIcon` + 弹出面板 | `LeftOpen.Core` 是共享引擎,托盘应用与 CLI 用的是**完全相同**的扫描、推断与关闭规则。 -### 为什么关闭阶梯里要加 Ctrl+Break +### 控制台关闭的安全边界 -Windows 没有 SIGTERM,最接近的“温柔”做法是模拟用户在那个终端里按键: +`GenerateConsoleCtrlEvent(..., 0)` 会向共享控制台的所有进程广播。Ctrl+C 不能限定进程组;Ctrl+Break 可以限定组,但组包含后代,PID 也不等于已验证的组身份。现有扫描和 PID 身份复核无法证明所有接收者均已获准关闭,因此控制台信号路径已移除。 -1. `WM_CLOSE` —— 有自己窗口的进程(GUI 程序)。 -2. `AttachConsole` + `CTRL_C_EVENT` —— 等同于在该进程的终端里按 Ctrl+C,dev server(node/vite/next)会走正常的清理逻辑。 -3. `CTRL_BREAK_EVENT` —— 由 PowerShell `Start-Process` 等方式启动的进程会被放进**新进程组**,新进程组默认忽略 Ctrl+C;Ctrl+Break 不受这个忽略模式影响,能可靠送达。 - -发送前,发送方会注册一个返回 `true` 的控制台事件处理器,确保这些事件不会把 LeftOpen 自己一起带走。 +仅在窗口确实属于已复核的目标 PID 时发送 `WM_CLOSE`,并检查发送结果。普通 node/python 控制台服务通常没有自己的窗口,因此会被安全拒绝;需要在原终端手动停止。即使用户确认或使用 `--yes`,也不会启用广播或强杀。 ## 安全规则 @@ -110,7 +106,7 @@ Windows 没有 SIGTERM,最接近的“温柔”做法是模拟用户在那个 2. **复核**(防 PID 复用):发信号前重新扫描,核对目标 PID 仍在该端口监听、没有出现新的持有者、用户 SID / 可执行路径 / **启动时间**都与预览时一致——任何一项变化都判定为“PID 被换人”,放弃并明确报告“未发送任何信号”。 3. **执行**:发送温和信号后轮询最多 5 秒确认端口释放,结构化返回 `targetStoppedListening` / `portFree` / `remainingPids` / `signalsDelivered`。 -**从不强杀**(没有 `taskkill /F`,没有 `Process.Kill`)。进程不响应就如实报告;连温和信号都送不到(既无可达窗口也无控制台)时,也会明确说明“未送达,进程未受影响”。 +**从不强杀**(没有 `taskkill /F`,没有 `Process.Kill`)。进程不响应就如实报告;连温和信号都送不到(无可达的目标进程窗口)时,也会明确说明“未送达,进程未受影响”。 ## 已知限制 @@ -145,3 +141,15 @@ dotnet test ## 致谢与许可 设计与交互来自 [SonghaiFan/leftopen](https://github.com/SonghaiFan/leftopen)(MIT)。本移植版沿用同一理念与安全模型。 + +### Windows 原生控制台回归探针 + +在 Windows + .NET 8 SDK 中运行(退出码非零表示失败): + +```powershell +dotnet run --project tests/LeftOpen.ConsoleSafety.Probe +``` + +探针先分离调用者的控制台,再创建专用控制台和三个受控测试进程(共享组的服务、显式新组的服务、该新组中的后代)。只在确认控制台成员全部为这些测试进程后广播,并记录真实控制事件。检查广播范围、Ctrl+C 非零组行为、Ctrl+Break 组内后代范围,以及生产关闭路径拒绝所有控制台服务且没有事件送达。测试处理器拦截退出,finally 用停止文件让测试进程自行退出;不会向开发者的终端广播。 + +此探针是待 Windows 实测的回归验证,不是现有 mock 单元测试的替代。Linux 代码审查不能证明其运行结果;合并或发布预览版前须保留 Windows 输出并运行 `dotnet test LeftOpen.sln`。 diff --git a/windows/src/LeftOpen.Cli/Program.cs b/windows/src/LeftOpen.Cli/Program.cs index cc53f57..8ac09f9 100644 --- a/windows/src/LeftOpen.Cli/Program.cs +++ b/windows/src/LeftOpen.Cli/Program.cs @@ -211,7 +211,7 @@ private static async Task RunClose(string[] args) } } - Console.WriteLine(Dim("Sending a gentle close (WM_CLOSE / Ctrl+C / Ctrl+Break) and checking the port...")); + Console.WriteLine(Dim("Sending WM_CLOSE to a verified target-owned window and checking the port...")); var result = await service.ExecuteCloseAsync(plan); if (result.PortFree) { @@ -228,7 +228,7 @@ private static async Task RunClose(string[] args) if (!result.SignalsDelivered) { Console.WriteLine(Yellow( - $"No gentle close could be delivered to PID {plan.Pid} (it has no reachable window or console), so it was left running. " + + $"No gentle close was delivered to PID {plan.Pid}. {ProcessTerminator.NoSafeCloseReason} " + "No force-kill was attempted.")); return 1; } diff --git a/windows/src/LeftOpen.Core/ProcessTerminator.cs b/windows/src/LeftOpen.Core/ProcessTerminator.cs index 049276f..7b5a20b 100644 --- a/windows/src/LeftOpen.Core/ProcessTerminator.cs +++ b/windows/src/LeftOpen.Core/ProcessTerminator.cs @@ -4,111 +4,56 @@ namespace LeftOpen.Core; /// -/// The Windows stand-in for leftopen's SIGTERM-only rule. Windows has no per-process -/// signal, so the gentle ladder is: (1) WM_CLOSE to the process's own window, then -/// (2) console Ctrl+C followed by Ctrl+Break, delivered as if the user pressed the -/// keys in the terminal hosting the process. Ctrl+Break matters because processes -/// started in a new process group (e.g. PowerShell Start-Process) ignore Ctrl+C, -/// while Ctrl+Break always gets through. Force-kill (taskkill /F, Process.Kill) is -/// never used; a process that ignores everything is reported honestly instead. +/// Only posts WM_CLOSE to a window owned by the target PID. Console control events +/// are deliberately unsupported: Ctrl+C cannot target a group, and Ctrl+Break +/// targets a group (including descendants), not a verified individual PID. +/// LeftOpen did not create these processes and cannot prove their group scope. /// public static class ProcessTerminator { - private const uint CtrlCEvent = 0; - private const uint CtrlBreakEvent = 1; - private const uint AttachParentProcess = unchecked(uint.MaxValue); + public const string NoSafeCloseReason = + "No reachable window owned by the target PID. Console Ctrl+C / Ctrl+Break " + + "are disabled because their recipients cannot be restricted to the verified target; " + + "they could stop other services sharing its console or process group."; - /// Sends every gentle close signal we have. True when something was actually delivered. + /// True only if WM_CLOSE was posted, or the process already exited. public static bool TrySendGentleClose(int pid) { - var delivered = false; - try { using var process = Process.GetProcessById(pid); - if (!process.HasExited && process.MainWindowHandle != 0) + if (process.HasExited) { - process.CloseMainWindow(); - delivered = true; + return true; } - } - catch (ArgumentException) - { - // Process already exited — nothing to signal, but also nothing left to do. - return true; - } - catch (Exception ex) when (ex is InvalidOperationException or System.ComponentModel.Win32Exception) - { - // Exited between enumeration and access, or details unavailable — - // the console path may still work. - } - if (TrySendConsoleCtrl(pid)) - { - delivered = true; - } - - return delivered; - } - - private static bool TrySendConsoleCtrl(int pid) - { - var hadConsole = GetConsoleWindow() != IntPtr.Zero; - if (hadConsole && !FreeConsole()) - { - return false; - } - - if (!AttachConsole((uint)pid)) - { - if (hadConsole) + var window = process.MainWindowHandle; + // A console's visible window belongs to the console host, not the + // service. Never close that host (or another PID's window). + if (window == IntPtr.Zero || + GetWindowThreadProcessId(window, out var ownerPid) == 0 || + ownerPid != (uint)pid) { - AttachConsole(AttachParentProcess); + return false; } - return false; + // Use the exact checked handle and report the native API result. + return PostMessage(window, 0x0010 /* WM_CLOSE */, IntPtr.Zero, IntPtr.Zero); } - - try + catch (ArgumentException) { - // A real handler that returns TRUE suppresses the default action for - // BOTH Ctrl+C and Ctrl+Break (the NULL-handler "ignore mode" only - // covers Ctrl+C), so the events we generate for that console do not - // terminate leftopen itself. - SetConsoleCtrlHandler(KeepAliveHandler, true); - GenerateConsoleCtrlEvent(CtrlCEvent, 0); - GenerateConsoleCtrlEvent(CtrlBreakEvent, 0); - Thread.Sleep(100); + return true; // Target already exited. } - finally + catch (Exception ex) when (ex is InvalidOperationException or System.ComponentModel.Win32Exception) { - FreeConsole(); - SetConsoleCtrlHandler(KeepAliveHandler, false); - if (hadConsole) - { - AttachConsole(AttachParentProcess); - } + return false; } - - return true; } - private delegate bool ConsoleCtrlDelegate(uint ctrlType); - - private static readonly ConsoleCtrlDelegate KeepAliveHandler = _ => true; - - [DllImport("kernel32.dll")] - private static extern IntPtr GetConsoleWindow(); - - [DllImport("kernel32.dll", SetLastError = true)] - private static extern bool FreeConsole(); - - [DllImport("kernel32.dll", SetLastError = true)] - private static extern bool AttachConsole(uint processId); - - [DllImport("kernel32.dll", SetLastError = true)] - private static extern bool SetConsoleCtrlHandler(ConsoleCtrlDelegate? handlerRoutine, bool add); + [DllImport("user32.dll", SetLastError = true)] + private static extern uint GetWindowThreadProcessId(IntPtr window, out uint processId); - [DllImport("kernel32.dll", SetLastError = true)] - private static extern bool GenerateConsoleCtrlEvent(uint ctrlEvent, uint processGroup); + [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool PostMessage(IntPtr window, uint message, IntPtr wParam, IntPtr lParam); } diff --git a/windows/src/LeftOpen.Tray/PanelForm.cs b/windows/src/LeftOpen.Tray/PanelForm.cs index 6dbab64..f85922a 100644 --- a/windows/src/LeftOpen.Tray/PanelForm.cs +++ b/windows/src/LeftOpen.Tray/PanelForm.cs @@ -632,7 +632,7 @@ private void CloseWithConfirm(GroupRow group) flow.Controls.Add(new Label { - Text = "将发送温和关闭(等同在该进程的终端里按 Ctrl+C),绝不使用强杀;若它不响应,会如实告诉你。", + Text = "只向目标进程自己的窗口发送温和关闭;控制台信号可能误停其他服务,已禁用。无目标窗口时会拒绝关闭,绝不强杀。", AutoSize = true, MaximumSize = new Size(PanelWidth - 40, 0), Margin = new Padding(16, 4, 16, 12), @@ -692,7 +692,7 @@ private async Task ExecuteClose(GroupRow group) } else if (!result.SignalsDelivered) { - label.Text = $"无法向 PID {group.Pid} 送达温和关闭(无可达的窗口或控制台),进程未受影响,未强杀。"; + label.Text = $"未向 PID {group.Pid} 送达温和关闭:无可达的目标进程窗口。控制台信号可能影响共享控制台或进程组的其他服务,已拒绝发送。未强杀。"; } else { diff --git a/windows/tests/LeftOpen.ConsoleSafety.Probe/LeftOpen.ConsoleSafety.Probe.csproj b/windows/tests/LeftOpen.ConsoleSafety.Probe/LeftOpen.ConsoleSafety.Probe.csproj new file mode 100644 index 0000000..90ab93a --- /dev/null +++ b/windows/tests/LeftOpen.ConsoleSafety.Probe/LeftOpen.ConsoleSafety.Probe.csproj @@ -0,0 +1,12 @@ + + + Exe + net8.0-windows + enable + enable + true + + + + + diff --git a/windows/tests/LeftOpen.ConsoleSafety.Probe/Program.cs b/windows/tests/LeftOpen.ConsoleSafety.Probe/Program.cs new file mode 100644 index 0000000..ba6db76 --- /dev/null +++ b/windows/tests/LeftOpen.ConsoleSafety.Probe/Program.cs @@ -0,0 +1,166 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text; +using LeftOpen.Core; + +// Runs separately from testhost: console attachment/handlers are process-global. +internal static class Program +{ + private delegate bool Handler(uint kind); + private static Handler? _handler; // Root the native callback for the process lifetime. + private static string Exe => Environment.ProcessPath!; + + private static int Main(string[] args) + { + if (!OperatingSystem.IsWindows()) + { + Console.Error.WriteLine("Requires real Windows; no test was run."); + return 2; + } + if (args.Length > 0 && args[0] == "worker") + return Worker(args[1], args[2], args.Length > 3); + + var dir = Path.Combine(Path.GetTempPath(), "leftopen-console-" + Guid.NewGuid()); + Directory.CreateDirectory(dir); + Console.WriteLine("Probe markers: " + dir); + var children = new List(); + try + { + FreeConsole(); // Never test a broadcast in the user's original console. + Require(AllocConsole(), "AllocConsole"); + _handler = _ => true; + Require(SetConsoleCtrlHandler(_handler, true), "host handler"); + children.Add(Spawn(dir, "shared", false, false)); + children.Add(Spawn(dir, "group", true, true)); + WaitFor(() => new[] { "shared", "group", "descendant" } + .All(name => File.Exists(Path.Combine(dir, name + ".ready")))); + var descendantPid = int.Parse(File.ReadAllText(Path.Combine(dir, "descendant.ready"))); + children.Add(Process.GetProcessById(descendantPid)); + var expected = children.Select(p => (uint)p.Id).Append((uint)Environment.ProcessId).ToHashSet(); + var members = new uint[16]; + var count = GetConsoleProcessList(members, (uint)members.Length); + Require(count == expected.Count && members.Take((int)count).ToHashSet().SetEquals(expected), + "Dedicated console contains only the four controlled probe processes"); + + foreach (var child in children) + Require(!ProcessTerminator.TrySendGentleClose(child.Id), "console close must be refused"); + Thread.Sleep(300); + Require(!Directory.EnumerateFiles(dir, "*.event").Any(), "refusal delivers no events"); + Require(children.All(p => !p.HasExited), "all services survive refusal"); + + // Handlers enable Ctrl+C explicitly, even in CREATE_NEW_PROCESS_GROUP. + Require(GenerateConsoleCtrlEvent(0, 0), "Ctrl+C broadcast"); + WaitFor(() => children.All(p => Event(dir, p.Id, 0))); + Require(GenerateConsoleCtrlEvent(1, 0), "Ctrl+Break broadcast"); + WaitFor(() => children.All(p => Event(dir, p.Id, 1))); + Console.WriteLine("PASS: both broadcasts reach all three controlled recipients."); + ClearEvents(dir); + + Require(GenerateConsoleCtrlEvent(0, (uint)children[1].Id), "Ctrl+C nonzero group call"); + Thread.Sleep(500); + Require(!Directory.EnumerateFiles(dir, "*.event").Any(), "Ctrl+C does not target a group"); + Require(GenerateConsoleCtrlEvent(1, (uint)children[1].Id), "Ctrl+Break group call"); + WaitFor(() => Event(dir, children[1].Id, 1) && Event(dir, descendantPid, 1)); + Thread.Sleep(300); + Require(!Event(dir, children[0].Id, 1), "other group does not receive targeted break"); + Console.WriteLine("PASS: targeted Ctrl+Break also reaches the group's descendant, not just the root PID."); + ClearEvents(dir); + foreach (var child in children) + Require(!ProcessTerminator.TrySendGentleClose(child.Id), "close still refused after group events"); + Thread.Sleep(300); + Require(!Directory.EnumerateFiles(dir, "*.event").Any() && children.All(p => !p.HasExited), + "production path leaves every controlled service running without events"); + Console.WriteLine("PASS: production console closes refused; target, sibling and group descendant remain alive."); + return 0; + } + catch (Exception ex) + { + Console.Error.WriteLine(ex); + return 1; + } + finally + { + File.WriteAllText(Path.Combine(dir, "stop"), "stop"); + foreach (var child in children) + { + if (!child.WaitForExit(5000)) + Console.Error.WriteLine($"Probe PID {child.Id} did not exit promptly; it has a 30s timeout."); + child.Dispose(); + } + FreeConsole(); + // Keep temporary marker files for diagnosis. + } + } + + private static int Worker(string dir, string name, bool spawnDescendant) + { + _handler = kind => + { + if (kind is 0 or 1) + { + try { File.WriteAllText(Path.Combine(dir, $"{Environment.ProcessId}.{kind}.event"), "received"); } + catch { /* Host timeout will fail the test if logging fails. */ } + } + return true; + }; + Require(SetConsoleCtrlHandler(null, false), "enable Ctrl+C"); + Require(SetConsoleCtrlHandler(_handler, true), "worker handler"); + using var descendant = spawnDescendant ? Spawn(dir, "descendant", false, false) : null; + File.WriteAllText(Path.Combine(dir, name + ".ready"), Environment.ProcessId.ToString()); + var timeout = Stopwatch.StartNew(); + while (!File.Exists(Path.Combine(dir, "stop")) && timeout.Elapsed < TimeSpan.FromSeconds(30)) + Thread.Sleep(50); + descendant?.WaitForExit(5000); + return 0; + } + + private static Process Spawn(string dir, string name, bool newGroup, bool descendant) + { + var info = new StartupInfo { cb = Marshal.SizeOf() }; + var command = new StringBuilder($"\"{Exe}\" worker \"{dir}\" {name}" + (descendant ? " parent" : "")); + Require(CreateProcess(Exe, command, IntPtr.Zero, IntPtr.Zero, false, + newGroup ? 0x00000200u : 0u, IntPtr.Zero, null, ref info, out var process), "CreateProcess"); + try { return Process.GetProcessById((int)process.pid); } + finally { CloseHandle(process.thread); CloseHandle(process.process); } + } + + private static bool Event(string dir, int pid, uint kind) => File.Exists(Path.Combine(dir, $"{pid}.{kind}.event")); + private static void ClearEvents(string dir) + { + foreach (var file in Directory.EnumerateFiles(dir, "*.event")) File.Delete(file); + } + private static void WaitFor(Func condition) + { + var timeout = Stopwatch.StartNew(); + while (!condition()) + { + if (timeout.Elapsed > TimeSpan.FromSeconds(5)) throw new Exception("Probe timed out waiting for ready/event files."); + Thread.Sleep(25); + } + } + private static void Require(bool success, string message) + { + if (!success) throw new Exception(message + $" failed (last error {Marshal.GetLastWin32Error()})."); + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct StartupInfo + { + public int cb; + public string? reserved, desktop, title; + public uint x, y, width, height, xChars, yChars, fill, flags; + public ushort show, reservedBytes; + public IntPtr reservedPtr, input, output, error; + } + [StructLayout(LayoutKind.Sequential)] + private struct ProcessInfo { public IntPtr process, thread; public uint pid, tid; } + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool CreateProcess(string app, StringBuilder command, IntPtr pa, IntPtr ta, + bool inherit, uint flags, IntPtr environment, string? cwd, ref StartupInfo startup, out ProcessInfo process); + [DllImport("kernel32.dll")] private static extern bool CloseHandle(IntPtr handle); + [DllImport("kernel32.dll")] private static extern bool FreeConsole(); + [DllImport("kernel32.dll", SetLastError = true)] private static extern bool AllocConsole(); + [DllImport("kernel32.dll", SetLastError = true)] private static extern bool SetConsoleCtrlHandler(Handler? handler, bool add); + [DllImport("kernel32.dll", SetLastError = true)] private static extern bool GenerateConsoleCtrlEvent(uint kind, uint group); + [DllImport("kernel32.dll", SetLastError = true)] private static extern uint GetConsoleProcessList(uint[] processes, uint length); +} diff --git a/windows/tests/LeftOpen.Core.Tests/CloseServiceTests.cs b/windows/tests/LeftOpen.Core.Tests/CloseServiceTests.cs index ddec630..f8779ff 100644 --- a/windows/tests/LeftOpen.Core.Tests/CloseServiceTests.cs +++ b/windows/tests/LeftOpen.Core.Tests/CloseServiceTests.cs @@ -315,4 +315,26 @@ public async Task ExecuteCloseAsync_PortTakenOverByPeer_ReportsRemainingPid() Assert.False(result.PortFree); Assert.Equal([101], result.RemainingPids); } + [Theory] + [InlineData("start")] + [InlineData("owner")] + [InlineData("exe")] + [InlineData("peer")] + public async Task ExecuteCloseAsync_ChangedPreview_NeverInvokesTerminator(string change) + { + var harness = new Harness { Snapshot = [Activity(100, 3000)] }; + var service = harness.Service(); + var plan = await service.PrepareCloseAsync(new CloseOptions(3000, null, CurrentSid, 555)); + switch (change) + { + case "start": harness.StartTime = Started.AddSeconds(1); break; + case "owner": harness.Snapshot = [Activity(100, 3000, sid: "other")]; break; + case "exe": harness.Snapshot = [Activity(100, 3000, exe: @"C:\dev\other.exe")]; break; + case "peer": harness.Snapshot.Add(Activity(101, 3000)); break; + } + await Assert.ThrowsAsync(() => service.ExecuteCloseAsync(plan)); + Assert.Equal(0, harness.Signalled); + Assert.Equal(0, harness.Waits); + } + } From b035d919c8529bf5f9a563f4e9dfe7fc388e4c70 Mon Sep 17 00:00:00 2001 From: "yan.gao1" Date: Mon, 12 Oct 2026 11:37:29 +0800 Subject: [PATCH 4/5] Windows: CI with tests + console-safety probe, self-contained release packaging - .github/workflows/windows-ci.yml: on windows/** changes build Release, run the 60 unit tests, run the console-safety probe, then package the self-contained shared-runtime zip (LeftOpen-windows-x64.zip, ~67 MB, unzip and run, no .NET install) and upload it as an artifact; tags windows-v* attach it to a GitHub release - publish.ps1: -Shared mode produces the same release layout CI uses - README: document the CI and the release packaging --- .github/workflows/windows-ci.yml | 60 ++++++++++++++++++++++++++++++++ windows/.gitignore | 1 + windows/README.md | 7 ++++ windows/Scripts/publish.ps1 | 54 ++++++++++++++++++++-------- 4 files changed, 108 insertions(+), 14 deletions(-) create mode 100644 .github/workflows/windows-ci.yml diff --git a/.github/workflows/windows-ci.yml b/.github/workflows/windows-ci.yml new file mode 100644 index 0000000..af0a816 --- /dev/null +++ b/.github/workflows/windows-ci.yml @@ -0,0 +1,60 @@ +name: Windows CI + +on: + push: + branches: [main, windows-port] + paths: + - "windows/**" + - ".github/workflows/windows-ci.yml" + pull_request: + paths: + - "windows/**" + - ".github/workflows/windows-ci.yml" + workflow_dispatch: + +permissions: + contents: write # required by the release step on windows-v* tags + +jobs: + test-and-package: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET 8 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Build (Release) + run: dotnet build windows/LeftOpen.sln -c Release + + - name: Unit tests + run: dotnet test windows/LeftOpen.sln -c Release --no-build + + # Regression probe for the console-close safety boundary: proves the + # Ctrl+C / Ctrl+Break broadcast scope and that production refuses them. + - name: Console-safety probe + run: dotnet run --project windows/tests/LeftOpen.ConsoleSafety.Probe -c Release + + # The release layout the maintainer asked for: unzip and run, no .NET install. + - name: Package self-contained zip + run: powershell -File windows/Scripts/publish.ps1 -Shared -OutputDir windows/release + + - name: Upload package artifact + uses: actions/upload-artifact@v4 + with: + name: LeftOpen-windows-x64 + path: windows/release/LeftOpen-windows-x64.zip + if-no-files-found: error + + - name: Attach to release (windows-v* tags only) + if: startsWith(github.ref, 'refs/tags/windows-v') + uses: softprops/action-gh-release@v2 + with: + files: windows/release/LeftOpen-windows-x64.zip + body: | + Self-contained Windows build — unzip and run, no .NET installation required. + + - `LeftOpenApp.exe` — tray app (door icon in the notification area) + - `leftopen.exe` — CLI (`leftopen [port] / open / close / --json`) diff --git a/windows/.gitignore b/windows/.gitignore index 213dd8d..8f9c5ee 100644 --- a/windows/.gitignore +++ b/windows/.gitignore @@ -3,6 +3,7 @@ bin/ obj/ dist/ dist-*/ +release/ # 面板快照与图标校验产物(--snapshot / 人工验证生成的图片) panel-snapshot*.png diff --git a/windows/README.md b/windows/README.md index 3f1628e..81f57bf 100644 --- a/windows/README.md +++ b/windows/README.md @@ -33,8 +33,15 @@ powershell -File Scripts\publish.ps1 # 发布自包含版本(目标机器无需安装 .NET,托盘约 158 MB——WinForms 不支持裁剪) powershell -File Scripts\publish.ps1 -SelfContained + +# 发布自包含"共享运行时"文件夹 + zip(约 67 MB,解压即用;CI 打包用的就是它) +powershell -File Scripts\publish.ps1 -Shared ``` +## CI 与发布 + +`.github/workflows/windows-ci.yml` 在每次改动 `windows/` 时自动运行:Release 构建 → 60 个单元测试 → 控制台安全探针 → 打出自包含 zip(`LeftOpen-windows-x64.zip`,解压即用、无需安装 .NET)并上传为构建产物。打 `windows-v*` 标签时,zip 会自动附加到 GitHub Release。 + ## 托盘应用 - 启动后只驻留一个托盘图标(门):**不扫描、不轮询、无守护进程**。 diff --git a/windows/Scripts/publish.ps1 b/windows/Scripts/publish.ps1 index 28bad62..60c0d2f 100644 --- a/windows/Scripts/publish.ps1 +++ b/windows/Scripts/publish.ps1 @@ -1,15 +1,19 @@ # Builds release artifacts for LeftOpen on Windows. -# Usage: powershell -File windows\Scripts\publish.ps1 [-OutputDir windows\dist] [-SelfContained] +# Usage: powershell -File windows\Scripts\publish.ps1 [-OutputDir windows\dist] [-SelfContained] [-Shared] # # Default: framework-dependent single-file executables (~1.5 MB each) — small and # fast to start, but require the .NET 8 runtime (SDK or Desktop Runtime). -# -SelfContained: standalone executables that run on any Windows 10+ box without -# a runtime, at the cost of size (WinForms cannot be trimmed). +# -SelfContained: standalone single-file executables that run on any Windows 10+ box +# without a runtime, at the cost of size (WinForms cannot be trimmed). +# -Shared: the release layout — both executables sharing one self-contained runtime +# folder (~67 MB zipped instead of ~91 MB for two single-file exes), then zipped. +# Unzip and run; no .NET installation needed. This is what CI packages. param( # Relative paths resolve against the repository root, not the current directory. [string]$OutputDir = "windows\dist", - [switch]$SelfContained + [switch]$SelfContained, + [switch]$Shared ) $ErrorActionPreference = "Stop" @@ -17,21 +21,43 @@ $ErrorActionPreference = "Stop" $repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) Set-Location $repoRoot -$common = @("-c", "Release", "-r", "win-x64", "-p:PublishSingleFile=true") -if ($SelfContained) { - $common += "--self-contained", "true", "-p:IncludeNativeLibrariesForSelfExtract=true" +$common = @("-c", "Release", "-r", "win-x64") +if ($SelfContained -or $Shared) { + $common += "--self-contained", "true" } else { - $common += "--self-contained", "false" + $common += "--self-contained", "false", "-p:PublishSingleFile=true" } -Write-Host "==> Publishing CLI (leftopen.exe)..." -ForegroundColor Cyan -dotnet publish "windows\src\LeftOpen.Cli\LeftOpen.Cli.csproj" @common -o $OutputDir -if ($LASTEXITCODE -ne 0) { throw "CLI publish failed." } +$projects = @( + "windows\src\LeftOpen.Cli\LeftOpen.Cli.csproj", + "windows\src\LeftOpen.Tray\LeftOpen.Tray.csproj" +) + +if ($Shared) { + $folder = Join-Path $OutputDir "LeftOpen-windows-x64" + Write-Host "==> Publishing shared-runtime folder to $folder..." -ForegroundColor Cyan + foreach ($project in $projects) { + dotnet publish $project @common -o $folder + if ($LASTEXITCODE -ne 0) { throw "publish failed for $project" } + } + + $zip = Join-Path $OutputDir "LeftOpen-windows-x64.zip" + Write-Host "==> Zipping $zip..." -ForegroundColor Cyan + Compress-Archive -Path $folder -DestinationPath $zip -Force -Write-Host "==> Publishing tray app (LeftOpenApp.exe)..." -ForegroundColor Cyan -dotnet publish "windows\src\LeftOpen.Tray\LeftOpen.Tray.csproj" @common -o $OutputDir -if ($LASTEXITCODE -ne 0) { throw "Tray publish failed." } + Write-Host "" + Write-Host "Done. Self-contained package in ${OutputDir}:" -ForegroundColor Green + "{0,-26} {1,10:N1} MB" -f (Split-Path -Leaf $zip), ((Get-Item $zip).Length / 1MB) + return +} + +foreach ($project in $projects) { + $name = Split-Path -Leaf (Split-Path -Parent $project) + Write-Host "==> Publishing $name..." -ForegroundColor Cyan + dotnet publish $project @common -o $OutputDir + if ($LASTEXITCODE -ne 0) { throw "publish failed for $project" } +} Write-Host "" Write-Host "Done. Artifacts in ${OutputDir}:" -ForegroundColor Green From 00baeb69247d45b998663958341ae4196c9af53d Mon Sep 17 00:00:00 2001 From: "yan.gao1" Date: Mon, 12 Oct 2026 13:51:45 +0800 Subject: [PATCH 5/5] fix(windows): probe asserts observed group-targeted Ctrl+C behaviour Running the reviewer's probe on real Windows (first execution anywhere; it was authored in a Linux environment without .NET) showed phase C asserted the opposite of observed behaviour: - GenerateConsoleCtrlEvent(CTRL_C_EVENT, group) does not fail and delivers to the group root AND its descendant (marker files prove it), while a console-mate outside the group is spared. The probe now asserts this observed delivery pattern instead of "no events". - The safety conclusion is unchanged and stronger: a group includes descendant processes LeftOpen did not create, so production correctly refuses every console control event. Also report probe failures on stdout: after FreeConsole/AllocConsole the original stderr handle is dead and .NET silently drops Console.Error writes, which made CI failures undiagnosable. Probe output now: four PASS phases, exit 0, on Win10 19045 and on the windows-latest CI runner. --- .../LeftOpen.ConsoleSafety.Probe/Program.cs | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/windows/tests/LeftOpen.ConsoleSafety.Probe/Program.cs b/windows/tests/LeftOpen.ConsoleSafety.Probe/Program.cs index ba6db76..c859581 100644 --- a/windows/tests/LeftOpen.ConsoleSafety.Probe/Program.cs +++ b/windows/tests/LeftOpen.ConsoleSafety.Probe/Program.cs @@ -56,9 +56,18 @@ private static int Main(string[] args) Console.WriteLine("PASS: both broadcasts reach all three controlled recipients."); ClearEvents(dir); - Require(GenerateConsoleCtrlEvent(0, (uint)children[1].Id), "Ctrl+C nonzero group call"); + // Real Windows behaviour (observed on Win10 19045, 2026-10): a group-targeted + // Ctrl+C IS delivered — to the group root AND its descendants — while a + // console-mate outside the group is spared. A group therefore includes + // processes LeftOpen did not create, which is exactly why production + // refuses every console control event. + Require(GenerateConsoleCtrlEvent(0, (uint)children[1].Id), "Ctrl+C group-targeted call"); Thread.Sleep(500); - Require(!Directory.EnumerateFiles(dir, "*.event").Any(), "Ctrl+C does not target a group"); + Require(Event(dir, children[1].Id, 0), "group root receives group-targeted Ctrl+C"); + Require(Event(dir, descendantPid, 0), "group descendant receives group-targeted Ctrl+C"); + Require(!Event(dir, children[0].Id, 0), "console mate outside the group does not receive it"); + Console.WriteLine("PASS: group-targeted Ctrl+C reaches the group root and its descendant, sparing other console members."); + ClearEvents(dir); Require(GenerateConsoleCtrlEvent(1, (uint)children[1].Id), "Ctrl+Break group call"); WaitFor(() => Event(dir, children[1].Id, 1) && Event(dir, descendantPid, 1)); Thread.Sleep(300); @@ -75,7 +84,11 @@ private static int Main(string[] args) } catch (Exception ex) { - Console.Error.WriteLine(ex); + // After FreeConsole/AllocConsole the original stderr handle is dead and + // .NET silently drops Console.Error writes — also report on stdout so + // CI failures stay diagnosable. + Console.Out.WriteLine("PROBE FAILED: " + ex.Message); + try { Console.Error.WriteLine(ex); } catch { /* best effort */ } return 1; } finally