Files
dsh-float-window/shell/float-window.ps1
T
jiangchen 8e4a648465 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 加载)
2026-09-27 14:02:58 +08:00

462 lines
22 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'
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()