Files
dsh-float-window/shell/float-window.ps1
T
jiangchen 7d1417fa1c fix(shell): 去掉所有窗口级玻璃(白框/黑块根因);feat: 多会话切换 + 文件桥(桌面壳 HTTP 网关 403 的解法)
白框根因:SetWindowCompositionAttribute(Win10 亚克力) / DwmExtendFrameIntoClientArea(客户区变玻璃)
/ WebView2 Transparent 背景 三者在真机上都会让 8px 边环露白、WebView2 变黑。现在窗口永远由
应用自绘深色,背景效果只作为页面侧半透明(不再碰 DWM 与窗口 alpha)。

新增:
- 会话切换:标题栏 ⇄ 按钮 + 列表(会话标题/轮/步/消息数,可'跟随最新');宿主侧 pinnedSessionId,
  被选中的会话不再被其它会话的事件抢走;snapshot 里带 sessions[]
- 文件桥:桌面壳对未认证请求一律 403(实测 6 种请求形态),浮窗改走文件——宿主每秒写 snapshot.json,
  浮窗渲染;浮窗的 chat/watch/newSession 写 outbox.json,宿主轮询执行。端到端实测通过
- 编码修复:快照按显式 UTF-8 读(PS 5.1 Get-Content -Raw 会按 GBK 读坏中文 JSON);
  outbox 写 UTF-8 无 BOM
2026-09-27 17:02:07 +08:00

504 lines
25 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'
$outboxFile = Join-Path $DshHome 'float-window\outbox.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 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 } }
'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
Send-ToPage @{ type = 'snapshot'; data = ($s | ConvertFrom-Json) }
}
}
} 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'
$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()