白框根因: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
504 lines
25 KiB
PowerShell
504 lines
25 KiB
PowerShell
<#
|
||
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()
|