From 8e4a6484653e1520e74b1dfed949fb3d83ffaf41 Mon Sep 17 00:00:00 2001 From: jiangchen <1703935164@qq.com> Date: Sun, 27 Sep 2026 14:02:58 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20DSH=20=E6=82=AC=E6=B5=AE=E7=AA=97=20v0.?= =?UTF-8?q?1.0=20=E2=80=94=20=E5=8E=9F=E7=94=9F=E7=BD=AE=E9=A1=B6=E5=B0=8F?= =?UTF-8?q?=E7=AA=97=20+=20=E4=BC=9A=E8=AF=9D=E9=95=9C=E5=83=8F=20+=20?= =?UTF-8?q?=E6=8A=93=E5=B1=8F=E9=9A=90=E8=BA=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - shell: WinForms + WebView2 原生壳(无边框/置顶/固定位置/锁定尺寸/全屏/自适应缩放/截图排除) - shell/ui.html: 自适应界面(工具栏、会话镜像区、底部聊天框、轮/步/tok/s/MTOK/缓存命中/上下文状态条、外观设置) - lib/host.js: DSH 插件 Host 半(endpoint 写入、SSE 会话与指标快照、float_window Agent 工具) - tools: WebView2 SDK 拉取、BOM 修复、Host 冒烟测试 - README: 架构、实测结论(WDA_EXCLUDEFROMCAPTURE、玻璃模式、DPI、WebView2 加载) --- .gitignore | 18 + LICENSE | 21 ++ README.md | 146 ++++++++ cordis.patch.yml | 14 + lib/host.js | 268 ++++++++++++++ package.json | 27 ++ shell/float-window.ps1 | 461 ++++++++++++++++++++++++ shell/run-float.cmd | 6 + shell/ui.html | 731 +++++++++++++++++++++++++++++++++++++++ tools/fix-bom.ps1 | 20 ++ tools/setup-webview2.ps1 | 87 +++++ tools/smoke-host.mjs | 51 +++ 12 files changed, 1850 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 cordis.patch.yml create mode 100644 lib/host.js create mode 100644 package.json create mode 100644 shell/float-window.ps1 create mode 100644 shell/run-float.cmd create mode 100644 shell/ui.html create mode 100644 tools/fix-bom.ps1 create mode 100644 tools/setup-webview2.ps1 create mode 100644 tools/smoke-host.mjs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0c90d3f --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# runtime / build artifacts +.cache/ +.runtime/ +node_modules/ +*.log + +# WebView2 SDK 由 tools/setup-webview2.ps1 拉取,不入库 +shell/lib/*.dll + +# 个人状态(位置/命令/心跳)不入库 +window.json +command.json +alive.json +endpoint.json + +# Windows +Thumbs.db +desktop.ini diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fb45af1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 jiangchen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9871e54 --- /dev/null +++ b/README.md @@ -0,0 +1,146 @@ +# dsh-float-window + +> DeepSeek Harness(DSH)悬浮窗:**始终置顶的会话镜像小窗**。把主界面切到后台,Agent 照样在跑,你在前面用一个小窗盯着它——而且 **Agent 抓屏时这个浮窗自动隐身**,完全不影响它的屏幕操控。 + +仅支持 Windows。 + +--- + +## 它长什么样 + +``` +┌──────────────────────────────────────────┐ +│ ● DSH 悬浮窗 未连接 📌 🔒 ⬆ ⛶ ⚙ ✕ │ ← 拖动这里移动窗口 +├──────────────────────────────────────────┤ +│ │ +│ 会话镜像区(消息流 / 工具调用 / 状态) │ +│ │ +├──────────────────────────────────────────┤ +│ [ 给会话发消息 …………………… ] ↑ │ ← 底部聊天框 +├──────────────────────────────────────────┤ +│ 轮 12 · 步 34 · 18.2 tok/s · 0.43 M tok │ ← 计费/状态条 +│ 缓存 62% · 上下文 ▓▓▓▓░░░░ 41% │ +└──────────────────────────────────────────┘ +``` + +## 功能 + +| 需求 | 实现 | +|---|---| +| 悬浮窗模式(不全屏打开) | 独立原生顶层窗口,无边框,默认贴在屏幕右侧 | +| 始终置顶 | `TopMost`,标题栏 ⬆ 一键开关 | +| 固定位置 | 标题栏 📌(图钉)锁定/解锁拖动 | +| 锁定尺寸 | 标题栏 🔒(小锁)锁定/解锁缩放 | +| 自适应调整 | 布局随窗口尺寸重排;拖动**任意边缘和四角**都能缩放(系统级 WM_NCHITTEST) | +| 全屏切换 | 标题栏 ⛶ 或 `F11`,返回时还原原来的位置与尺寸 | +| 抓屏隐身 | `SetWindowDisplayAffinity(WDA_EXCLUDEFROMCAPTURE)`,Agent 截屏里彻底看不到这个窗 | +| 底部聊天框 | 与当前会话互通(随插件启用) | +| 计费/状态条 | 轮数、步数、tok/s、累计 MTOK、缓存命中率、上下文用量进度条 | +| 外观自定义 | 背景效果:不透明 / 毛玻璃(亚克力)/ 云母 / 模糊 / 半透明;不透明度、界面缩放 | +| 按钮触发 | 运行 `shell\run-float.cmd`,或 DSH 里点插件入口 | +| Agent 自动打开 | 注册 `float_window` 工具,模型可以自己 `open / close / pin / fullscreen / hide` | + +## 快速开始 + +```powershell +git clone dsh-float-window +cd dsh-float-window + +# 1) 拉 WebView2 SDK(首次一次即可) +powershell -ExecutionPolicy Bypass -File tools\setup-webview2.ps1 + +# 2) 直接跑浮窗(不依赖 DSH) +shell\run-float.cmd + +# 3) 装进 DSH(获得会话镜像 + Agent 触发能力),然后重启 DSH +dsh plugin --profile desktop add link:. +``` + +重启后浮窗会自动连上宿主,标题栏的“未连接”变成 `127.0.0.1:`,镜像区开始滚动会话内容。 + +## 命令行 + +```powershell +shell\run-float.cmd # 打开(已开着则前置) +shell\run-float.cmd -NoExclude # 调试:允许被截图(否则你截不到它) +shell\run-float.cmd -Backdrop acrylic -Opacity 0.8 # 毛玻璃 + 80% 霜化 +shell\run-float.cmd -Action pin # 给正在运行的实例下发动作 +``` + +`-Action` 支持 `pin` `sizeLock` `topmost` `fullscreen` `hide` `show` `close`; +已运行的实例每 700ms 读一次 `%DSH_HOME%\float-window\command.json`,所以插件也能这样遥控它。 + +## 架构 + +``` +┌──────────────────────────────┐ ┌───────────────────────────────┐ +│ shell/float-window.ps1 │ │ lib/host.js (DSH 插件) │ +│ WinForms + WebView2 原生壳 │ HTTP │ · 写 endpoint.json 给壳 │ +│ · 置顶 / 锁定 / 全屏 / 缩放 │◀──────▶│ · SSE /events 会话+指标快照 │ +│ · 截图排除 0x11 │ SSE │ · /cmd 控制通道 │ +│ · 位置尺寸记忆 / 单实例 │ │ · float_window 工具(Agent) │ +└──────────────┬───────────────┘ └───────────────────────────────┘ + │ postMessage 桥 ▲ +┌──────────────▼───────────────┐ │ session/event +│ shell/ui.html (全部界面) │ ┌─────────┴─────────────────────┐ +│ 工具栏 / 镜像区 / 聊天框 / 状态条 │ │ DSH 宿主:sessions / tokenMeter │ +└──────────────────────────────┘ └───────────────────────────────┘ +``` + +* **界面完全在 `shell/ui.html`**:原生壳只负责窗口行为,页面通过 WebView2 的 `postMessage` 桥发 `drag / resize / topmost / posLock / sizeLock / fullscreen / opacity / scale / backdrop` 等命令。 +* **数据来自插件**:壳把 `endpoint.json` 里的地址交给页面,页面用 `EventSource` 订阅 `/dsh-float-window/events`,拿到 `{messages, metrics}` 快照。 +* **插件是"探测式"的**:`ctx.get('sessions' | 'tokenMeter' | 'sessionProjection' | 'tools')` 全部 try/catch,缺哪个服务就少几个数字,绝不让插件把 DSH 拖垮。 + +## 已实测的关键结论 + +这台机器(Windows 11 / 150% 缩放 / WebView2 154)上的实测记录: + +1. **截图排除确实可行**:GDI 抓屏下 + * `WDA_NONE` → 抓到窗口本身(对照组) + * `WDA_MONITOR (0x1)` → 抓到一个**纯黑方块**(窗口还在,只是变黑,观感很差) + * `WDA_EXCLUDEFROMCAPTURE (0x11)` → 窗口**完全消失**,抓到的是它背后的画面 + * 只对本进程自己的顶层窗口生效(跨进程调用返回 False),所以浮窗必须是我们自己起的进程。 +2. **毛玻璃模式顺带也是"抓屏隐身"的**:`DWMWA_SYSTEMBACKDROP_TYPE` + `DwmExtendFrameIntoClientArea` 的窗口,GDI 抓屏同样拿不到它(抓到的是底下内容)。 +3. **WebView2 SDK 用 `Assembly.LoadFrom` 会报 0x80131515**(本机 PowerShell 5.1),改用 `[Reflection.Assembly]::Load(bytes)` 加载托管 DLL,再把原生 `WebView2Loader.dll` 放进 `PATH` 就好。 +4. **初始化不能阻塞 UI 线程**:`EnsureCoreWebView2Async(...).GetAwaiter().GetResult()` 会死锁,必须用 `CoreWebView2InitializationCompleted` 事件。 +5. **`CoreWebView2Environment.CreateAsync` 的参数顺序是 `(browserExecutableFolder, userDataFolder, options)`**,传错会得到 "Couldn't find a compatible Webview2 Runtime"。 +6. **高 DPI**:WinForms 会把逻辑尺寸按缩放放大,位置要按真实 `Width/Height` 夹回屏幕内。 +7. `.ps1` 里出现中文时**必须存成 UTF-8 with BOM**,否则 PowerShell 5.1 按 GBK 读会把脚本解析坏(`tools/fix-bom.ps1` 专门干这个)。 + +## 配置 + +持久化在 `%DSH_HOME%\float-window\window.json`(不可写时自动降到 `%LOCALAPPDATA%\dsh-float-window` 或仓库内 `.runtime`): + +```json +{ "x": 1895, "y": 36, "w": 420, "h": 720, "topmost": true, + "posLock": false, "sizeLock": false, "opacity": 0.92, + "exclude": true, "scale": 1.0, "backdrop": "opaque" } +``` + +日志:`%DSH_HOME%\float-window\float-window.log`(每次启动都会记,排障先看它)。 + +## 目录 + +``` +dsh-float-window/ +├─ package.json DSH bundle 插件元数据(dsh.bundle.patch) +├─ cordis.patch.yml 挂载声明 +├─ lib/host.js 插件 Host 半(路由 + SSE + float_window 工具) +├─ shell/float-window.ps1 原生壳(窗口行为 + 互操作) +├─ shell/ui.html 浮窗界面(自适应、工具栏、聊天框、状态条、设置) +├─ shell/run-float.cmd 启动器(用户双击 / 插件拉起) +├─ tools/setup-webview2.ps1 拉取 WebView2 SDK +├─ tools/fix-bom.ps1 给所有 .ps1 补 BOM(中文脚本必备) +└─ tools/smoke-host.mjs 插件 Host 半的假 ctx 冒烟测试 +``` + +## 已知限制 / 路线图 + +* 仅 Windows(macOS 的排除要换 `NSWindow.sharingType`,且 ScreenCaptureKit 是否遵守需要单独实测)。 +* 聊天框要真正把消息发进会话,还需要接宿主侧“发送到会话”的服务(当前只做只读镜像)。 +* 背景效果里的“半透明”依赖整窗 alpha,WebView2 子窗口在这种模式下的观感不如毛玻璃,建议优先用亚克力/云母。 +* 还没做托盘图标与全局热键。 + +## License + +MIT diff --git a/cordis.patch.yml b/cordis.patch.yml new file mode 100644 index 0000000..1e727e9 --- /dev/null +++ b/cordis.patch.yml @@ -0,0 +1,14 @@ +# ============================================================================ +# dsh-float-window — DSH bundle patch +# ---------------------------------------------------------------------------- +# 把悬浮窗插件插入 profile 的配置树。DSH 启动时按 bundle 层叠加各插件的 patch。 +# +# 安装: +# dsh plugin --profile desktop add link:<本目录绝对路径> +# 或已发布到 npm 后: +# dsh plugin --profile desktop add dsh-float-window +# ============================================================================ + +- insert: + - id: dsh-float-window + name: dsh-float-window diff --git a/lib/host.js b/lib/host.js new file mode 100644 index 0000000..ad8fd3b --- /dev/null +++ b/lib/host.js @@ -0,0 +1,268 @@ +/** + * dsh-float-window — Host 半(标准 DSH / cordis 插件) + * + * 职责: + * 1. 把原生浮窗需要的接入信息写成 `%DSH_HOME%\float-window\endpoint.json` + * (WebView2 壳读它来连这里;因为壳是独立进程,拿不到 ctx)。 + * 2. 提供 SSE 数据通道 `/dsh-float-window/events`:会话消息 + 计费指标快照。 + * 3. 提供 `/dsh-float-window/cmd`:浮窗 → 宿主的控制命令。 + * 4. 注册 `float_window` 工具,让 Agent 能自己把浮窗调出来 / 收起 / 置顶 / 全屏。 + * + * 设计原则:宿主内部 API 一律"探测式"使用(ctx.get(...) + try/catch), + * 任何一个服务缺失都只是少几个指标,绝不能让插件加载失败拖垮 DSH。 + */ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +export const name = 'dsh-float-window' +export const inject = ['webServer'] + +const HERE = path.dirname(fileURLToPath(import.meta.url)) +const REPO = path.resolve(HERE, '..') +const ROUTE = '/dsh-float-window' + +function dshHome() { + return process.env.DSH_HOME || path.join(os.homedir(), '.dsh') +} + +/** 会话事件 → 浮窗消息行的宽松映射(不同版本字段不完全一致,宁可少显示也不要报错) */ +function toRow(event) { + if (!event || typeof event !== 'object') return null + const type = String(event.type || '') + const text = (v) => (typeof v === 'string' ? v : '') + if (type === 'tool/call') { + return { role: 'tool', text: `${event.name || event.tool || 'tool'}(${short(event.args ?? event.input)})` } + } + if (type === 'tool/result') return null + const msg = event.message + const role = msg?.role || event.role || (type.includes('user') ? 'user' : type.includes('assistant') ? 'assistant' : '') + if (!role) return null + let body = '' + const content = msg?.content ?? event.content ?? event.text + if (typeof content === 'string') body = content + else if (Array.isArray(content)) body = content.map((c) => (typeof c === 'string' ? c : text(c?.text))).join('') + if (!body) return null + return { role, text: body.length > 4000 ? body.slice(0, 4000) + '…' : body, streaming: /delta|chunk|stream/.test(type) } +} + +function short(v, n = 120) { + try { + const s = typeof v === 'string' ? v : JSON.stringify(v) + return s && s.length > n ? s.slice(0, n) + '…' : s || '' + } catch { return '' } +} + +export function apply(ctx) { + const log = (...a) => { try { (ctx.logger?.info || console.log)('[float-window]', ...a) } catch { } } + + // ---------------------------------------------------------------- endpoint + const endpointFile = path.join(dshHome(), 'float-window', 'endpoint.json') + const writeEndpoint = () => { + try { + fs.mkdirSync(path.dirname(endpointFile), { recursive: true }) + const data = { + baseUrl: `http://127.0.0.1:${ctx.webServer.port}`, + host: ctx.webServer.host, + pid: process.pid, + updatedAt: new Date().toISOString(), + } + fs.writeFileSync(endpointFile, JSON.stringify(data, null, 2)) + log('endpoint written', data.baseUrl, '->', endpointFile) + } catch (e) { log('endpoint write failed:', String(e?.message || e)) } + } + writeEndpoint() + ctx.effect(() => () => { try { fs.rmSync(endpointFile, { force: true }) } catch { } }) + + // ---------------------------------------------------------------- 会话镜像缓冲 + const MAX_ROWS = 120 + const rows = new Map() // sessionId -> row[] + let lastSessionId = null + + ctx.on('session/event', (session, event) => { + try { + const id = session?.id || session?.sessionId || 'unknown' + lastSessionId = id + const row = toRow(event) + if (!row) return + const list = rows.get(id) || [] + const last = list[list.length - 1] + if (row.streaming && last && last.role === row.role && last.streaming) last.text += row.text + else list.push(row) + while (list.length > MAX_ROWS) list.shift() + rows.set(id, list) + } catch { } + }) + + // ---------------------------------------------------------------- 指标 + function metricsFor(session) { + const out = { turns: null, steps: null, tps: null, tokens: null, cacheHit: null, contextUsed: null } + try { + const proj = ctx.get('sessionProjection') + const stats = proj?.get?.(session, 'sessionStats') || proj?.peek?.(session, 'sessionStats') + if (stats) { + out.turns = stats.turns ?? null + out.steps = stats.steps ?? null + if (stats.decodeTokens && stats.decodeMs) out.tps = (stats.decodeTokens / (stats.decodeMs / 1000)) + } + } catch { } + try { + const meter = ctx.get('tokenMeter') + const m = meter?.measure?.(session) + if (m) { + out.tokens = m.totalTokens ?? m.surfaceTokens ?? null + const u = m.usage || m.providerUsage + if (u) { + const read = u.cacheRead ?? u.cache_read ?? 0 + const input = (u.input ?? 0) + read + if (input > 0) out.cacheHit = read / input + } + const capacity = ctx.get('llm')?.resolveModelInfo?.()?.context + const used = m.surfaceTokens ?? m.totalTokens + if (capacity && used) out.contextUsed = Math.min(1, used / capacity) + } + } catch { } + return out + } + + function snapshot() { + const sessions = (() => { try { return ctx.get('sessions')?.list?.() || [] } catch { return [] } })() + let session = sessions.find((s) => s?.id === lastSessionId) || sessions[sessions.length - 1] || null + const id = session?.id || lastSessionId + return { + ok: true, + at: Date.now(), + sessionId: id || null, + title: session?.meta?.title || session?.title || null, + messages: (id && rows.get(id)) || [], + metrics: session ? metricsFor(session) : { turns: null, steps: null, tps: null, tokens: null, cacheHit: null, contextUsed: null }, + sessions: sessions.slice(-10).map((s) => ({ id: s?.id, title: s?.meta?.title || s?.title || null })), + } + } + + // ---------------------------------------------------------------- 路由 + const sseClients = new Set() + const push = () => { + if (!sseClients.size) return + const payload = `data: ${JSON.stringify(snapshot())}\n\n` + for (const res of sseClients) { try { res.write(payload) } catch { } } + } + const tick = setInterval(push, 1000) + ctx.effect(() => () => clearInterval(tick)) + + const handler = async (req, res) => { + const url = new URL(req.url || '/', 'http://127.0.0.1') + const cors = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'content-type', + 'Access-Control-Allow-Methods': 'GET,POST,OPTIONS', + } + if (req.method === 'OPTIONS') { res.writeHead(204, cors); res.end(); return } + if (url.pathname === `${ROUTE}/events`) { + res.writeHead(200, { ...cors, 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-cache', Connection: 'keep-alive' }) + res.write(': connected\n\n') + res.write(`data: ${JSON.stringify(snapshot())}\n\n`) + sseClients.add(res) + req.on('close', () => sseClients.delete(res)) + return + } + if (url.pathname === `${ROUTE}/state`) { + res.writeHead(200, { ...cors, 'Content-Type': 'application/json; charset=utf-8' }) + res.end(JSON.stringify(snapshot())) + return + } + if (url.pathname === `${ROUTE}/health`) { + res.writeHead(200, { ...cors, 'Content-Type': 'application/json; charset=utf-8' }) + res.end(JSON.stringify({ ok: true, version: '0.1.0', shell: shellPath() })) + return + } + res.writeHead(404, { ...cors, 'Content-Type': 'text/plain; charset=utf-8' }) + res.end('dsh-float-window: not found') + } + ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: ROUTE, handler })) + + // ---------------------------------------------------------------- 壳进程 + const stateDir = path.join(dshHome(), 'float-window') + const cmdFile = path.join(stateDir, 'command.json') + const aliveFile = path.join(stateDir, 'alive.json') + + function shellPath() { + return path.join(REPO, 'shell', 'run-float.cmd') + } + + /** 壳每 700ms 写一次心跳,据此判断浮窗是否还活着 */ + function shellAlive() { + try { return Date.now() - fs.statSync(aliveFile).mtimeMs < 8000 } catch { return false } + } + + async function spawnShell() { + const cmd = shellPath() + if (!fs.existsSync(cmd)) return { ok: false, error: `launcher not found: ${cmd}` } + try { + const shell = ctx.get('shell') + const sp = ctx.get('sandboxPolicy') + const policy = sp?.resolve?.({ mode: 'danger-full-access' }) + if (shell?.resolve && shell?.run) { + const spec = shell.resolve({ command: `cmd /c start "" "${cmd}"`, ...(policy ? { sandboxPolicy: policy } : {}) }) + await shell.run(spec) + return { ok: true, via: 'ctx.shell' } + } + } catch (e) { log('ctx.shell launch failed, fallback to spawn:', String(e?.message || e)) } + try { + const { spawn } = await import('node:child_process') + spawn('cmd', ['/c', 'start', '', cmd], { detached: true, stdio: 'ignore', windowsHide: true }).unref() + return { ok: true, via: 'spawn' } + } catch (e) { return { ok: false, error: String(e?.message || e) } } + } + + /** 浮窗已经在跑就投递命令,否则按需拉起 */ + async function controlFloat(action) { + if (action === 'open' && !shellAlive()) return spawnShell() + if (action === 'close' && !shellAlive()) return { ok: true, via: 'already closed' } + try { + fs.mkdirSync(stateDir, { recursive: true }) + fs.writeFileSync(cmdFile, JSON.stringify({ cmd: action, at: new Date().toISOString() })) + return { ok: true, via: 'command file' } + } catch (e) { return { ok: false, error: String(e?.message || e) } } + } + + // ---------------------------------------------------------------- Agent 工具 + try { + const tools = ctx.get('tools') + if (tools?.register) { + tools.register({ + name: 'float_window', + description: '控制 DSH 悬浮窗(始终置顶的会话镜像小窗):open 打开/聚焦,close 关闭,pin 切换固定位置,fullscreen 切换全屏,hide 临时隐藏(抓屏前用)。', + parameters: { + action: { type: 'string', required: true, description: 'open | close | pin | fullscreen | hide | status' }, + }, + output: { schema: { type: 'string' }, render: (_a, v) => [{ type: 'text', text: v }] }, + async execute(args) { + const action = String(args?.action || 'open') + if (action === 'status') { + const s = snapshot() + return `float-window: alive=${shellAlive()} session=${s.sessionId || 'none'} messages=${s.messages.length} turns=${s.metrics.turns ?? '?'} steps=${s.metrics.steps ?? '?'}` + } + if (action === 'hide') { broadcast({ type: 'cmd', cmd: 'hide' }); const r = await controlFloat('hide'); return `float-window: hide (${r.via || r.error})` } + const r = await controlFloat(action) + return r.ok ? `float-window: ${action} ok (${r.via})` : `float-window: ${action} failed: ${r.error}` + }, + }) + log('tool float_window registered') + } else { + log('tools service not available; agent trigger disabled') + } + } catch (e) { log('tool registration failed:', String(e?.message || e)) } + + const broadcast = (msg) => { + const payload = `data: ${JSON.stringify(msg)}\n\n` + for (const res of sseClients) { try { res.write(payload) } catch { } } + } + const pushHidden = () => broadcast({ type: 'cmd', cmd: 'hide' }) + + // 抓屏前隐藏:给其它插件/工具用的门面(没有消费者时静默) + ctx.set?.('floatWindow', { hide: pushHidden, show: () => broadcast({ type: 'cmd', cmd: 'show' }), snapshot, endpoint: () => endpointFile }) + + log('ready; endpoints:', `${ROUTE}/events`, `${ROUTE}/state`, `${ROUTE}/health`) +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..a8aaf76 --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "name": "dsh-float-window", + "version": "0.1.0", + "description": "DSH 悬浮窗:始终置顶、可固定位置/尺寸、支持毛玻璃的会话镜像小窗;Agent 抓屏时自动隐身,因此不会干扰屏幕操控", + "keywords": [ + "dsh", + "dsh-plugin", + "deepseek-harness", + "float-window", + "always-on-top" + ], + "type": "module", + "main": "lib/host.js", + "files": [ + "lib", + "shell", + "tools", + "cordis.patch.yml", + "README.md" + ], + "dsh": { + "bundle": { + "patch": "./cordis.patch.yml" + } + }, + "license": "MIT" +} diff --git a/shell/float-window.ps1 b/shell/float-window.ps1 new file mode 100644 index 0000000..69b2f0f --- /dev/null +++ b/shell/float-window.ps1 @@ -0,0 +1,461 @@ +<# + float-window.ps1 -- DSH 悬浮窗原生壳 (Windows only) + + 一个由我们自己进程拥有的无边框顶层窗口,内嵌 WebView2 (Chromium): + * 始终置顶 / 固定位置 (pin) / 锁定尺寸 (lock) 三个可开关状态 + * 拖动移动、右下角拖动改变尺寸、自适应布局、全屏切换 + * SetWindowDisplayAffinity(WDA_EXCLUDEFROMCAPTURE) -> 从截屏里彻底消失 + * 位置/尺寸/开关状态记忆;透明度(CSS + 窗口级 alpha 双通道) + * 与页面通过 WebView2 的 postMessage 桥通信 (见 shell/ui.html) + + 用法: + powershell -File shell\float-window.ps1 + powershell -File shell\float-window.ps1 -NoExclude -Opacity 1.0 # 调试: 允许被截图 +#> +[CmdletBinding()] +param( + [string]$Lib, # WebView2 SDK 目录, 默认 \shell\lib + [string]$UiFile, # 浮窗 UI 文件, 默认 \shell\ui.html + [string]$DshHome, # DSH 主目录, 默认 $env:DSH_HOME 或 ~\.dsh + [double]$Opacity = 0.92, + [ValidateSet('opaque', 'translucent', 'acrylic', 'mica', 'blur')] + [string]$Backdrop = 'opaque', + [switch]$NoExclude, # 调试用: 关闭截图排除 + [switch]$NoTopMost, + [string]$Action # 命令行下发的动作: pin|sizeLock|topmost|fullscreen|hide|show|close +) + +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' +Add-Type -AssemblyName System.Windows.Forms, System.Drawing + +$root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path) +if (-not $Lib) { $Lib = Join-Path $root 'shell\lib' } +if (-not $UiFile) { $UiFile = Join-Path $root 'shell\ui.html' } +if (-not $DshHome) { $DshHome = if ($env:DSH_HOME) { $env:DSH_HOME } else { Join-Path $env:USERPROFILE '.dsh' } } + +# 插件写出的 endpoint.json 永远在 DSH 主目录下(只读) +$endpointFile = Join-Path (Join-Path $DshHome 'float-window') 'endpoint.json' + +# 自己的状态和 WebView2 用户数据目录需要可写;被沙箱限制时自动降级 +function Test-Writable([string]$Dir) { + try { + New-Item -ItemType Directory -Force -Path $Dir | Out-Null + $probe = Join-Path $Dir '.write-probe' + Set-Content -Path $probe -Value 'x' -ErrorAction Stop + Remove-Item $probe -Force + return $true + } catch { return $false } +} +$stateDir = $null +foreach ($cand in @((Join-Path $DshHome 'float-window'), (Join-Path $env:LOCALAPPDATA 'dsh-float-window'), (Join-Path $root '.runtime'))) { + if (Test-Writable $cand) { $stateDir = $cand; break } +} +if (-not $stateDir) { throw 'no writable state directory' } +$stateFile = Join-Path $stateDir 'window.json' +$logFile = Join-Path $stateDir 'float-window.log' +function Log([string]$Msg) { + try { Add-Content -Path $logFile -Value ("{0} {1}" -f (Get-Date -Format 'HH:mm:ss.fff'), $Msg) -Encoding UTF8 } catch {} + Write-Host "[float] $Msg" +} +Log "start lib=$Lib ui=$UiFile stateDir=$stateDir endpoint=$endpointFile exclude=$(-not $NoExclude) topmost=$(-not $NoTopMost)" + +# 单实例:必须在建窗口之前判定 +# 单实例:已在运行时,把动作写进 command.json 交给它执行,然后自己退出 +$mutexName = 'DSHFloatWindow' +$alreadyRunning = $false +try { $null = [System.Threading.Mutex]::OpenExisting($mutexName); $alreadyRunning = $true } catch { $alreadyRunning = $false } +$commandFile = Join-Path $stateDir 'command.json' +$aliveFile = Join-Path $stateDir 'alive.json' +if ($alreadyRunning) { + if ($Action) { + try { + @{ cmd = $Action; at = (Get-Date).ToString('o') } | ConvertTo-Json | Set-Content -Path $commandFile -Encoding UTF8 + Log "handed action '$Action' to the running instance" + } catch { Log "command write failed: $($_.Exception.Message)" } + } else { Log 'another instance is running; exiting' } + exit 0 +} + +$createdNew = $false +$mutex = New-Object System.Threading.Mutex($true, $mutexName, [ref]$createdNew) +if (-not $createdNew) { Log 'another instance is running; exiting'; exit 0 } +Log 'mutex ok' + +# ---------------------------------------------------------------- native interop +Add-Type -Namespace DshFloat -Name Native -MemberDefinition @' +[DllImport("user32.dll")] public static extern bool ReleaseCapture(); +[DllImport("user32.dll")] public static extern IntPtr SendMessage(IntPtr hWnd, int Msg, IntPtr wParam, IntPtr lParam); +[DllImport("user32.dll")] public static extern bool SetWindowDisplayAffinity(IntPtr hWnd, uint dwAffinity); +[DllImport("user32.dll")] public static extern int SetWindowLong(IntPtr hWnd, int nIndex, int dwNewLong); +[DllImport("user32.dll")] public static extern int GetWindowLong(IntPtr hWnd, int nIndex); +[DllImport("user32.dll")] public static extern bool SetLayeredWindowAttributes(IntPtr hWnd, uint crKey, byte bAlpha, uint dwFlags); +'@ + +# ---------------------------------------------------------------- WebView2 加载 +# 本机 PowerShell 用 LoadFrom 加载 WebView2 SDK 会报 0x80131515, 改从字节加载 +function Import-AssemblyFromBytes([string]$Path) { + if (-not (Test-Path $Path)) { throw "missing assembly: $Path (先跑 tools\setup-webview2.ps1)" } + [void][System.Reflection.Assembly]::Load([System.IO.File]::ReadAllBytes($Path)) +} +Import-AssemblyFromBytes (Join-Path $Lib 'Microsoft.Web.WebView2.Core.dll') +Import-AssemblyFromBytes (Join-Path $Lib 'Microsoft.Web.WebView2.WinForms.dll') +# 原生 loader 走 P/Invoke, 靠 PATH 找到 +$env:PATH = "$Lib;$env:PATH" +Log 'webview2 assemblies loaded' + +# ---------------------------------------------------------------- 窗体类(边缘缩放 + 云母/亚克力背景) +# 无边框窗口默认没有可拖拽的边框:用 WM_NCHITTEST 在四周 8px 边环上返回 HT* 代码, +# 让系统自己完成缩放(比从页面 postMessage 触发可靠得多)。玻璃模式下抑制 WinForms +# 擦背景,把客户区让给 DWM 的系统背景(Mica / Acrylic)绘制。 +Add-Type -ReferencedAssemblies System.Windows.Forms, System.Drawing, System.Runtime.InteropServices -TypeDefinition @' +using System; +using System.Drawing; +using System.Runtime.InteropServices; +using System.Windows.Forms; + +namespace DshFloat +{ + public class FloatForm : Form + { + public int BorderSize = 8; + public bool AllowResize = true; + public int BackdropMode = 0; // 0=opaque 1=mica 2=acrylic 3=blur + + [StructLayout(LayoutKind.Sequential)] struct MARGINS { public int l, r, t, b; } + [StructLayout(LayoutKind.Sequential)] struct ACCENTPOLICY { public int State, Flags, GradientColor, AnimationId; } + [StructLayout(LayoutKind.Sequential)] struct WINCOMPDATA { public int Attrib; public IntPtr Data; public int Size; } + + [DllImport("dwmapi.dll")] static extern int DwmSetWindowAttribute(IntPtr h, int attr, ref int val, int size); + [DllImport("dwmapi.dll")] static extern int DwmExtendFrameIntoClientArea(IntPtr h, ref MARGINS m); + [DllImport("user32.dll")] static extern int SetWindowCompositionAttribute(IntPtr h, ref WINCOMPDATA d); + + public string ApplyBackdrop(int mode) + { + BackdropMode = mode; + int type = mode == 1 ? 2 : (mode == 2 ? 3 : 0); // DWMSBT_MAINWINDOW / TRANSIENTWINDOW / AUTO + int hr = DwmSetWindowAttribute(Handle, 38, ref type, 4); // DWMWA_SYSTEMBACKDROP_TYPE + MARGINS m = new MARGINS(); + if (mode != 0) { m.l = -1; m.r = -1; m.t = -1; m.b = -1; } + DwmExtendFrameIntoClientArea(Handle, ref m); + SetAccent(mode == 3 ? 4 : 0, mode == 0 ? 0 : unchecked((int)0x99202028)); + Invalidate(true); + return "mode=" + mode + " hr=" + hr; + } + + void SetAccent(int state, int color) + { + ACCENTPOLICY acc = new ACCENTPOLICY(); + acc.State = state; acc.Flags = 2; acc.GradientColor = color; acc.AnimationId = 0; + int size = Marshal.SizeOf(acc); + IntPtr p = Marshal.AllocHGlobal(size); + Marshal.StructureToPtr(acc, p, false); + WINCOMPDATA d = new WINCOMPDATA(); + d.Attrib = 19; d.Data = p; d.Size = size; // WCA_ACCENT_POLICY + SetWindowCompositionAttribute(Handle, ref d); + Marshal.FreeHGlobal(p); + } + + protected override void OnPaintBackground(PaintEventArgs e) + { + if (BackdropMode == 0) base.OnPaintBackground(e); + } + + protected override void WndProc(ref Message m) + { + const int WM_NCHITTEST = 0x84; + const int WM_ERASEBKGND = 0x14; + if (m.Msg == WM_ERASEBKGND && BackdropMode != 0) { m.Result = (IntPtr)1; return; } + if (m.Msg == WM_NCHITTEST && AllowResize) + { + long lp = m.LParam.ToInt64(); + Point p = PointToClient(new Point((short)(lp & 0xFFFF), (short)((lp >> 16) & 0xFFFF))); + int b = BorderSize; + bool l = p.X <= b, r = p.X >= ClientSize.Width - b; + bool t = p.Y <= b, bo = p.Y >= ClientSize.Height - b; + int hit = 0; + if (t && l) hit = 13; else if (t && r) hit = 14; + else if (bo && l) hit = 16; else if (bo && r) hit = 17; + else if (l) hit = 10; else if (r) hit = 11; + else if (t) hit = 12; else if (bo) hit = 15; + if (hit != 0) { m.Result = (IntPtr)hit; return; } + } + base.WndProc(ref m); + } + } +} +'@ +Log 'floatform type ok' + +# ---------------------------------------------------------------- 状态与默认值 +$WS_EX_LAYERED = 0x80000 +$WDA_EXCLUDEFROMCAPTURE = 0x11 +$SWP_NOMOVE = 0x2; $SWP_NOSIZE = 0x1 + +$script:state = [ordered]@{ + x = -1; y = -1; w = 420; h = 720 + topmost = (-not $NoTopMost) + posLock = $false + sizeLock = $false + opacity = $Opacity + exclude = (-not $NoExclude) + scale = 1.0 + fullscreen = $false + backdrop = if ($Backdrop) { $Backdrop } else { 'opaque' } +} +if (Test-Path $stateFile) { + try { + $saved = Get-Content $stateFile -Raw | ConvertFrom-Json + foreach ($k in @($saved.PSObject.Properties.Name)) { if ($script:state.Contains($k)) { $script:state[$k] = $saved.$k } } + if ($NoTopMost) { $script:state.topmost = $false } + if ($NoExclude) { $script:state.exclude = $false } + $script:state.opacity = $Opacity + $script:state.fullscreen = $false + } catch { Write-Warning "state file ignored: $($_.Exception.Message)" } +} + +function Save-State { + try { ($script:state | ConvertTo-Json -Depth 3) | Set-Content -Path $stateFile -Encoding UTF8 } catch {} +} + +# ---------------------------------------------------------------- 窗口 +$form = New-Object DshFloat.FloatForm +$form.Text = 'DSH 悬浮窗' +$form.FormBorderStyle = 'None' +$form.StartPosition = 'Manual' +$form.ShowInTaskbar = $true +$form.MinimumSize = New-Object System.Drawing.Size(260, 240) +$form.BackColor = [System.Drawing.Color]::FromArgb(15, 17, 21) +$form.TopMost = [bool]$script:state.topmost +$form.KeyPreview = $true +# 8px 边环留给系统做缩放命中测试,WebView2 只铺在里面 +$form.Padding = New-Object System.Windows.Forms.Padding(8, 8, 8, 8) + +$scr = [System.Windows.Forms.Screen]::PrimaryScreen.WorkingArea +if ($script:state.x -lt 0 -or $script:state.y -lt 0) { + $script:state.x = $scr.Right - [int]$script:state.w - 24 + $script:state.y = $scr.Top + 24 +} +$form.Bounds = New-Object System.Drawing.Rectangle( + [int]$script:state.x, [int]$script:state.y, [int]$script:state.w, [int]$script:state.h) +# 高 DPI 下 WinForms 会把逻辑尺寸放大,这里按真实尺寸把窗口夹回屏幕内 +if ($form.Right -gt $scr.Right) { $form.Left = [Math]::Max($scr.Left, $scr.Right - $form.Width - 12) } +if ($form.Bottom -gt $scr.Bottom) { $form.Top = [Math]::Max($scr.Top, $scr.Bottom - $form.Height - 12) } +if ($form.Left -lt $scr.Left) { $form.Left = $scr.Left + 12 } +if ($form.Top -lt $scr.Top) { $form.Top = $scr.Top + 12 } + +$wv = New-Object Microsoft.Web.WebView2.WinForms.WebView2 +$wv.Dock = 'Fill' +$form.Controls.Add($wv) + +function Send-ToPage([hashtable]$Msg) { + if ($wv.CoreWebView2) { + try { $wv.CoreWebView2.PostWebMessageAsJson(($Msg | ConvertTo-Json -Depth 6 -Compress)) } catch {} + } +} + +function Apply-Opacity([double]$value) { + $script:state.opacity = [Math]::Round([Math]::Max(0.25, [Math]::Min(1.0, $value)), 2) + # 观感透明度由页面 CSS 的 --alpha 决定;只有「半透明」模式才额外给整窗 alpha + if ($script:state.backdrop -eq 'translucent') { Set-WindowAlpha $script:state.opacity } else { Set-WindowAlpha 1.0 } +} + +function Apply-Exclude { + # 0x11 = WDA_EXCLUDEFROMCAPTURE: 窗口从所有抓屏 API 里彻底消失(实测有效) + $aff = if ($script:state.exclude) { $WDA_EXCLUDEFROMCAPTURE } else { 0 } + [void][DshFloat.Native]::SetWindowDisplayAffinity($form.Handle, $aff) +} + +function Apply-Backdrop([string]$Mode) { + if (-not $Mode) { $Mode = 'opaque' } + $script:state.backdrop = $Mode + $code = switch ($Mode) { 'mica' { 1 } 'acrylic' { 2 } 'blur' { 3 } 'translucent' { 0 } default { 0 } } + try { + $info = $form.ApplyBackdrop($code) + Log "backdrop $Mode -> $info" + } catch { Log "backdrop ERROR: $($_.Exception.Message)" } + # 玻璃模式必须让 WebView2 背景透明,否则页面自己把桌面糊住了 + if ($wv.CoreWebView2) { + try { + $wv.DefaultBackgroundColor = if ($code -eq 0) { [System.Drawing.Color]::FromArgb(255, 15, 17, 21) } else { [System.Drawing.Color]::Transparent } + } catch { Log "bg color ERROR: $($_.Exception.Message)" } + } + # 半透明:整窗 alpha(WebView2 是子窗口,这条路径观感一般,作为兜底) + if ($Mode -eq 'translucent') { Apply-Opacity $script:state.opacity } else { Set-WindowAlpha 1.0 } +} + +function Set-WindowAlpha([double]$value) { + $alpha = [byte][Math]::Round([Math]::Max(0.05, [Math]::Min(1.0, $value)) * 255) + $style = [DshFloat.Native]::GetWindowLong($form.Handle, -20) + if ($value -lt 1.0) { + [void][DshFloat.Native]::SetWindowLong($form.Handle, -20, $style -bor $WS_EX_LAYERED) + [void][DshFloat.Native]::SetLayeredWindowAttributes($form.Handle, 0, $alpha, 0x2) + } else { + [void][DshFloat.Native]::SetWindowLong($form.Handle, -20, $style -band (-bnot $WS_EX_LAYERED)) + } +} + +function Push-State { + Send-ToPage @{ + type = 'state' + state = @{ + topmost = [bool]$script:state.topmost + posLock = [bool]$script:state.posLock + sizeLock = [bool]$script:state.sizeLock + opacity = [double]$script:state.opacity + exclude = [bool]$script:state.exclude + scale = [double]$script:state.scale + backdrop = [string]$script:state.backdrop + fullscreen = [bool]$script:state.fullscreen + w = $form.Width; h = $form.Height + } + } +} + +function Toggle-Fullscreen { + if ($script:state.fullscreen) { + $b = $script:savedBounds + if ($b) { $form.Bounds = $b } + $script:state.fullscreen = $false + } else { + $script:savedBounds = $form.Bounds + $form.WindowState = 'Normal' + $form.Bounds = [System.Windows.Forms.Screen]::FromHandle($form.Handle).Bounds + $script:state.fullscreen = $true + } + Push-State +} + +function Send-Endpoint { + $payload = @{ type = 'endpoint'; baseUrl = $null; token = $null } + if (Test-Path $endpointFile) { + try { + $ep = Get-Content $endpointFile -Raw | ConvertFrom-Json + $payload.baseUrl = $ep.baseUrl; $payload.token = $ep.token + } catch {} + } + Send-ToPage $payload +} + +# ---------------------------------------------------------------- 页面消息处理 +$onWebMessage = { + param($sender, $args) + try { + $msg = $args.TryGetWebMessageAsString() + if (-not $msg) { $msg = $args.WebMessageAsJson } + $m = $msg | ConvertFrom-Json + } catch { return } + switch ($m.cmd) { + 'ready' { Push-State; Send-Endpoint } + 'drag' { if (-not $script:state.posLock) { [void][DshFloat.Native]::ReleaseCapture(); [void][DshFloat.Native]::SendMessage($form.Handle, 0xA1, [IntPtr]2, [IntPtr]::Zero) } } + 'resize' { if (-not $script:state.sizeLock) { [void][DshFloat.Native]::ReleaseCapture(); [void][DshFloat.Native]::SendMessage($form.Handle, 0xA1, [IntPtr]17, [IntPtr]::Zero) } } + 'close' { $form.Close() } + 'minimize' { $form.WindowState = 'Minimized' } + 'fullscreen' { Toggle-Fullscreen } + 'topmost' { $script:state.topmost = -not $script:state.topmost; $form.TopMost = [bool]$script:state.topmost; Save-State; Push-State } + 'posLock' { $script:state.posLock = -not $script:state.posLock; Save-State; Push-State } + 'sizeLock' { $script:state.sizeLock = -not $script:state.sizeLock; $form.AllowResize = -not $script:state.sizeLock; Save-State; Push-State } + 'exclude' { $script:state.exclude = -not $script:state.exclude; Apply-Exclude; Save-State; Push-State } + 'opacity' { Apply-Opacity ([double]$m.value); Save-State; Push-State } + 'backdrop' { Apply-Backdrop ([string]$m.value); Save-State; Push-State } + 'scale' { $script:state.scale = [double]$m.value; $wv.ZoomFactor = [double]$m.value; Save-State; Push-State } + 'reload' { $wv.Reload() } + 'openExternal' { if ($m.url -match '^https?://') { Start-Process $m.url } } + } +} +# ---------------------------------------------------------------- 外部命令 / 心跳 +function Invoke-Cmd([string]$c) { + switch ($c) { + 'close' { $form.Close() } + 'pin' { $script:state.posLock = -not $script:state.posLock; Save-State; Push-State } + 'sizeLock' { $script:state.sizeLock = -not $script:state.sizeLock; $form.AllowResize = -not $script:state.sizeLock; Save-State; Push-State } + 'topmost' { $script:state.topmost = -not $script:state.topmost; $form.TopMost = [bool]$script:state.topmost; Save-State; Push-State } + 'fullscreen' { Toggle-Fullscreen } + 'hide' { Send-ToPage @{ type = 'cmd'; cmd = 'hide' } } + 'show' { $form.Show(); $form.Activate(); Send-ToPage @{ type = 'cmd'; cmd = 'show' } } + default { Send-ToPage @{ type = 'cmd'; cmd = $c } } + } +} + +$script:lastEndpointText = '' +$script:lastCommandText = '' +$timer = New-Object System.Windows.Forms.Timer +$timer.Interval = 700 +$timer.Add_Tick({ + # endpoint.json 变了(DSH 重启 / 换端口)就重新告诉页面 + try { + if (Test-Path $endpointFile) { + $t = Get-Content $endpointFile -Raw + if ($t -ne $script:lastEndpointText) { $script:lastEndpointText = $t; Send-Endpoint } + } + } catch {} + # 插件/命令行下发的动作 + try { + if (Test-Path $commandFile) { + $c = Get-Content $commandFile -Raw -ErrorAction SilentlyContinue + if ($c -and $c -ne $script:lastCommandText) { + $script:lastCommandText = $c + Remove-Item $commandFile -Force -ErrorAction SilentlyContinue + $cmd = ($c | ConvertFrom-Json).cmd + Log "external cmd: $cmd" + Invoke-Cmd $cmd + } + } + } catch { Log "cmd error: $($_.Exception.Message)" } + # 心跳,宿主据此判断浮窗是否还活着 + try { @{ pid = $PID; at = (Get-Date).ToString('o') } | ConvertTo-Json | Set-Content -Path $aliveFile -Encoding UTF8 } catch {} + }) +$timer.Start() + +# WebView2 初始化是异步的:绝不能在 UI 线程上阻塞等待(会死锁),改为事件驱动 +$onWvReady = { + param($sender, $e) + if (-not $e.IsSuccess) { Log "WV2 init failed: $($e.InitializationException)"; return } + Log 'corewebview2 ready' + $s = $wv.CoreWebView2.Settings + $s.AreDefaultContextMenusEnabled = $false + $s.IsStatusBarEnabled = $false + $s.AreBrowserAcceleratorKeysEnabled = $false + $s.IsZoomControlEnabled = $true + $s.AreDevToolsEnabled = $false + $wv.CoreWebView2.add_WebMessageReceived($onWebMessage) + $wv.CoreWebView2.SetVirtualHostNameToFolderMapping('dshfloat.local', $root, 1) + Apply-Backdrop $script:state.backdrop + Apply-Opacity $script:state.opacity + $wv.CoreWebView2.Navigate("file:///$(($UiFile -replace '\\','/'))") + Log "navigated to $UiFile" + Push-State +} +$wv.add_CoreWebView2InitializationCompleted($onWvReady) +$form.Add_Shown({ + Log 'form shown' + $form.AllowResize = -not $script:state.sizeLock + try { + $udf = Join-Path $stateDir 'webview2' + New-Item -ItemType Directory -Force -Path $udf | Out-Null + Log "creating WebView2 env at $udf" + # 参数顺序是 (browserExecutableFolder, userDataFolder, options) + $wvEnv = [Microsoft.Web.WebView2.Core.CoreWebView2Environment]::CreateAsync($null, $udf, $null).GetAwaiter().GetResult() + Log "env ok browser=$($wvEnv.BrowserVersionString)" + [void]$wv.EnsureCoreWebView2Async($wvEnv) + Log 'EnsureCoreWebView2Async started' + } catch { + Log "WV2 ERROR: $($_.Exception.Message)" + } + try { Apply-Exclude; $wv.ZoomFactor = [double]$script:state.scale } catch { Log "apply ERROR: $($_.Exception.Message)" } + }) + +$form.Add_ResizeEnd({ if (-not $script:state.fullscreen) { $script:state.w = $form.Width; $script:state.h = $form.Height; Save-State; Push-State } }) +$form.Add_Move({ if (-not $script:state.fullscreen -and $form.WindowState -eq 'Normal') { $script:state.x = $form.Left; $script:state.y = $form.Top } }) +$form.Add_FormClosing({ Save-State }) +$form.Add_KeyDown({ + param($s, $e) + if ($e.KeyCode -eq [System.Windows.Forms.Keys]::F11) { Toggle-Fullscreen } + if ($e.KeyCode -eq [System.Windows.Forms.Keys]::Escape -and $script:state.fullscreen) { Toggle-Fullscreen } + }) + +# 单实例已在上方处理 + +[void]$form.Show() +[System.Windows.Forms.Application]::Run($form) +$mutex.ReleaseMutex() diff --git a/shell/run-float.cmd b/shell/run-float.cmd new file mode 100644 index 0000000..7f628f8 --- /dev/null +++ b/shell/run-float.cmd @@ -0,0 +1,6 @@ +@echo off +rem DSH 悬浮窗启动器(Windows):无控制台窗口拉起 WinForms + WebView2 原生壳 +setlocal +set "PS=%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe" +start "" "%PS%" -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File "%~dp0float-window.ps1" %* +endlocal diff --git a/shell/ui.html b/shell/ui.html new file mode 100644 index 0000000..de5a3c4 --- /dev/null +++ b/shell/ui.html @@ -0,0 +1,731 @@ + + + + + + + DSH 悬浮窗 + + + + +
+
+
+
DSH 悬浮窗未连接
+ + + + + + +
+ +
+
+ 等待 DSH 插件连接…
+ 浮窗已经就绪:拖动标题栏移动,拖动四周边缘或右下角改变尺寸。
+ 安装插件后这里会实时镜像会话: + dsh plugin --profile desktop add link:<repo> 然后重启 DSH。
+ 壳进程已启用 截图排除:Agent 抓屏时看不到这个浮窗。 +
+
+ +
+ + +
+ +
+
轮 —
+
·
+
步 —
+
·
+
— tok/s
+
·
+
— M tok
+
·
+
缓存 —
+
+ 上下文 +
+ — +
+
+ +
+

