Files
dsh-float-window/shell/float-window.ps1
T
jiangchen 6ee713b145 feat: 独立镜像桥(免 DSH 重启出镜像/指标)+ 去掉 IP 显示 + 上下文占比兜底
- tools/mirror-bridge.mjs:直接读 %DSH_HOME%/sessions/**/session.jsonl.zstd(多帧 zstd 按魔数切帧解)
  与 storages/session_projcache.json,算出 消息行/轮/步/tok·s⁻¹/token/上下文占比 并写
  snapshot-bridge.json(插件缺字段时由浮窗补齐);插件在写 snapshot.json 时它不抢
- 浮窗标题栏不再显示 endpoint IP,只在未连接时显示状态词;数据来自桥时指标带 ≈
- host: contextUsed 兜底按 1M 上下文算(真机 sessionProjection=n,拿不到上下文窗口)
- 实测:snapshot -> page rows=40 turns=1 steps=67 tok=214639;outbox watch 切会话生效
2026-09-27 17:20:14 +08:00

548 lines
28 KiB
PowerShell
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<#
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'
# 文件桥:插件每秒写 snapshot.json(桌面壳 HTTP 网关 403,浮窗只能走文件)
$snapshotFile = Join-Path $DshHome 'float-window\snapshot.json'
$bridgeSnapshotFile = Join-Path $DshHome 'float-window\snapshot-bridge.json'
$outboxFile = Join-Path $DshHome 'float-window\outbox.json'
$watchFile = Join-Path $DshHome 'float-window\watch.json'
# 独立镜像桥:直接读会话日志/投影缓存(DSH 还没重启、插件没在写快照时的兜底数据源)
$bridgeScript = Join-Path (Split-Path $PSScriptRoot -Parent) 'tools\mirror-bridge.mjs'
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 DwmGetWindowAttribute(IntPtr h, int attr, out 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
int got = -1;
int hrGet = DwmGetWindowAttribute(Handle, 38, out got, 4);
bool effective = mode == 0 ? true : (hr == 0 && hrGet == 0 && got == type);
// 关键 1:**不再**把客户区扩展成"玻璃"(真机上会让 8px 边环与 WebView2 露出白底)
// 关键 2:**不要**再用 SetWindowCompositionAttribute 的 ACCENT_ENABLE_ACRYLICBLURBEHIND,
// 那是 Win10 时代的未文档化做法,Win11 上会把窗口变成纯黑(实测 PrintWindow 全黑)。
// 现在窗口始终由应用自己画深色,系统背景只当装饰,"霜化"观感交给页面自己的半透明。
MARGINS m = new MARGINS();
DwmExtendFrameIntoClientArea(Handle, ref m);
Invalidate(true);
return "mode=" + mode + " hr=" + hr + " got=" + got + " effective=" + effective;
}
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);
}
// 始终由应用自己画背景(深色):玻璃模式下 DWM 若没生效也不会露出白底
protected override void OnPaintBackground(PaintEventArgs e)
{
base.OnPaintBackground(e);
}
protected override void WndProc(ref Message m)
{
const int WM_NCHITTEST = 0x84;
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 = $true # 默认抓屏隐身;-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)" }
}
# -NoExclude 只是本次运行的调试开关,不允许被写回状态文件(否则以后默认就不隐身了)
$script:persistExclude = [bool]$script:state.exclude
function Save-State {
try {
$out = [ordered]@{}
foreach ($k in $script:state.Keys) { $out[$k] = $script:state[$k] }
if ($NoExclude) { $out.exclude = $script:persistExclude }
($out | 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' }
# 真机结论:这台机器上任何"窗口级"的系统背景(DWM 系统背景类型 / Win10 亚克力 /
# 把客户区扩展成玻璃)都会让 WebView2 内容变黑、8px 边环露白底(用户原话"边框有白色的框")。
# 所以窗口**永远**保持应用自己绘制的深色,"背景效果"只作为页面侧的半透明/霜化观感,
# 页面 alpha 由不透明度滑杆控制。
$script:state.backdrop = $Mode
if ($wv.CoreWebView2) {
try { $wv.DefaultBackgroundColor = [System.Drawing.Color]::FromArgb(255, 15, 17, 21) } catch { }
}
Set-WindowAlpha 1.0
Log "backdrop $Mode (page-level only)"
Push-State
}
# 页面 -> 宿主:写 outbox.json(UTF-8 无 BOM,宿主侧 JSON.parse 才吃得下)
function Write-Outbox([hashtable]$Msg) {
try {
$Msg.at = (Get-Date).ToString('o')
$json = $Msg | ConvertTo-Json -Compress
[System.IO.File]::WriteAllText($outboxFile, $json, (New-Object System.Text.UTF8Encoding($false)))
Log "outbox $($Msg.cmd) ($($json.Length)B)"
} catch { Log "outbox write failed: $($_.Exception.Message)" }
}
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 { Log "endpoint parse failed: $($_.Exception.Message)" }
}
Log "push endpoint -> $($payload.baseUrl)"
Send-ToPage $payload
}
# ---------------------------------------------------------------- 页面消息处理
$onWebMessage = {
# 参数不能叫 $args —— 那是 PowerShell 的自动变量,会被整个事件参数数组占住,
# 于是 TryGetWebMessageAsString 找不到(实测:页面全部按钮失效、ready 握手丢失)。
param($s, $e)
try {
$msg = $e.TryGetWebMessageAsString()
if (-not $msg) { $msg = $e.WebMessageAsJson }
$m = $msg | ConvertFrom-Json
} catch { Log "webmessage parse failed: $($_.Exception.Message)"; return }
if ($m.cmd -and $m.cmd -ne 'drag' -and $m.cmd -ne 'resize') { Log "page cmd: $($m.cmd)" }
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() }
'chat' { Write-Outbox @{ cmd = 'chat'; text = [string]$m.text } }
'watch' {
Write-Outbox @{ cmd = 'watch'; sessionId = [string]$m.sessionId }
# 镜像桥也跟随同一个选择
try {
[System.IO.File]::WriteAllText($watchFile, (@{ sessionId = [string]$m.sessionId } | ConvertTo-Json -Compress), (New-Object System.Text.UTF8Encoding($false)))
} catch { }
}
'newSession' { Write-Outbox @{ cmd = 'session'; action = 'create' } }
'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' } }
'open' { if (-not $form.Visible) { $form.Show() }; $form.Activate(); $form.BringToFront(); Send-ToPage @{ type = 'cmd'; cmd = 'show' } }
default { Send-ToPage @{ type = 'cmd'; cmd = $c } }
}
}
$script:lastEndpointText = ''
$script:lastCommandText = ''
$script:lastSnapshotText = ''
$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 {}
# 文件桥:宿主每秒写 snapshot.json(桌面壳的 HTTP 网关会 403,浮窗只能走文件)
# 必须显式按 UTF-8 读:PS 5.1 的 Get-Content -Raw 会按 GBK 解码,中文摘要会破坏 JSON
try {
if (Test-Path $snapshotFile) {
$s = [System.IO.File]::ReadAllText($snapshotFile, [System.Text.Encoding]::UTF8)
if ($s -and $s -ne $script:lastSnapshotText) {
$script:lastSnapshotText = $s
$snap = $s | ConvertFrom-Json
# 用镜像桥的兜底快照补齐插件缺的字段(token/上下文估算、标题)
try {
if (Test-Path $bridgeSnapshotFile) {
$bs = [System.IO.File]::ReadAllText($bridgeSnapshotFile, [System.Text.Encoding]::UTF8) | ConvertFrom-Json
if ($bs -and $bs.sessionId -eq $snap.sessionId) {
if ($null -eq $snap.metrics.contextUsed) { $snap.metrics.contextUsed = $bs.metrics.contextUsed }
if ($null -eq $snap.metrics.tokens) { $snap.metrics.tokens = $bs.metrics.tokens }
if ($null -eq $snap.metrics.cacheHit) { $snap.metrics.cacheHit = $bs.metrics.cacheHit }
if ($null -eq $snap.metrics.tps -and $bs.metrics.tps) { $snap.metrics.tps = $bs.metrics.tps }
if (-not $snap.title -and $bs.title) { $snap.title = $bs.title }
if ($bs.approx) { $snap.approx = $true }
}
}
} catch { }
Send-ToPage @{ type = 'snapshot'; data = $snap }
# 每 ~5 秒记一行,用来确认数据真的到了页面
$now = Get-Date
if (-not $script:lastSnapLog -or ($now - $script:lastSnapLog).TotalSeconds -gt 5) {
$script:lastSnapLog = $now
Log "snapshot -> page: src=$($snap.source) rows=$(@($snap.messages).Count) turns=$($snap.metrics.turns) steps=$($snap.metrics.steps) tok=$($snap.metrics.tokens) ctx=$($snap.metrics.contextUsed) approx=$($snap.approx)"
}
}
}
} catch { Log "snapshot error: $($_.Exception.Message)" }
# 插件/命令行下发的动作
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'
# 独立镜像桥:以隐藏窗口跑起来(DSH 没重启时也能出镜像与指标)
try {
if (Test-Path $bridgeScript) {
$nodeExe = (Get-Command node -ErrorAction SilentlyContinue).Source
if ($nodeExe) {
$script:bridge = Start-Process -FilePath $nodeExe -ArgumentList @("`"$bridgeScript`"", '--home', "`"$DshHome`"", '--quiet') -WindowStyle Hidden -PassThru
Log "mirror bridge pid=$($script:bridge.Id)"
} else { Log 'node not found; mirror bridge skipped' }
} else { Log "bridge script missing: $bridgeScript" }
} catch { Log "bridge start failed: $($_.Exception.Message)" }
$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
try { if ($script:bridge -and -not $script:bridge.HasExited) { $script:bridge.Kill() } } catch { }
})
$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()