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:
+18
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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`)
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -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
@@ -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:<repo></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>
|
||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
@@ -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')
|
||||||
Reference in New Issue
Block a user