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
+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>