悬浮窗设置

+
背景效果 + +
+
不透明度 + +
+
界面缩放 + +
+
始终置顶 +
+
+
固定位置 +
+
+
锁定尺寸 +
+
+
截图时隐藏本窗 +
+
+
+ 更多设置会同步注册到 DeepSeek 设置 → 悬浮窗(由 DSH 插件提供入口)。 +
+
+ +
+
+ + + + + diff --git a/tools/fix-bom.ps1 b/tools/fix-bom.ps1 new file mode 100644 index 0000000..e63330c --- /dev/null +++ b/tools/fix-bom.ps1 @@ -0,0 +1,20 @@ +# fix-bom.ps1 - ensure every .ps1 in this repo is UTF-8 WITH BOM. +# Windows PowerShell 5.1 reads BOM-less .ps1 as ANSI/GBK, which corrupts non-ASCII +# comments and can silently break parsing (observed: variables becoming empty). +# ASCII-only on purpose: this file must survive being saved without a BOM. +param([string]$Root) +$ErrorActionPreference = 'Stop' +if (-not $Root) { $Root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path) } +$utf8Bom = New-Object System.Text.UTF8Encoding($true) +$fixed = 0 +Get-ChildItem $Root -Recurse -File -Filter *.ps1 | Where-Object { $_.FullName -notmatch '\\\.cache\\' } | ForEach-Object { + $bytes = [System.IO.File]::ReadAllBytes($_.FullName) + $hasBom = $bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF + if (-not $hasBom) { + $text = [System.IO.File]::ReadAllText($_.FullName, [System.Text.Encoding]::UTF8) + [System.IO.File]::WriteAllText($_.FullName, $text, $utf8Bom) + Write-Host "[bom] + $($_.Name)" + $script:fixed++ + } +} +Write-Host "[bom] done, fixed=$fixed" diff --git a/tools/setup-webview2.ps1 b/tools/setup-webview2.ps1 new file mode 100644 index 0000000..a91a70c --- /dev/null +++ b/tools/setup-webview2.ps1 @@ -0,0 +1,87 @@ +<# + setup-webview2.ps1 — 拉取 WebView2 SDK(托管 DLL + Loader),供 shell/float-window.ps1 内嵌 Chromium 使用。 + + 为什么要这么绕:本机 PowerShell 的 schannel 凭据在当前沙箱里不可用 + (SEC_E_NO_CREDENTIALS),Invoke-WebRequest/curl 走不通;Node 自带 OpenSSL,网络正常。 + 所以优先用 node 下载,并保留 Invoke-WebRequest 作为通用回退。 +#> +[CmdletBinding()] +param( + [string]$Dest, + [string]$Cache +) + +$ErrorActionPreference = 'Stop' +# 在无控制台的宿主(被 harness / 计划任务拉起)里 Write-Progress 会抛 Win32 "Access is denied" +$ProgressPreference = 'SilentlyContinue' +$root = Split-Path -Parent $MyInvocation.MyCommand.Path | Split-Path -Parent +if (-not $Dest) { $Dest = Join-Path $root 'shell\lib' } +if (-not $Cache) { $Cache = Join-Path $root '.cache' } +New-Item -ItemType Directory -Force -Path $Dest, $Cache | Out-Null + +if (Test-Path (Join-Path $Dest 'Microsoft.Web.WebView2.WinForms.dll')) { + Write-Host "[setup] WebView2 SDK 已存在:$Dest" + exit 0 +} + +$nupkg = Join-Path $Cache 'webview2.nupkg' +$needDownload = $true +if (Test-Path $nupkg) { $needDownload = $false } + +if ($needDownload) { + $nodeScript = @' +const fs = require('node:fs') +const idx = 'https://api.nuget.org/v3-flatcontainer/microsoft.web.webview2/index.json' +async function main() { + const j = await (await fetch(idx, { signal: AbortSignal.timeout(30000) })).json() + const stable = j.versions.filter((v) => !v.includes('-')) + const v = stable[stable.length - 1] + const url = `https://api.nuget.org/v3-flatcontainer/microsoft.web.webview2/${v}/microsoft.web.webview2.${v}.nupkg` + const buf = Buffer.from(await (await fetch(url, { signal: AbortSignal.timeout(120000) })).arrayBuffer()) + fs.writeFileSync(process.argv[2], buf) + console.log(v) +} +main().catch((e) => { console.error('download failed: ' + e.message); process.exit(1) }) +'@ + $nodeFile = Join-Path $Cache 'download-webview2.cjs' + Set-Content -Path $nodeFile -Value $nodeScript -Encoding UTF8 + + $version = $null + if (Get-Command node -ErrorAction SilentlyContinue) { + $version = (& node $nodeFile $nupkg | Select-Object -Last 1) + } + if (-not (Test-Path $nupkg)) { + # 回退:本机 PowerShell 的 TLS 可用时走这里 + $v = (Invoke-RestMethod 'https://api.nuget.org/v3-flatcontainer/microsoft.web.webview2/index.json').versions | + Where-Object { $_ -notmatch '-' } | Select-Object -Last 1 + Invoke-WebRequest "https://api.nuget.org/v3-flatcontainer/microsoft.web.webview2/$v/microsoft.web.webview2.$v.nupkg" -OutFile $nupkg + $version = $v + } + Write-Host "[setup] 下载 WebView2 SDK $version" +} + +$extract = Join-Path $Cache 'extract' +Remove-Item -Recurse -Force $extract -ErrorAction SilentlyContinue +# Expand-Archive (PS 5.1) 只认 .zip 扩展名,nupkg 本质就是 zip,复制一份再解 +$zip = Join-Path $Cache 'webview2.zip' +Copy-Item $nupkg $zip -Force +Expand-Archive -Path $zip -DestinationPath $extract -Force + +# 包内布局随版本变(1.0.4191.47 是 lib\net462\),按文件名找 + 目录偏好挑选 +$wanted = @( + @{ Leaf = 'Microsoft.Web.WebView2.Core.dll'; Prefer = '\\lib\\net4' }, + @{ Leaf = 'Microsoft.Web.WebView2.WinForms.dll'; Prefer = '\\lib\\net4' }, + @{ Leaf = 'WebView2Loader.dll'; Prefer = 'runtimes\\win-x64\\native' } +) +foreach ($w in $wanted) { + $hits = @(Get-ChildItem $extract -Recurse -File -Filter $w.Leaf -ErrorAction SilentlyContinue) + if (-not $hits) { throw "包内找不到 $($w.Leaf)" } + $pick = @($hits | Where-Object { $_.FullName -match $w.Prefer } | Select-Object -First 1) + if (-not $pick) { $pick = @($hits | Select-Object -First 1) } + Copy-Item $pick[0].FullName (Join-Path $Dest $w.Leaf) -Force + Write-Host "[setup] + $($w.Leaf) <- $($pick[0].FullName.Replace($extract, ''))" +} + +$missing = @($wanted | Where-Object { -not (Test-Path (Join-Path $Dest $_.Leaf)) }) +if ($missing) { throw "缺少文件:$(($missing | ForEach-Object { $_.Leaf }) -join ', ')" } +Write-Host "[setup] 完成:$Dest" diff --git a/tools/smoke-host.mjs b/tools/smoke-host.mjs new file mode 100644 index 0000000..5c70a7a --- /dev/null +++ b/tools/smoke-host.mjs @@ -0,0 +1,51 @@ +/** + * 冒烟测试:用假的 ctx 调 apply(),确认插件不会抛异常,路由能应答。 + * 用法:node tools/smoke-host.mjs + */ +import { apply, name, inject } from '../lib/host.js' + +const routes = [] +const events = [] +const effects = [] + +const ctx = { + logger: { info: (...a) => console.log('[mock]', ...a) }, + webServer: { + port: 3099, + host: '127.0.0.1', + register: (r) => { routes.push(r); return () => { } }, + }, + get: () => undefined, + set: () => { }, + on: (ev, fn) => { events.push([ev, fn]); return () => { } }, + effect: (fn) => { const d = fn(); effects.push(d); return () => { } }, +} + +console.log('name =', name, '| inject =', JSON.stringify(inject)) +apply(ctx) +console.log('routes =', routes.map((r) => `${r.kind}:${r.path}`).join(', ')) +console.log('events =', events.map((e) => e[0]).join(', ')) +console.log('effects =', effects.length) + +function fakeRes() { + return { + status: 0, headers: null, body: '', + writeHead(s, h) { this.status = s; this.headers = h }, + write(chunk) { this.body += chunk; return true }, + end(chunk) { if (chunk) this.body += chunk }, + } +} + +const route = routes[0] +for (const p of ['/dsh-float-window/health', '/dsh-float-window/state', '/dsh-float-window/nope']) { + const res = fakeRes() + await route.handler({ method: 'GET', url: p, on: () => { } }, res) + console.log(p, '->', res.status, String(res.body).slice(0, 120)) +} + +// SSE 分支:只验证不抛异常且写出了 data: +const res = fakeRes() +await route.handler({ method: 'GET', url: '/dsh-float-window/events', on: () => { } }, res) +console.log('events ->', res.status, res.headers['Content-Type'], JSON.stringify(res.body.slice(0, 80))) + +console.log('SMOKE OK')