feat: DSH 悬浮窗 v0.1.0 — 原生置顶小窗 + 会话镜像 + 抓屏隐身

- 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 加载)
This commit is contained in:
2026-09-27 14:02:58 +08:00
commit 8e4a648465
12 changed files with 1850 additions and 0 deletions
+18
View File
@@ -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
+21
View File
@@ -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.
+146
View File
@@ -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 <repo> 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:<port>`,镜像区开始滚动会话内容。
## 命令行
```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
+14
View File
@@ -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
+268
View File
@@ -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`)
}
+27
View File
@@ -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"
}
+461
View File
@@ -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 目录, 默认 <repo>\shell\lib
[string]$UiFile, # 浮窗 UI 文件, 默认 <repo>\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()
+6
View File
@@ -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
+731
View File
@@ -0,0 +1,731 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>DSH 悬浮窗</title>
<style>
:root {
--bg: #0f1115;
--bg-soft: #161a21;
--bg-chip: #1d222b;
--line: #262c37;
--fg: #e6eaf2;
--fg-dim: #8b93a5;
--accent: #4d7cfe;
--accent-soft: #2a3a63;
--ok: #3ecf8e;
--warn: #f0b429;
--alpha: 0.92;
--radius: 12px;
--pad: 10px;
--font: 13px;
--title-font: 12.5px;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
height: 100%;
overflow: hidden;
}
body {
background: rgba(15, 17, 21, var(--alpha));
color: var(--fg);
font: var(--font)/1.45 "Segoe UI", "Microsoft YaHei UI", system-ui, sans-serif;
user-select: none;
}
#app {
display: flex;
flex-direction: column;
height: 100%;
border: 1px solid var(--line);
border-radius: var(--radius);
overflow: hidden;
}
/* ---------------- titlebar ---------------- */
#bar {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
background: var(--bg-soft);
border-bottom: 1px solid var(--line);
cursor: default;
flex: 0 0 auto;
}
#bar .dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--fg-dim);
flex: 0 0 auto;
}
#bar .dot.live {
background: var(--ok);
box-shadow: 0 0 6px var(--ok);
}
#bar .dot.wait {
background: var(--warn);
}
#title {
flex: 1 1 auto;
font-size: var(--title-font);
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
letter-spacing: .2px;
}
#title small {
color: var(--fg-dim);
font-weight: 400;
margin-left: 6px;
}
.tbtn {
flex: 0 0 auto;
width: 24px;
height: 24px;
display: grid;
place-items: center;
border-radius: 7px;
border: 1px solid transparent;
background: transparent;
color: var(--fg-dim);
cursor: pointer;
padding: 0;
}
.tbtn:hover {
background: var(--bg-chip);
color: var(--fg);
}
.tbtn.on {
background: var(--accent-soft);
border-color: var(--accent);
color: #cfe0ff;
}
.tbtn svg {
width: 15px;
height: 15px;
display: block;
}
/* ---------------- mirror ---------------- */
#mirror {
flex: 1 1 auto;
overflow-y: auto;
overflow-x: hidden;
padding: var(--pad);
scrollbar-width: thin;
scrollbar-color: #333a47 transparent;
}
#mirror::-webkit-scrollbar {
width: 8px;
}
#mirror::-webkit-scrollbar-thumb {
background: #333a47;
border-radius: 4px;
}
.hint {
color: var(--fg-dim);
font-size: 12px;
line-height: 1.7;
}
.hint b {
color: #b9c4d8;
font-weight: 600;
}
.hint code {
background: var(--bg-chip);
padding: 1px 5px;
border-radius: 5px;
font-family: Consolas, monospace;
font-size: 11.5px;
}
.row {
display: flex;
gap: 8px;
padding: 5px 0;
border-bottom: 1px dashed var(--line);
}
.row:last-child {
border-bottom: 0;
}
.row .who {
flex: 0 0 auto;
font-size: 11px;
color: var(--fg-dim);
min-width: 34px;
padding-top: 1px;
}
.row.user .who {
color: var(--accent);
}
.row .what {
flex: 1 1 auto;
white-space: pre-wrap;
word-break: break-word;
}
.tool {
font-family: Consolas, monospace;
font-size: 11.5px;
color: #9fd0ff;
background: #10151d;
border: 1px solid var(--line);
border-radius: 6px;
padding: 2px 6px;
display: inline-block;
margin: 2px 0;
}
.blink::after {
content: '▍';
animation: b 1s steps(2) infinite;
}
@keyframes b {
50% {
opacity: 0;
}
}
/* ---------------- composer ---------------- */
#composer {
flex: 0 0 auto;
display: flex;
align-items: flex-end;
gap: 6px;
padding: 8px;
border-top: 1px solid var(--line);
background: var(--bg-soft);
}
#input {
flex: 1 1 auto;
resize: none;
min-height: 32px;
max-height: 120px;
background: var(--bg-chip);
color: var(--fg);
border: 1px solid var(--line);
border-radius: 9px;
padding: 7px 9px;
font: inherit;
outline: none;
user-select: text;
}
#input:focus {
border-color: var(--accent);
}
#input:disabled {
opacity: .55;
}
#send {
flex: 0 0 auto;
width: 32px;
height: 32px;
border-radius: 9px;
border: 1px solid var(--accent);
background: var(--accent);
color: #fff;
cursor: pointer;
display: grid;
place-items: center;
}
#send:disabled {
background: var(--bg-chip);
border-color: var(--line);
color: var(--fg-dim);
cursor: not-allowed;
}
#send svg {
width: 16px;
height: 16px;
}
/* ---------------- metrics ---------------- */
#metrics {
flex: 0 0 auto;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 2px 7px;
padding: 4px 8px 6px;
border-top: 1px solid var(--line);
background: #12151b;
font-size: 11px;
line-height: 1.5;
color: var(--fg-dim);
overflow: hidden;
}
#metrics .m {
white-space: nowrap;
}
#metrics .m b {
color: #c9d3e6;
font-weight: 600;
}
#metrics .sep {
color: #333a47;
}
#ctxwrap {
display: flex;
align-items: center;
gap: 5px;
flex: 0 0 auto;
}
#bar-ctx {
position: relative;
height: 4px;
border-radius: 3px;
background: #232936;
flex: 0 0 64px;
width: 64px;
overflow: hidden;
}
#bar-ctx i {
position: absolute;
inset: 0 100% 0 0;
background: linear-gradient(90deg, #3ecf8e, #f0b429 70%, #f2555a);
transition: right .3s ease;
}
/* ---------------- settings ---------------- */
#settings {
position: absolute;
right: 8px;
bottom: 52px;
width: min(300px, calc(100% - 16px));
background: #171b22;
border: 1px solid var(--line);
border-radius: 10px;
padding: 10px;
box-shadow: 0 12px 32px rgba(0, 0, 0, .5);
display: none;
z-index: 20;
}
#settings.show {
display: block;
}
#settings h4 {
margin: 0 0 8px;
font-size: 12px;
color: #c9d3e6;
}
#settings .srow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 5px 0;
}
#settings .srow span {
font-size: 12px;
}
#settings input[type=range] {
width: 132px;
accent-color: var(--accent);
}
#settings select {
background: var(--bg-chip);
color: var(--fg);
border: 1px solid var(--line);
border-radius: 7px;
padding: 3px 6px;
font: inherit;
outline: none;
}
#settings .note {
color: var(--fg-dim);
font-size: 11px;
line-height: 1.6;
border-top: 1px dashed var(--line);
margin-top: 6px;
padding-top: 6px;
}
.switch {
position: relative;
width: 32px;
height: 18px;
border-radius: 10px;
background: #2a3140;
cursor: pointer;
flex: 0 0 auto;
transition: background .15s;
}
.switch::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 14px;
height: 14px;
border-radius: 50%;
background: #8b93a5;
transition: transform .15s, background .15s;
}
.switch.on {
background: var(--accent-soft);
}
.switch.on::after {
transform: translateX(14px);
background: var(--accent);
}
/* ---------------- resize grip ---------------- */
#grip {
position: absolute;
right: 0;
bottom: 0;
width: 16px;
height: 16px;
cursor: nwse-resize;
z-index: 30;
background:
linear-gradient(135deg, transparent 46%, #3a4256 46%, #3a4256 56%, transparent 56%),
linear-gradient(135deg, transparent 66%, #2b3242 66%, #2b3242 76%, transparent 76%);
}
/* ---------------- adaptive ---------------- */
@container (max-width: 330px) {
.hide-narrow {
display: none;
}
}
@media (max-width: 350px) {
:root {
--pad: 8px;
--font: 12.5px;
}
.hide-narrow {
display: none;
}
}
@media (max-height: 320px) {
#mirror {
display: none;
}
}
@media (max-width: 250px) {
#metrics .m.opt {
display: none;
}
}
</style>
</head>
<body>
<div id="app">
<div id="bar">
<div class="dot wait" id="dot"></div>
<div id="title">DSH 悬浮窗<small id="sub">未连接</small></div>
<button class="tbtn" id="bPin" title="固定位置">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
<path d="M9 4h6l-1 6 3 3v1H7v-1l3-3-1-6z" />
<path d="M12 14v6" />
</svg>
</button>
<button class="tbtn" id="bLock" title="锁定尺寸">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
<rect x="5" y="10" width="14" height="10" rx="2" />
<path d="M8 10V7a4 4 0 0 1 8 0v3" />
</svg>
</button>
<button class="tbtn" id="bTop" title="始终置顶">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
<path d="M12 20V7" />
<path d="M6 12l6-6 6 6" />
</svg>
</button>
<button class="tbtn" id="bFull" title="全屏切换 (F11)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
<path d="M4 9V5h4M20 15v4h-4M20 9V5h-4M4 15v4h4" />
</svg>
</button>
<button class="tbtn" id="bSet" title="设置">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
<circle cx="12" cy="12" r="3" />
<path d="M12 3v2M12 19v2M3 12h2M19 12h2M5.6 5.6l1.4 1.4M17 17l1.4 1.4M18.4 5.6L17 7M7 17l-1.4 1.4" />
</svg>
</button>
<button class="tbtn" id="bClose" title="关闭">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
</div>
<div id="mirror">
<div class="hint" id="hint">
<b>等待 DSH 插件连接…</b><br />
浮窗已经就绪:拖动标题栏移动,拖动四周边缘或右下角改变尺寸。<br />
安装插件后这里会实时镜像会话:
<code>dsh plugin --profile desktop add link:&lt;repo&gt;</code> 然后重启 DSH。<br />
<span id="hintExclude">壳进程已启用 <b>截图排除</b>:Agent 抓屏时看不到这个浮窗。</span>
</div>
</div>
<div id="composer">
<textarea id="input" rows="1" placeholder="给会话发消息(连接后可编辑)" disabled></textarea>
<button id="send" disabled title="发送">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round">
<path d="M12 19V5M6 11l6-6 6 6" />
</svg>
</button>
</div>
<div id="metrics">
<div class="m" title="对话轮数">轮 <b id="m-turn">—</b></div>
<div class="sep">·</div>
<div class="m" title="工具/推理步数">步 <b id="m-step">—</b></div>
<div class="sep">·</div>
<div class="m" title="每秒输出 token 数"><b id="m-tps">—</b> tok/s</div>
<div class="sep">·</div>
<div class="m" title="累计 token"><b id="m-mtok">—</b> M tok</div>
<div class="sep opt">·</div>
<div class="m opt" title="缓存命中率">缓存 <b id="m-cache">—</b></div>
<div id="ctxwrap" title="上下文用量">
<span class="hide-narrow">上下文</span>
<div id="bar-ctx"><i id="m-ctxbar"></i></div>
<b id="m-ctx">—</b>
</div>
</div>
<div id="settings">
<h4>悬浮窗设置</h4>
<div class="srow"><span>背景效果</span>
<select id="sBackdrop">
<option value="opaque">不透明</option>
<option value="acrylic">毛玻璃(亚克力)</option>
<option value="mica">云母</option>
<option value="blur">模糊</option>
<option value="translucent">半透明</option>
</select>
</div>
<div class="srow"><span>不透明度</span>
<input type="range" id="sOpacity" min="30" max="100" step="1" value="92" />
</div>
<div class="srow"><span>界面缩放</span>
<input type="range" id="sScale" min="80" max="150" step="5" value="100" />
</div>
<div class="srow"><span>始终置顶</span>
<div class="switch on" id="sTop"></div>
</div>
<div class="srow"><span>固定位置</span>
<div class="switch" id="sPin"></div>
</div>
<div class="srow"><span>锁定尺寸</span>
<div class="switch" id="sLock"></div>
</div>
<div class="srow"><span>截图时隐藏本窗</span>
<div class="switch on" id="sExclude"></div>
</div>
<div class="note">
更多设置会同步注册到 <b>DeepSeek 设置 → 悬浮窗</b>(由 DSH 插件提供入口)。
</div>
</div>
<div id="grip" title="拖动改变尺寸"></div>
</div>
<script>
const $ = (id) => document.getElementById(id)
const bridge = window.chrome && window.chrome.webview ? window.chrome.webview : null
const send = (obj) => { if (bridge) bridge.postMessage(JSON.stringify(obj)) }
const S = { topmost: true, posLock: false, sizeLock: false, opacity: .92, exclude: true, scale: 1, fullscreen: false, backdrop: 'opaque' }
function paint() {
$('bPin').classList.toggle('on', S.posLock)
$('bLock').classList.toggle('on', S.sizeLock)
$('bTop').classList.toggle('on', S.topmost)
$('sTop').classList.toggle('on', S.topmost)
$('sPin').classList.toggle('on', S.posLock)
$('sLock').classList.toggle('on', S.sizeLock)
$('sExclude').classList.toggle('on', S.exclude)
$('sBackdrop').value = S.backdrop || 'opaque'
$('sOpacity').value = Math.round(S.opacity * 100)
$('sScale').value = Math.round(S.scale * 100)
// 玻璃/亚克力模式下窗口本身是透明的,页面用不透明度当"霜化浓度";
// 半透明模式由窗口级 alpha 负责,页面保持不透明以免二次变暗。
const glass = S.backdrop && S.backdrop !== 'opaque' && S.backdrop !== 'translucent'
document.documentElement.style.setProperty('--alpha', glass ? S.opacity : 1)
const he = $('hintExclude')
if (he) he.innerHTML = S.exclude
? '壳进程已启用 <b>截图排除</b>:Agent 抓屏时看不到这个浮窗。'
: '截图排除已关闭:当前浮窗会出现在截屏里。'
}
let es = null
function setEndpoint(ep) {
const live = !!(ep && ep.baseUrl)
$('dot').className = 'dot ' + (live ? 'live' : 'wait')
$('sub').textContent = live ? ep.baseUrl.replace(/^https?:\/\//, '') : '未连接'
$('input').disabled = !live
$('send').disabled = !live
document.body.classList.toggle('linked', live)
if (live) {
$('hint').style.display = 'none'
connect(ep)
}
}
/* ---- 与 DSH 插件的实时通道(安装插件后启用) ---- */
function connect(ep) {
const url = ep.baseUrl + '/dsh-float-window/events'
if (es && es.__url === url && es.readyState !== 2) return
if (es) { try { es.close() } catch (e) { } }
try {
es = new EventSource(url)
es.__url = url
es.onmessage = (ev) => {
try { render(JSON.parse(ev.data)) } catch (e) { }
}
es.onerror = () => { $('dot').className = 'dot wait' }
} catch (e) { }
}
function handleCmd(c) {
if (c === 'hide') document.body.style.visibility = 'hidden'
if (c === 'show') document.body.style.visibility = 'visible'
}
function render(msg) {
if (!msg) return
if (msg.type === 'cmd') return handleCmd(msg.cmd)
if (msg.metrics) metrics(msg.metrics)
if (msg.messages) messages(msg.messages)
if (msg.title) $('title').firstChild.textContent = msg.title
}
function metrics(m) {
$('m-turn').textContent = m.turns ?? '—'
$('m-step').textContent = m.steps ?? '—'
$('m-tps').textContent = (m.tps ?? 0).toFixed ? (m.tps ?? 0).toFixed(1) : '—'
$('m-mtok').textContent = ((m.tokens ?? 0) / 1e6).toFixed(3)
$('m-cache').textContent = m.cacheHit == null ? '—' : Math.round(m.cacheHit * 100) + '%'
const p = Math.max(0, Math.min(1, m.contextUsed ?? 0))
$('m-ctxbar').style.right = (100 - p * 100) + '%'
$('m-ctx').textContent = Math.round(p * 100) + '%'
}
function messages(list) {
const box = $('mirror')
box.innerHTML = ''
for (const m of list.slice(-80)) {
const row = document.createElement('div')
row.className = 'row ' + (m.role === 'user' ? 'user' : '')
const who = document.createElement('div'); who.className = 'who'
who.textContent = m.role === 'user' ? '你' : (m.role === 'tool' ? '工具' : 'DSH')
const what = document.createElement('div'); what.className = 'what'
if (m.role === 'tool') { const s = document.createElement('span'); s.className = 'tool'; s.textContent = m.text; what.appendChild(s) }
else { what.textContent = m.text; if (m.streaming) what.classList.add('blink') }
row.append(who, what); box.appendChild(row)
}
box.scrollTop = box.scrollHeight
}
/* ---------------- 交互 ---------------- */
const cmd = (c, extra) => send(Object.assign({ cmd: c }, extra || {}))
$('bar').addEventListener('mousedown', (e) => { if (e.target.closest('.tbtn')) return; cmd('drag') })
$('grip').addEventListener('mousedown', () => cmd('resize'))
$('bPin').onclick = () => cmd('posLock')
$('bLock').onclick = () => cmd('sizeLock')
$('bTop').onclick = () => cmd('topmost')
$('bFull').onclick = () => cmd('fullscreen')
$('bClose').onclick = () => cmd('close')
$('bSet').onclick = () => $('settings').classList.toggle('show')
$('sTop').onclick = () => cmd('topmost')
$('sPin').onclick = () => cmd('posLock')
$('sLock').onclick = () => cmd('sizeLock')
$('sExclude').onclick = () => cmd('exclude')
$('sBackdrop').onchange = (e) => { S.backdrop = e.target.value; paint(); cmd('backdrop', { value: S.backdrop }) }
$('sOpacity').oninput = (e) => { S.opacity = e.target.value / 100; paint(); cmd('opacity', { value: S.opacity }) }
$('sScale').oninput = (e) => { S.scale = e.target.value / 100; paint(); cmd('scale', { value: S.scale }) }
$('input').addEventListener('input', (e) => {
e.target.style.height = 'auto'
e.target.style.height = Math.min(120, e.target.scrollHeight) + 'px'
})
const submit = () => {
const v = $('input').value.trim()
if (!v) return
send({ cmd: 'chat', text: v })
$('input').value = ''; $('input').style.height = 'auto'
}
$('send').onclick = submit
$('input').addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); submit() }
})
document.addEventListener('keydown', (e) => {
if (e.key === 'F11') { e.preventDefault(); cmd('fullscreen') }
if (e.key === 'Escape') $('settings').classList.remove('show')
})
if (bridge) {
bridge.addEventListener('message', (ev) => {
const d = typeof ev.data === 'string' ? JSON.parse(ev.data) : ev.data
if (d.type === 'state') { Object.assign(S, d.state); paint() }
if (d.type === 'endpoint') setEndpoint(d)
if (d.type === 'metrics') metrics(d.metrics)
if (d.type === 'messages') messages(d.messages)
})
}
paint()
cmd('ready')
</script>
</body>
</html>
+20
View File
@@ -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"
+87
View File
@@ -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"
+51
View File
@@ -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')