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/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..8f9c5ee --- /dev/null +++ b/windows/.gitignore @@ -0,0 +1,20 @@ +# 构建产物 +bin/ +obj/ +dist/ +dist-*/ +release/ + +# 面板快照与图标校验产物(--snapshot / 人工验证生成的图片) +panel-snapshot*.png +exe-icon-check.png + +# 测试结果 +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..81f57bf --- /dev/null +++ b/windows/README.md @@ -0,0 +1,162 @@ +# 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 + +# 发布自包含"共享运行时"文件夹 + 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。 + +## 托盘应用 + +- 启动后只驻留一个托盘图标(门):**不扫描、不轮询、无守护进程**。 +- 点击图标 → 弹出面板 → **此刻才扫描一次**;再次点击或点击别处自动收起。 +- 面板分组:`我的项目` / `其他进程` / `不可关闭(系统或应用,默认折叠)`。 +- 每行显示:端口(多端口显示 `+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 | 仅向目标 PID 自己的窗口发送 WM_CLOSE;拒绝控制台广播 | +| 菜单栏 `MenuBarExtra` | 系统托盘 `NotifyIcon` + 弹出面板 | + +`LeftOpen.Core` 是共享引擎,托盘应用与 CLI 用的是**完全相同**的扫描、推断与关闭规则。 + +### 控制台关闭的安全边界 + +`GenerateConsoleCtrlEvent(..., 0)` 会向共享控制台的所有进程广播。Ctrl+C 不能限定进程组;Ctrl+Break 可以限定组,但组包含后代,PID 也不等于已验证的组身份。现有扫描和 PID 身份复核无法证明所有接收者均已获准关闭,因此控制台信号路径已移除。 + +仅在窗口确实属于已复核的目标 PID 时发送 `WM_CLOSE`,并检查发送结果。普通 node/python 控制台服务通常没有自己的窗口,因此会被安全拒绝;需要在原终端手动停止。即使用户确认或使用 `--yes`,也不会启用广播或强杀。 + +## 安全规则 + +关闭是**三段式**的,任何一步不满足就拒绝执行、绝不发信号: + +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)。本移植版沿用同一理念与安全模型。 + +### 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/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 new file mode 100644 index 0000000..60c0d2f --- /dev/null +++ b/windows/Scripts/publish.ps1 @@ -0,0 +1,66 @@ +# Builds release artifacts for LeftOpen on Windows. +# 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 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]$Shared +) + +$ErrorActionPreference = "Stop" +# 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") +if ($SelfContained -or $Shared) { + $common += "--self-contained", "true" +} +else { + $common += "--self-contained", "false", "-p:PublishSingleFile=true" +} + +$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 "" + 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 +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..8ac09f9 --- /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 WM_CLOSE to a verified target-owned window 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 was delivered to PID {plan.Pid}. {ProcessTerminator.NoSafeCloseReason} " + + "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..7b5a20b --- /dev/null +++ b/windows/src/LeftOpen.Core/ProcessTerminator.cs @@ -0,0 +1,59 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; + +namespace LeftOpen.Core; + +/// +/// 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 +{ + 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."; + + /// True only if WM_CLOSE was posted, or the process already exited. + public static bool TrySendGentleClose(int pid) + { + try + { + using var process = Process.GetProcessById(pid); + if (process.HasExited) + { + return true; + } + + 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) + { + return false; + } + + // Use the exact checked handle and report the native API result. + return PostMessage(window, 0x0010 /* WM_CLOSE */, IntPtr.Zero, IntPtr.Zero); + } + catch (ArgumentException) + { + return true; // Target already exited. + } + catch (Exception ex) when (ex is InvalidOperationException or System.ComponentModel.Win32Exception) + { + return false; + } + } + + [DllImport("user32.dll", SetLastError = true)] + private static extern uint GetWindowThreadProcessId(IntPtr window, out uint processId); + + [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.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..1d2fc2b --- /dev/null +++ b/windows/src/LeftOpen.Tray/AppIcons.cs @@ -0,0 +1,343 @@ +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, 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 partial class AppIcons +{ + /// + /// 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 (!File.Exists(executablePath)) + { + return null; + } + + using var icon = Icon.ExtractAssociatedIcon(executablePath); + return icon == null ? null : new Bitmap(icon.ToBitmap(), size, size); + } + catch + { + 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 + { + var bytes = FetchBytes($"http://127.0.0.1:{port}/favicon.ico"); + if (bytes == null) + { + // No /favicon.ico: ask the served HTML what it declares. + var html = FetchText($"http://127.0.0.1:{port}/"); + if (html != null) + { + 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 + { + 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 + } + + 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)); + } + + /// 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, PixelFormat.Format32bppArgb); + using var g = Graphics.FromImage(bitmap); + 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) / 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 0000000..874ab08 Binary files /dev/null and b/windows/src/LeftOpen.Tray/Assets/leftopen.ico differ 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 new file mode 100644 index 0000000..ccd8dd4 --- /dev/null +++ b/windows/src/LeftOpen.Tray/GroupRowControl.cs @@ -0,0 +1,253 @@ +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; + + // 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 = rowWidth; + 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}"; + + // 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, 4), + SizeMode = PictureBoxSizeMode.CenterImage, + }; + + var label = new Label + { + Text = TrimLabel(group.Label), + AutoSize = false, + 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)} · {group.Pid}", + AutoSize = false, + Size = new Size(rowWidth - 216, 16), + Location = new Point(116, 27), + ForeColor = SystemColors.GrayText, + }; + + var uptime = new Label + { + Text = group.StartTime != null ? DescribeDuration(DateTime.Now - group.StartTime.Value) : "—", + AutoSize = false, + 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 + { + Text = "LAN", + AutoSize = false, + Size = new Size(34, 16), + 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), + 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(rowWidth - 44, 4), + 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 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, group.Ports[0], group.Category, group.Command, 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 <= 40 ? label : label[..39] + "…"; + + 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..5c3626b --- /dev/null +++ b/windows/src/LeftOpen.Tray/LeftOpen.Tray.csproj @@ -0,0 +1,20 @@ + + + + + + + + WinExe + LeftOpenApp + LeftOpen.Tray + net8.0-windows + enable + true + enable + 0.1.0 + + Assets\leftopen.ico + + + diff --git a/windows/src/LeftOpen.Tray/PanelForm.cs b/windows/src/LeftOpen.Tray/PanelForm.cs new file mode 100644 index 0000000..f85922a --- /dev/null +++ b/windows/src/LeftOpen.Tray/PanelForm.cs @@ -0,0 +1,767 @@ +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 = 480; + 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 = ""; + private DateTime _shownAt; + private bool _everActivated; + + 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 = 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. + if (_currentView is { Name: "confirmView" or "progressView" }) + { + 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(); + } + + 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); + DoorMark.Paint(g, new RectangleF(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 - 90, 0), + Margin = new Padding(14, 12, 14, 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 = "只向目标进程自己的窗口发送温和关闭;控制台信号可能误停其他服务,已禁用。无目标窗口时会拒绝关闭,绝不强杀。", + 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}小时"; + } + + [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 new file mode 100644 index 0000000..e4f5a99 --- /dev/null +++ b/windows/src/LeftOpen.Tray/Program.cs @@ -0,0 +1,277 @@ +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(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). 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(); + 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); + } + + 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. +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 = DoorMark.RenderTrayIcon(TrayIconSize(), DoorMark.TrayState.Closed, 0, DoorMark.ThemeFrameColor()), + Text = "LeftOpen — 查看遗留进程", + ContextMenuStrip = menu, + Visible = true, + }; + _notifyIcon.MouseClick += OnTrayMouseClick; + + Application.ApplicationExit += OnApplicationExit; + } + + private void OnTrayMouseClick(object? sender, MouseEventArgs e) + { + if (e.Button == MouseButtons.Left) + { + 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() + { + if (_panel.InvokeRequired) + { + _panel.BeginInvoke(OpenPanel); + return; + } + + _panel.OpenFromTray(rescan: _panel.IsStale); + } + + private void OnScanFinished(ScanResult result) + { + if (_disposed) + { + return; + } + + // 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(); + 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 = DoorMark.RenderTrayIcon(TrayIconSize(), state, closablePorts, DoorMark.ThemeFrameColor()); + old?.Dispose(); + + _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"; + 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.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..c859581 --- /dev/null +++ b/windows/tests/LeftOpen.ConsoleSafety.Probe/Program.cs @@ -0,0 +1,179 @@ +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); + + // 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(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); + 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) + { + // 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 + { + 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 new file mode 100644 index 0000000..f8779ff --- /dev/null +++ b/windows/tests/LeftOpen.Core.Tests/CloseServiceTests.cs @@ -0,0 +1,340 @@ +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); + } + [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); + } + +} 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"); + } +}