<# 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 目录, 默认 \shell\lib [string]$UiFile, # 浮窗 UI 文件, 默认 \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()